diff --git a/.gitignore b/.gitignore index 10a83f70f..00a929e22 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ bin/*.js /lib/ /_site/ -spec/*.js -spec/browser/*.js spec/result.xml spec/runner.html server/static/js/*.js diff --git a/CHANGES.md b/CHANGES.md index 4d97c2b40..dff4d3eb0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,11 +3,14 @@ NoFlo ChangeLog ## 1.2.0 (git master) +* Ported NoFlo from CoffeeScript to ES6 * Deprecated constructing networks with `new noflo.Network`. Use `noflo.createNetwork` instead, with the following options available: - `subscribeGraph: true`: Uses `LegacyNetwork` which modifies network topology based on changes in graph. This can cause some types of errors to be silent. - `subscribeGraph: false`: Uses `Network`: network topology can be changed with network's methods (`addNode`, `removeEdge`, etc) and will be also written to the graph. For backwards compatibility reasons, `subscribeGraph` defaults to `true`. Adapt your applications to use `false` instead and start utilizing Network methods for any changes to a running graph. * Added support for a more standard `noflo.createNetwork(graph, options, callback)` signature, with backwards compatibility for the legacy `noflo.createNetwork(graph, callback, options)` signature +* Removed support for `noflo.WirePattern`. WirePattern has been deprecated since 1.0, and all code using it should be migrated to the latest Process API +* Removed support for changing component icon and description statically (on class level) at run-time (i.e. `ComponentName::icon = 'new-icon'`). Component icon and description should be set in class constructor or in `getComponent` instead. Changing icon and description for a specific instance (process) is not affected and is fully supported ## 1.1.3 (April 12th 2018) diff --git a/Gruntfile.coffee b/Gruntfile.coffee deleted file mode 100644 index 376f15d1a..000000000 --- a/Gruntfile.coffee +++ /dev/null @@ -1,150 +0,0 @@ -module.exports = -> - # Project configuration - @initConfig - pkg: @file.readJSON 'package.json' - - # CoffeeScript compilation - coffee: - libraries: - options: - bare: true - expand: true - cwd: 'src/lib' - src: ['**.coffee'] - dest: 'lib' - ext: '.js' - components: - options: - bare: true - expand: true - cwd: 'src/components' - src: ['**.coffee'] - dest: 'components' - ext: '.js' - libraries_loaders: - options: - bare: true - expand: true - cwd: 'src/lib/loader' - src: ['**.coffee'] - dest: 'lib/loader' - ext: '.js' - spec: - options: - bare: true - transpile: - presets: ['es2015'] - expand: true - cwd: 'spec' - src: ['**.coffee'] - dest: 'spec' - ext: '.js' - - # Browser build of NoFlo - noflo_browser: - options: - baseDir: './' - webpack: - module: - rules: [ - test: /\.js$/, - use: [ - loader: 'babel-loader' - options: - presets: ['es2015'] - ] - ] - build: - files: - 'browser/noflo.js': ['spec/fixtures/entry.js'] - - # Automated recompilation and testing when developing - watch: - files: ['spec/*.coffee', 'spec/**/*.coffee', 'src/**/*.coffee'] - tasks: ['test:nodejs'] - - # BDD tests on Node.js - mochaTest: - nodejs: - src: ['spec/*.coffee'] - options: - reporter: 'spec' - require: [ - 'coffeescript/register' - ] - grep: process.env.TESTS - - # Web server for the browser tests - connect: - server: - options: - port: 8000 - - # Generate runner.html - noflo_browser_mocha: - all: - options: - scripts: [ - "../browser/<%=pkg.name%>.js" - "https://cdnjs.cloudflare.com/ajax/libs/coffee-script/1.7.1/coffee-script.min.js" - ] - files: - 'spec/runner.html': ['spec/*.js'] - # BDD tests on browser - mocha_phantomjs: - all: - options: - output: 'spec/result.xml' - reporter: 'spec' - urls: ['http://localhost:8000/spec/runner.html'] - failWithOutput: true - - # Coding standards - coffeelint: - libraries: - files: - src: ['src/lib/*.coffee'] - options: - max_line_length: - value: 80 - level: 'ignore' - no_trailing_semicolons: - level: 'warn' - components: - files: - src: ['src/components/*.coffee'] - options: - max_line_length: - value: 80 - level: 'ignore' - - # Grunt plugins used for building - @loadNpmTasks 'grunt-contrib-coffee' - @loadNpmTasks 'grunt-noflo-browser' - - # Grunt plugins used for testing - @loadNpmTasks 'grunt-contrib-watch' - @loadNpmTasks 'grunt-contrib-connect' - @loadNpmTasks 'grunt-mocha-test' - @loadNpmTasks 'grunt-mocha-phantomjs' - @loadNpmTasks 'grunt-coffeelint' - - # Our local tasks - @registerTask 'build', 'Build NoFlo for the chosen target platform', (target = 'all') => - @task.run 'coffee' - if target is 'all' or target is 'browser' - @task.run 'noflo_browser' - - @registerTask 'test', 'Build NoFlo and run automated tests', (target = 'all') => - @task.run 'coffeelint' - @task.run "build:#{target}" - if target is 'all' or target is 'nodejs' - # The components directory has to exist for Node.js 4.x - @file.mkdir 'components' - @task.run 'mochaTest' - if target is 'all' or target is 'browser' - @task.run 'noflo_browser_mocha' - @task.run 'connect' - @task.run 'mocha_phantomjs' - - @registerTask 'default', ['test'] diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 000000000..270448214 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,138 @@ +module.exports = function() { + // Project configuration + this.initConfig({ + pkg: this.file.readJSON('package.json'), + + // Copy plain JS files + babel: { + options: { + presets: ['env'] + }, + dist: { + files: [{ + cwd: 'src/lib/', + src: ['**/*.js'], + dest: 'lib/', + expand: true, + ext: '.js' + } + , { + cwd: 'src/components/', + src: ['**/*.js'], + dest: 'components/', + expand: true, + ext: '.js' + } + ] + } + }, + + // Browser build of NoFlo + noflo_browser: { + options: { + baseDir: './', + webpack: { + module: { + rules: [{ + test: /\.js$/, + use: [{ + loader: 'babel-loader', + options: { + presets: ['env'] + } + } + ] + } + ] + } + } + }, + build: { + files: { + 'browser/noflo.js': ['spec/fixtures/entry.js'] + } + } + }, + + // BDD tests on Node.js + mochaTest: { + nodejs: { + src: ['spec/*.js'], + options: { + reporter: 'spec', + grep: process.env.TESTS + } + } + }, + + // Web server for the browser tests + connect: { + server: { + options: { + port: 8000 + } + } + }, + + // Generate runner.html + noflo_browser_mocha: { + all: { + options: { + scripts: [ + "../browser/<%=pkg.name%>.js", + "https://cdnjs.cloudflare.com/ajax/libs/coffee-script/1.7.1/coffee-script.min.js" + ] + }, + files: { + 'spec/runner.html': ['spec/*.js'] + } + } + }, + // BDD tests on browser + mocha_phantomjs: { + all: { + options: { + output: 'spec/result.xml', + reporter: 'spec', + urls: ['http://localhost:8000/spec/runner.html'], + failWithOutput: true + } + } + } + }); + + // Grunt plugins used for building + this.loadNpmTasks('grunt-babel'); + this.loadNpmTasks('grunt-noflo-browser'); + + // Grunt plugins used for testing + this.loadNpmTasks('grunt-contrib-connect'); + this.loadNpmTasks('grunt-mocha-test'); + this.loadNpmTasks('grunt-mocha-phantomjs'); + + // Our local tasks + this.registerTask('build', 'Build NoFlo for the chosen target platform', target => { + if (target == null) { target = 'all'; } + this.task.run('babel'); + if ((target === 'all') || (target === 'browser')) { + this.task.run('noflo_browser'); + } + }); + + this.registerTask('test', 'Build NoFlo and run automated tests', target => { + if (target == null) { target = 'all'; } + this.task.run(`build:${target}`); + if ((target === 'all') || (target === 'nodejs')) { + // The components directory has to exist for Node.js 4.x + this.file.mkdir('components'); + this.task.run('mochaTest'); + } + if ((target === 'all') || (target === 'browser')) { + this.task.run('noflo_browser_mocha'); + this.task.run('connect'); + // this.task.run('mocha_phantomjs'); + } + }); + + this.registerTask('default', ['test']); +}; diff --git a/examples/http/HelloController.coffee b/examples/http/HelloController.coffee deleted file mode 100644 index e08b54237..000000000 --- a/examples/http/HelloController.coffee +++ /dev/null @@ -1,19 +0,0 @@ -noflo = require "noflo" - -exports.getComponent = -> - c = new noflo.Component - c.description = "Simple controller that says hello, user" - c.inPorts.add 'in', - datatype: 'object' - c.outPorts.add 'out', - datatype: 'object' - c.outPorts.add 'data', - datatype: 'object' - c.process (input, output) -> - return unless input.hasData 'in' - request = input.getData 'in' - output.sendDone - out: request - data: - locals: - string: "Hello, #{request.req.remoteUser}" diff --git a/examples/http/HelloController.js b/examples/http/HelloController.js new file mode 100644 index 000000000..e68d48163 --- /dev/null +++ b/examples/http/HelloController.js @@ -0,0 +1,25 @@ +const noflo = require("noflo"); + +exports.getComponent = () => { + const c = new noflo.Component; + c.description = "Simple controller that says hello, user"; + c.inPorts.add('in', + {datatype: 'object'}); + c.outPorts.add('out', + {datatype: 'object'}); + c.outPorts.add('data', + {datatype: 'object'}); + c.process((input, output) => { + if (!input.hasData('in')) { return; } + const request = input.getData('in'); + output.sendDone({ + out: request, + data: { + locals: { + string: `Hello, ${request.req.remoteUser}` + } + } + }); + }); + return c; +}; diff --git a/examples/http/hello.coffee b/examples/http/hello.coffee deleted file mode 100644 index d39bf872c..000000000 --- a/examples/http/hello.coffee +++ /dev/null @@ -1,33 +0,0 @@ -# Flow-based example of serving web pages - -noflo = require "noflo" - -graph = noflo.graph.createGraph "blog" - -graph.addNode "Web Server", "HTTP/Server" -graph.addNode "Profiler", "HTTP/Profiler" -graph.addNode "Authentication", "HTTP/BasicAuth" -graph.addNode "Read Template", "ReadFile" -graph.addNode "Greet User", require("./HelloController").getComponent() -graph.addNode "Render", "Template" -graph.addNode "Write Response", "HTTP/WriteResponse" -graph.addNode "Send", "HTTP/SendResponse" - -# Main request flow -graph.addInitial 8003, "Web Server", "listen" -graph.addEdge "Web Server", "request", "Profiler", "in" -graph.addEdge "Profiler", "out", "Authentication", "in" -graph.addEdge "Authentication", "out", "Greet User", "in" -graph.addEdge "Greet User", "out", "Write Response", "in" -graph.addEdge "Greet User", "data", "Render", "options" -graph.addEdge "Write Response", "out", "Send", "in" - -# Templating flow -graph.addInitial "#{__dirname}/hello.jade", "Read Template", "in" -graph.addEdge "Read Template", "out", "Render", "template" -graph.addEdge "Render", "out", "Write Response", "string" - -noflo.createNetwork graph, (err) -> - if err - console.error err - process.exit 1 diff --git a/examples/http/hello.js b/examples/http/hello.js new file mode 100644 index 000000000..f11382c92 --- /dev/null +++ b/examples/http/hello.js @@ -0,0 +1,35 @@ +// Flow-based example of serving web pages + +const noflo = require("noflo"); + +const graph = noflo.graph.createGraph("blog"); + +graph.addNode("Web Server", "HTTP/Server"); +graph.addNode("Profiler", "HTTP/Profiler"); +graph.addNode("Authentication", "HTTP/BasicAuth"); +graph.addNode("Read Template", "ReadFile"); +graph.addNode("Greet User", require("./HelloController").getComponent()); +graph.addNode("Render", "Template"); +graph.addNode("Write Response", "HTTP/WriteResponse"); +graph.addNode("Send", "HTTP/SendResponse"); + +// Main request flow +graph.addInitial(8003, "Web Server", "listen"); +graph.addEdge("Web Server", "request", "Profiler", "in"); +graph.addEdge("Profiler", "out", "Authentication", "in"); +graph.addEdge("Authentication", "out", "Greet User", "in"); +graph.addEdge("Greet User", "out", "Write Response", "in"); +graph.addEdge("Greet User", "data", "Render", "options"); +graph.addEdge("Write Response", "out", "Send", "in"); + +// Templating flow +graph.addInitial(`${__dirname}/hello.jade`, "Read Template", "in"); +graph.addEdge("Read Template", "out", "Render", "template"); +graph.addEdge("Render", "out", "Write Response", "string"); + +noflo.createNetwork(graph, (err) => { + if (err) { + console.error(err); + process.exit(1); + } +}); diff --git a/examples/linecount/count.coffee b/examples/linecount/count.coffee deleted file mode 100644 index d6acb67b9..000000000 --- a/examples/linecount/count.coffee +++ /dev/null @@ -1,29 +0,0 @@ -# Flow-based example of counting lines of a file, roughly equivalent to -# "wc -l " - -noflo = require "noflo" - -unless process.argv[2] - console.error "You must provide a filename" - process.exit 1 - -fileName = process.argv[2] - -graph = noflo.graph.createGraph "linecount" -graph.addNode "Read File", "ReadFile" -graph.addNode "Split by Lines", "SplitStr" -graph.addNode "Count Lines", "Counter" -graph.addNode "Display", "Output" - -graph.addEdge "Read File", "out", "Split by Lines", "in" -#graph.addEdge "Read File", "error", "Display", "in" -graph.addEdge "Split by Lines", "out", "Count Lines", "in" -graph.addEdge "Count Lines", "count", "Display", "in" - -# Kick the process off by sending filename to fileReader -graph.addInitial fileName, "Read File", "in" - -noflo.createNetwork graph, (err) -> - if err - console.error err - process.exit 1 diff --git a/examples/linecount/count.js b/examples/linecount/count.js new file mode 100644 index 000000000..5d2d0f02d --- /dev/null +++ b/examples/linecount/count.js @@ -0,0 +1,32 @@ +// Flow-based example of counting lines of a file, roughly equivalent to +// "wc -l " + +const noflo = require("noflo"); + +if (!process.argv[2]) { + console.error("You must provide a filename"); + process.exit(1); +} + +const fileName = process.argv[2]; + +const graph = noflo.graph.createGraph("linecount"); +graph.addNode("Read File", "ReadFile"); +graph.addNode("Split by Lines", "SplitStr"); +graph.addNode("Count Lines", "Counter"); +graph.addNode("Display", "Output"); + +graph.addEdge("Read File", "out", "Split by Lines", "in"); +//graph.addEdge "Read File", "error", "Display", "in" +graph.addEdge("Split by Lines", "out", "Count Lines", "in"); +graph.addEdge("Count Lines", "count", "Display", "in"); + +// Kick the process off by sending filename to fileReader +graph.addInitial(fileName, "Read File", "in"); + +noflo.createNetwork(graph, (err) => { + if (err) { + console.error(err); + process.exit(1); + } +}); diff --git a/package.json b/package.json index e6e90ac38..77ca998d8 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,15 @@ "devDependencies": { "babel-core": "^6.26.0", "babel-loader": "^7.1.2", - "babel-preset-es2015": "^6.24.1", + "babel-polyfill": "^6.26.0", + "babel-preset-env": "^1.7.0", "chai": "^4.0.0", "coveralls": "^3.0.0", + "eslint": "^7.2.0", + "eslint-config-airbnb-base": "^14.2.0", + "eslint-plugin-import": "^2.21.2", "grunt": "^1.0.1", + "grunt-babel": "^7.0.0", "grunt-cli": "~1.2.0", "grunt-coffeelint": "^0.0.16", "grunt-contrib-coffee": "^2.0.0", @@ -59,7 +64,7 @@ }, "nyc": { "include": [ - "src/**/*.coffee" + "src/**/*.js" ] } -} +} \ No newline at end of file diff --git a/spec/.eslintrc b/spec/.eslintrc new file mode 100644 index 000000000..6728c3ff0 --- /dev/null +++ b/spec/.eslintrc @@ -0,0 +1,20 @@ +{ + "extends": "airbnb-base", + "rules": { + "consistent-return": 0, + "default-case": 0, + "func-names": 0, + "global-require": 0, + "import/no-extraneous-dependencies": 0, + "import/no-unresolved": 0, + "no-console": 0, + "no-param-reassign": 0, + "no-plusplus": 0, + "no-restricted-syntax": 0, + "no-return-assign": 0, + "no-shadow": 0, + "no-undef": 0, + "no-unused-expressions": 0, + "prefer-destructuring": 0 + } +} \ No newline at end of file diff --git a/spec/AsCallback.coffee b/spec/AsCallback.coffee deleted file mode 100644 index b7e8abfba..000000000 --- a/spec/AsCallback.coffee +++ /dev/null @@ -1,356 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - path = require 'path' - root = path.resolve __dirname, '../' - urlPrefix = './' -else - noflo = require 'noflo' - root = 'noflo' - urlPrefix = '/' - -describe 'asCallback interface', -> - loader = null - - processAsync = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - c.process (input, output) -> - data = input.getData 'in' - setTimeout -> - output.sendDone data - , 1 - processError = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - c.outPorts.add 'error' - c.process (input, output) -> - data = input.getData 'in' - output.done new Error "Received #{data}" - processValues = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - values: ['green', 'blue'] - c.outPorts.add 'out', - datatype: 'string' - c.process (input, output) -> - data = input.getData 'in' - output.sendDone data - neverSend = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - c.process (input, output) -> - data = input.getData 'in' - streamify = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - c.process (input, output) -> - data = input.getData 'in' - words = data.split ' ' - for word, idx in words - output.send new noflo.IP 'openBracket', idx - chars = word.split '' - output.send new noflo.IP 'data', char for char in chars - output.send new noflo.IP 'closeBracket', idx - output.done() - - before (done) -> - loader = new noflo.ComponentLoader root - loader.listComponents (err) -> - return done err if err - loader.registerComponent 'process', 'Async', processAsync - loader.registerComponent 'process', 'Error', processError - loader.registerComponent 'process', 'Values', processValues - loader.registerComponent 'process', 'NeverSend', neverSend - loader.registerComponent 'process', 'Streamify', streamify - done() - describe 'with a non-existing component', -> - wrapped = null - before -> - wrapped = noflo.asCallback 'foo/Bar', - loader: loader - it 'should be able to wrap it', (done) -> - chai.expect(wrapped).to.be.a 'function' - chai.expect(wrapped.length).to.equal 2 - done() - it 'should fail execution', (done) -> - wrapped 1, (err) -> - chai.expect(err).to.be.an 'error' - done() - describe 'with simple asynchronous component', -> - wrapped = null - before -> - wrapped = noflo.asCallback 'process/Async', - loader: loader - it 'should be able to wrap it', (done) -> - chai.expect(wrapped).to.be.a 'function' - chai.expect(wrapped.length).to.equal 2 - done() - it 'should execute network with input map and provide output map', (done) -> - expected = - hello: 'world' - - wrapped - in: expected - , (err, out) -> - return done err if err - chai.expect(out.out).to.eql expected - done() - it 'should execute network with simple input and provide simple output', (done) -> - expected = - hello: 'world' - - wrapped expected, (err, out) -> - return done err if err - chai.expect(out).to.eql expected - done() - it 'should not mix up simultaneous runs', (done) -> - received = 0 - [0..100].forEach (idx) -> - wrapped idx, (err, out) -> - return done err if err - chai.expect(out).to.equal idx - received++ - return unless received is 101 - done() - it 'should execute a network with a sequence and provide output sequence', (done) -> - sent = [ - in: 'hello' - , - in: 'world' - , - in: 'foo' - , - in: 'bar' - ] - expected = sent.map (portmap) -> - return res = - out: portmap.in - wrapped sent, (err, out) -> - return done err if err - chai.expect(out).to.eql expected - done() - describe 'with the raw option', -> - it 'should execute a network with a sequence and provide output sequence', (done) -> - wrappedRaw = noflo.asCallback 'process/Async', - loader: loader - raw: true - sent = [ - in: new noflo.IP 'openBracket', 'a' - , - in: 'hello' - , - in: 'world' - , - in: new noflo.IP 'closeBracket', 'a' - , - in: new noflo.IP 'openBracket', 'b' - , - in: 'foo' - , - in: 'bar' - , - in: new noflo.IP 'closeBracket', 'b' - ] - wrappedRaw sent, (err, out) -> - return done err if err - types = out.map (map) -> "#{map.out.type} #{map.out.data}" - chai.expect(types).to.eql [ - 'openBracket a' - 'data hello' - 'data world' - 'closeBracket a' - 'openBracket b' - 'data foo' - 'data bar' - 'closeBracket b' - ] - done() - describe 'with a component sending an error', -> - wrapped = null - before -> - wrapped = noflo.asCallback 'process/Error', - loader: loader - it 'should execute network with input map and provide error', (done) -> - expected = 'hello there' - wrapped - in: expected - , (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain expected - done() - it 'should execute network with simple input and provide error', (done) -> - expected = 'hello world' - wrapped expected, (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain expected - done() - describe 'with a component supporting only certain values', -> - wrapped = null - before -> - wrapped = noflo.asCallback 'process/Values', - loader: loader - it 'should execute network with input map and provide output map', (done) -> - expected ='blue' - wrapped - in: expected - , (err, out) -> - return done err if err - chai.expect(out.out).to.eql expected - done() - it 'should execute network with simple input and provide simple output', (done) -> - expected = 'blue' - wrapped expected, (err, out) -> - return done err if err - chai.expect(out).to.eql expected - done() - it 'should execute network with wrong map and provide error', (done) -> - expected = 'red' - wrapped - in: 'red' - , (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'Invalid data=\'red\' received, not in [green,blue]' - done() - it 'should execute network with wrong input and provide error', (done) -> - wrapped 'red', (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'Invalid data=\'red\' received, not in [green,blue]' - done() - describe 'with a component sending streams', -> - wrapped = null - before -> - wrapped = noflo.asCallback 'process/Streamify', - loader: loader - return - it 'should execute network with input map and provide output map with streams as arrays', (done) -> - wrapped - in: 'hello world' - , (err, out) -> - chai.expect(out.out).to.eql [ - ['h','e','l','l','o'] - ['w','o','r','l','d'] - ] - done() - return - it 'should execute network with simple input and and provide simple output with streams as arrays', (done) -> - wrapped 'hello there', (err, out) -> - chai.expect(out).to.eql [ - ['h','e','l','l','o'] - ['t','h','e','r','e'] - ] - done() - return - describe 'with the raw option', -> - it 'should execute network with input map and provide output map with IP objects', (done) -> - wrappedRaw = noflo.asCallback 'process/Streamify', - loader: loader - raw: true - wrappedRaw - in: 'hello world' - , (err, out) -> - types = out.out.map (ip) -> "#{ip.type} #{ip.data}" - chai.expect(types).to.eql [ - 'openBracket 0' - 'data h' - 'data e' - 'data l' - 'data l' - 'data o' - 'closeBracket 0' - 'openBracket 1' - 'data w' - 'data o' - 'data r' - 'data l' - 'data d' - 'closeBracket 1' - ] - done() - describe 'with a graph instead of component name', -> - graph = null - wrapped = null - before (done) -> - noflo.graph.loadFBP """ - INPORT=Async.IN:IN - OUTPORT=Stream.OUT:OUT - Async(process/Async) OUT -> IN Stream(process/Streamify) - """, (err, g) -> - return done err if err - graph = g - wrapped = noflo.asCallback graph, - loader: loader - done() - it 'should execute network with input map and provide output map with streams as arrays', (done) -> - wrapped - in: 'hello world' - , (err, out) -> - return done err if err - chai.expect(out.out).to.eql [ - ['h','e','l','l','o'] - ['w','o','r','l','d'] - ] - done() - it 'should execute network with simple input and and provide simple output with streams as arrays', (done) -> - wrapped 'hello there', (err, out) -> - return done err if err - chai.expect(out).to.eql [ - ['h','e','l','l','o'] - ['t','h','e','r','e'] - ] - done() - describe 'with a graph containing a component supporting only certain values', -> - graph = null - wrapped = null - before (done) -> - noflo.graph.loadFBP """ - INPORT=Async.IN:IN - OUTPORT=Values.OUT:OUT - Async(process/Async) OUT -> IN Values(process/Values) - """, (err, g) -> - return done err if err - graph = g - wrapped = noflo.asCallback graph, - loader: loader - done() - it 'should execute network with input map and provide output map', (done) -> - expected ='blue' - wrapped - in: expected - , (err, out) -> - return done err if err - chai.expect(out.out).to.eql expected - done() - it 'should execute network with simple input and provide simple output', (done) -> - expected = 'blue' - wrapped expected, (err, out) -> - return done err if err - chai.expect(out).to.eql expected - done() - it 'should execute network with wrong map and provide error', (done) -> - expected = 'red' - wrapped - in: 'red' - , (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'Invalid data=\'red\' received, not in [green,blue]' - done() - it 'should execute network with wrong input and provide error', (done) -> - wrapped 'red', (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'Invalid data=\'red\' received, not in [green,blue]' - done() diff --git a/spec/AsCallback.js b/spec/AsCallback.js new file mode 100644 index 000000000..515e8c4d3 --- /dev/null +++ b/spec/AsCallback.js @@ -0,0 +1,470 @@ +let chai; let noflo; let root; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + const path = require('path'); + root = path.resolve(__dirname, '../'); +} else { + noflo = require('noflo'); + root = 'noflo'; +} + +describe('asCallback interface', () => { + let loader = null; + + const processAsync = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + + return c.process((input, output) => { + const data = input.getData('in'); + setTimeout(() => output.sendDone(data), + 1); + }); + }; + const processError = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + c.outPorts.add('error'); + return c.process((input, output) => { + const data = input.getData('in'); + output.done(new Error(`Received ${data}`)); + }); + }; + const processValues = function () { + const c = new noflo.Component(); + c.inPorts.add('in', { + datatype: 'string', + values: ['green', 'blue'], + }); + c.outPorts.add('out', + { datatype: 'string' }); + return c.process((input, output) => { + const data = input.getData('in'); + output.sendDone(data); + }); + }; + const neverSend = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + return c.process((input) => { + input.getData('in'); + }); + }; + const streamify = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + c.process((input, output) => { + const data = input.getData('in'); + const words = data.split(' '); + for (let idx = 0; idx < words.length; idx++) { + const word = words[idx]; + output.send(new noflo.IP('openBracket', idx)); + const chars = word.split(''); + for (const char of chars) { output.send(new noflo.IP('data', char)); } + output.send(new noflo.IP('closeBracket', idx)); + } + output.done(); + }); + return c; + }; + + before((done) => { + loader = new noflo.ComponentLoader(root); + loader.listComponents((err) => { + if (err) { + done(err); + return; + } + loader.registerComponent('process', 'Async', processAsync); + loader.registerComponent('process', 'Error', processError); + loader.registerComponent('process', 'Values', processValues); + loader.registerComponent('process', 'NeverSend', neverSend); + loader.registerComponent('process', 'Streamify', streamify); + done(); + }); + }); + describe('with a non-existing component', () => { + let wrapped = null; + before(() => { + wrapped = noflo.asCallback('foo/Bar', + { loader }); + }); + it('should be able to wrap it', (done) => { + chai.expect(wrapped).to.be.a('function'); + chai.expect(wrapped.length).to.equal(2); + done(); + }); + it('should fail execution', (done) => { + wrapped(1, (err) => { + chai.expect(err).to.be.an('error'); + done(); + }); + }); + }); + describe('with simple asynchronous component', () => { + let wrapped = null; + before(() => { + wrapped = noflo.asCallback('process/Async', + { loader }); + }); + it('should be able to wrap it', (done) => { + chai.expect(wrapped).to.be.a('function'); + chai.expect(wrapped.length).to.equal(2); + done(); + }); + it('should execute network with input map and provide output map', (done) => { + const expected = { hello: 'world' }; + + wrapped( + { in: expected }, + (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out.out).to.eql(expected); + done(); + }, + ); + }); + it('should execute network with simple input and provide simple output', (done) => { + const expected = { hello: 'world' }; + + wrapped(expected, (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out).to.eql(expected); + done(); + }); + }); + it('should not mix up simultaneous runs', (done) => { + let received = 0; + for (let idx = 0; idx <= 100; idx += 1) { + /* eslint-disable no-loop-func */ + wrapped(idx, (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out).to.equal(idx); + received++; + if (received !== 101) { return; } + done(); + }); + } + }); + it('should execute a network with a sequence and provide output sequence', (done) => { + const sent = [ + { in: 'hello' }, + { in: 'world' }, + { in: 'foo' }, + { in: 'bar' }, + ]; + const expected = sent.map((portmap) => ({ out: portmap.in })); + wrapped(sent, (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out).to.eql(expected); + done(); + }); + }); + describe('with the raw option', () => { + it('should execute a network with a sequence and provide output sequence', (done) => { + const wrappedRaw = noflo.asCallback('process/Async', { + loader, + raw: true, + }); + const sent = [ + { in: new noflo.IP('openBracket', 'a') }, + { in: 'hello' }, + { in: 'world' }, + { in: new noflo.IP('closeBracket', 'a') }, + { in: new noflo.IP('openBracket', 'b') }, + { in: 'foo' }, + { in: 'bar' }, + { in: new noflo.IP('closeBracket', 'b') }, + ]; + wrappedRaw(sent, (err, out) => { + if (err) { + done(err); + return; + } + const types = out.map((map) => `${map.out.type} ${map.out.data}`); + chai.expect(types).to.eql([ + 'openBracket a', + 'data hello', + 'data world', + 'closeBracket a', + 'openBracket b', + 'data foo', + 'data bar', + 'closeBracket b', + ]); + done(); + }); + }); + }); + }); + describe('with a component sending an error', () => { + let wrapped = null; + before(() => { + wrapped = noflo.asCallback('process/Error', + { loader }); + }); + it('should execute network with input map and provide error', (done) => { + const expected = 'hello there'; + wrapped( + { in: expected }, + (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain(expected); + done(); + }, + ); + }); + it('should execute network with simple input and provide error', (done) => { + const expected = 'hello world'; + wrapped(expected, (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain(expected); + done(); + }); + }); + }); + describe('with a component supporting only certain values', () => { + let wrapped = null; + before(() => { + wrapped = noflo.asCallback('process/Values', + { loader }); + }); + it('should execute network with input map and provide output map', (done) => { + const expected = 'blue'; + wrapped( + { in: expected }, + (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out.out).to.eql(expected); + done(); + }, + ); + }); + it('should execute network with simple input and provide simple output', (done) => { + const expected = 'blue'; + wrapped(expected, (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out).to.eql(expected); + done(); + }); + }); + it('should execute network with wrong map and provide error', (done) => { + wrapped( + { in: 'red' }, + (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('Invalid data=\'red\' received, not in [green,blue]'); + done(); + }, + ); + }); + it('should execute network with wrong input and provide error', (done) => { + wrapped('red', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('Invalid data=\'red\' received, not in [green,blue]'); + done(); + }); + }); + }); + describe('with a component sending streams', () => { + let wrapped = null; + before(() => { + wrapped = noflo.asCallback('process/Streamify', + { loader }); + }); + it('should execute network with input map and provide output map with streams as arrays', (done) => { + wrapped( + { in: 'hello world' }, + (err, out) => { + chai.expect(out.out).to.eql([ + ['h', 'e', 'l', 'l', 'o'], + ['w', 'o', 'r', 'l', 'd'], + ]); + done(); + }, + ); + }); + it('should execute network with simple input and and provide simple output with streams as arrays', (done) => { + wrapped('hello there', (err, out) => { + chai.expect(out).to.eql([ + ['h', 'e', 'l', 'l', 'o'], + ['t', 'h', 'e', 'r', 'e'], + ]); + done(); + }); + }); + describe('with the raw option', () => { + it('should execute network with input map and provide output map with IP objects', (done) => { + const wrappedRaw = noflo.asCallback('process/Streamify', { + loader, + raw: true, + }); + wrappedRaw( + { in: 'hello world' }, + (err, out) => { + const types = out.out.map((ip) => `${ip.type} ${ip.data}`); + chai.expect(types).to.eql([ + 'openBracket 0', + 'data h', + 'data e', + 'data l', + 'data l', + 'data o', + 'closeBracket 0', + 'openBracket 1', + 'data w', + 'data o', + 'data r', + 'data l', + 'data d', + 'closeBracket 1', + ]); + done(); + }, + ); + }); + }); + }); + describe('with a graph instead of component name', () => { + let graph = null; + let wrapped = null; + before((done) => { + noflo.graph.loadFBP(`\ +INPORT=Async.IN:IN +OUTPORT=Stream.OUT:OUT +Async(process/Async) OUT -> IN Stream(process/Streamify)\ +`, (err, g) => { + if (err) { + done(err); + return; + } + graph = g; + wrapped = noflo.asCallback(graph, + { loader }); + done(); + }); + }); + it('should execute network with input map and provide output map with streams as arrays', (done) => { + wrapped( + { in: 'hello world' }, + (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out.out).to.eql([ + ['h', 'e', 'l', 'l', 'o'], + ['w', 'o', 'r', 'l', 'd'], + ]); + done(); + }, + ); + }); + it('should execute network with simple input and and provide simple output with streams as arrays', (done) => { + wrapped('hello there', (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out).to.eql([ + ['h', 'e', 'l', 'l', 'o'], + ['t', 'h', 'e', 'r', 'e'], + ]); + done(); + }); + }); + }); + describe('with a graph containing a component supporting only certain values', () => { + let graph = null; + let wrapped = null; + before((done) => { + noflo.graph.loadFBP(`\ +INPORT=Async.IN:IN +OUTPORT=Values.OUT:OUT +Async(process/Async) OUT -> IN Values(process/Values)\ +`, (err, g) => { + if (err) { + done(err); + return; + } + graph = g; + wrapped = noflo.asCallback(graph, + { loader }); + done(); + }); + }); + it('should execute network with input map and provide output map', (done) => { + const expected = 'blue'; + wrapped( + { in: expected }, + (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out.out).to.eql(expected); + done(); + }, + ); + }); + it('should execute network with simple input and provide simple output', (done) => { + const expected = 'blue'; + wrapped(expected, (err, out) => { + if (err) { + done(err); + return; + } + chai.expect(out).to.eql(expected); + done(); + }); + }); + it('should execute network with wrong map and provide error', (done) => { + wrapped( + { in: 'red' }, + (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('Invalid data=\'red\' received, not in [green,blue]'); + done(); + }, + ); + }); + it('should execute network with wrong input and provide error', (done) => { + wrapped('red', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('Invalid data=\'red\' received, not in [green,blue]'); + done(); + }); + }); + }); +}); diff --git a/spec/AsComponent.coffee b/spec/AsComponent.coffee deleted file mode 100644 index 4e2192db1..000000000 --- a/spec/AsComponent.coffee +++ /dev/null @@ -1,265 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - path = require 'path' - root = path.resolve __dirname, '../' - urlPrefix = './' - isBrowser = false -else - noflo = require 'noflo' - root = 'noflo' - urlPrefix = '/' - isBrowser = true - -describe 'asComponent interface', -> - loader = null - before (done) -> - loader = new noflo.ComponentLoader root - loader.listComponents done - describe 'with a synchronous function taking a single parameter', -> - describe 'with returned value', -> - func = (hello) -> - return "Hello #{hello}" - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'sync-one', component, done - it 'should be loadable', (done) -> - loader.load 'ascomponent/sync-one', done - it 'should contain correct ports', (done) -> - loader.load 'ascomponent/sync-one', (err, instance) -> - return done err if err - chai.expect(Object.keys(instance.inPorts.ports)).to.eql ['hello'] - chai.expect(Object.keys(instance.outPorts.ports)).to.eql ['out', 'error'] - done() - it 'should send to OUT port', (done) -> - wrapped = noflo.asCallback 'ascomponent/sync-one', - loader: loader - wrapped 'World', (err, res) -> - return done err if err - chai.expect(res).to.equal 'Hello World' - done() - it 'should forward brackets to OUT port', (done) -> - loader.load 'ascomponent/sync-one', (err, instance) -> - return done err if err - ins = new noflo.internalSocket.createSocket() - out = new noflo.internalSocket.createSocket() - error = new noflo.internalSocket.createSocket() - instance.inPorts.hello.attach ins - instance.outPorts.out.attach out - instance.outPorts.error.attach error - received = [] - expected = [ - 'openBracket a' - 'data Hello Foo' - 'data Hello Bar' - 'data Hello Baz' - 'closeBracket a' - ] - error.once 'data', (data) -> - done data - out.on 'ip', (ip) -> - received.push "#{ip.type} #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - ins.post new noflo.IP 'openBracket', 'a' - ins.post new noflo.IP 'data', 'Foo' - ins.post new noflo.IP 'data', 'Bar' - ins.post new noflo.IP 'data', 'Baz' - ins.post new noflo.IP 'closeBracket', 'a' - describe 'with returned NULL', -> - func = (hello) -> - return null - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'sync-null', component, done - it 'should send to OUT port', (done) -> - wrapped = noflo.asCallback 'ascomponent/sync-null', - loader: loader - wrapped 'World', (err, res) -> - return done err if err - chai.expect(res).to.be.a 'null' - done() - describe 'with a thrown exception', -> - func = (hello) -> - throw new Error "Hello #{hello}" - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'sync-throw', component, done - it 'should send to ERROR port', (done) -> - wrapped = noflo.asCallback 'ascomponent/sync-throw', - loader: loader - wrapped 'Error', (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.equal 'Hello Error' - done() - describe 'with a synchronous function taking a multiple parameters', -> - describe 'with returned value', -> - func = (greeting, name) -> - return "#{greeting} #{name}" - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'sync-two', component, done - it 'should be loadable', (done) -> - loader.load 'ascomponent/sync-two', done - it 'should contain correct ports', (done) -> - loader.load 'ascomponent/sync-two', (err, instance) -> - return done err if err - chai.expect(Object.keys(instance.inPorts.ports)).to.eql ['greeting', 'name'] - chai.expect(Object.keys(instance.outPorts.ports)).to.eql ['out', 'error'] - done() - it 'should send to OUT port', (done) -> - wrapped = noflo.asCallback 'ascomponent/sync-two', - loader: loader - wrapped - greeting: 'Hei' - name: 'Maailma' - , (err, res) -> - return done err if err - chai.expect(res).to.eql - out: 'Hei Maailma' - done() - describe 'with a default value', -> - before -> - @skip() if isBrowser # Browser runs with ES5 which didn't have defaults - func = (name, greeting = 'Hello') -> - return "#{greeting} #{name}" - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'sync-default', component, done - it 'should be loadable', (done) -> - loader.load 'ascomponent/sync-default', done - it 'should contain correct ports', (done) -> - loader.load 'ascomponent/sync-default', (err, instance) -> - return done err if err - chai.expect(Object.keys(instance.inPorts.ports)).to.eql ['name', 'greeting'] - chai.expect(Object.keys(instance.outPorts.ports)).to.eql ['out', 'error'] - chai.expect(instance.inPorts.name.isRequired()).to.equal true - chai.expect(instance.inPorts.name.hasDefault()).to.equal false - chai.expect(instance.inPorts.greeting.isRequired()).to.equal false - chai.expect(instance.inPorts.greeting.hasDefault()).to.equal true - done() - it 'should send to OUT port', (done) -> - wrapped = noflo.asCallback 'ascomponent/sync-default', - loader: loader - wrapped - name: 'Maailma' - , (err, res) -> - return done err if err - chai.expect(res).to.eql - out: 'Hello Maailma' - done() - describe 'with a function returning a Promise', -> - describe 'with a resolved promise', -> - before -> - @skip() if isBrowser and typeof window.Promise is 'undefined' - func = (hello) -> - return new Promise (resolve, reject) -> - setTimeout -> - resolve "Hello #{hello}" - , 5 - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'promise-one', component, done - it 'should send to OUT port', (done) -> - wrapped = noflo.asCallback 'ascomponent/promise-one', - loader: loader - wrapped 'World', (err, res) -> - return done err if err - chai.expect(res).to.equal 'Hello World' - done() - describe 'with a rejected promise', -> - before -> - if isBrowser and typeof window.Promise is 'undefined' - return @skip() - func = (hello) -> - return new Promise (resolve, reject) -> - setTimeout -> - reject new Error "Hello #{hello}" - , 5 - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'sync-throw', component, done - it 'should send to ERROR port', (done) -> - wrapped = noflo.asCallback 'ascomponent/sync-throw', - loader: loader - wrapped 'Error', (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.equal 'Hello Error' - done() - describe 'with a synchronous function taking zero parameters', -> - describe 'with returned value', -> - func = () -> - return "Hello there" - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'sync-zero', component, done - it 'should contain correct ports', (done) -> - loader.load 'ascomponent/sync-zero', (err, instance) -> - return done err if err - chai.expect(Object.keys(instance.inPorts.ports)).to.eql ['in'] - chai.expect(Object.keys(instance.outPorts.ports)).to.eql ['out', 'error'] - done() - it 'should send to OUT port', (done) -> - wrapped = noflo.asCallback 'ascomponent/sync-zero', - loader: loader - wrapped 'bang', (err, res) -> - return done err if err - chai.expect(res).to.equal 'Hello there' - done() - describe 'with a built-in function', -> - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent Math.random - loader.registerComponent 'ascomponent', 'sync-zero', component, done - it 'should contain correct ports', (done) -> - loader.load 'ascomponent/sync-zero', (err, instance) -> - return done err if err - chai.expect(Object.keys(instance.inPorts.ports)).to.eql ['in'] - chai.expect(Object.keys(instance.outPorts.ports)).to.eql ['out', 'error'] - done() - it 'should send to OUT port', (done) -> - wrapped = noflo.asCallback 'ascomponent/sync-zero', - loader: loader - wrapped 'bang', (err, res) -> - return done err if err - chai.expect(res).to.be.a 'number' - done() - describe 'with an asynchronous function taking a single parameter and callback', -> - describe 'with successful callback', -> - func = (hello, callback) -> - setTimeout -> - callback null, "Hello #{hello}" - , 5 - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'async-one', component, done - it 'should be loadable', (done) -> - loader.load 'ascomponent/async-one', done - it 'should contain correct ports', (done) -> - loader.load 'ascomponent/async-one', (err, instance) -> - return done err if err - chai.expect(Object.keys(instance.inPorts.ports)).to.eql ['hello'] - chai.expect(Object.keys(instance.outPorts.ports)).to.eql ['out', 'error'] - done() - it 'should send to OUT port', (done) -> - wrapped = noflo.asCallback 'ascomponent/async-one', - loader: loader - wrapped 'World', (err, res) -> - return done err if err - chai.expect(res).to.equal 'Hello World' - done() - describe 'with failed callback', -> - func = (hello, callback) -> - setTimeout -> - callback new Error "Hello #{hello}" - , 5 - it 'should be possible to componentize', (done) -> - component = -> noflo.asComponent func - loader.registerComponent 'ascomponent', 'async-throw', component, done - it 'should send to ERROR port', (done) -> - wrapped = noflo.asCallback 'ascomponent/async-throw', - loader: loader - wrapped 'Error', (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.equal 'Hello Error' - done() diff --git a/spec/AsComponent.js b/spec/AsComponent.js new file mode 100644 index 000000000..6940ba994 --- /dev/null +++ b/spec/AsComponent.js @@ -0,0 +1,372 @@ +let chai; let isBrowser; let noflo; let root; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + const path = require('path'); + root = path.resolve(__dirname, '../'); + isBrowser = false; +} else { + noflo = require('noflo'); + root = 'noflo'; + isBrowser = true; +} + +describe('asComponent interface', () => { + let loader = null; + before((done) => { + loader = new noflo.ComponentLoader(root); + loader.listComponents(done); + }); + describe('with a synchronous function taking a single parameter', () => { + describe('with returned value', () => { + const func = (hello) => `Hello ${hello}`; + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'sync-one', component, done); + }); + it('should be loadable', (done) => { + loader.load('ascomponent/sync-one', done); + }); + it('should contain correct ports', (done) => { + loader.load('ascomponent/sync-one', (err, instance) => { + if (err) { + done(err); + return; + } + chai.expect(Object.keys(instance.inPorts.ports)).to.eql(['hello']); + chai.expect(Object.keys(instance.outPorts.ports)).to.eql(['out', 'error']); + done(); + }); + }); + it('should send to OUT port', (done) => { + const wrapped = noflo.asCallback('ascomponent/sync-one', + { loader }); + wrapped('World', (err, res) => { + if (err) { + done(err); + return; + } + chai.expect(res).to.equal('Hello World'); + done(); + }); + }); + it('should forward brackets to OUT port', (done) => { + loader.load('ascomponent/sync-one', (err, instance) => { + if (err) { + done(err); + return; + } + const ins = noflo.internalSocket.createSocket(); + const out = noflo.internalSocket.createSocket(); + const error = noflo.internalSocket.createSocket(); + instance.inPorts.hello.attach(ins); + instance.outPorts.out.attach(out); + instance.outPorts.error.attach(error); + const received = []; + const expected = [ + 'openBracket a', + 'data Hello Foo', + 'data Hello Bar', + 'data Hello Baz', + 'closeBracket a', + ]; + error.once('data', (data) => done(data)); + out.on('ip', (ip) => { + received.push(`${ip.type} ${ip.data}`); + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + ins.post(new noflo.IP('openBracket', 'a')); + ins.post(new noflo.IP('data', 'Foo')); + ins.post(new noflo.IP('data', 'Bar')); + ins.post(new noflo.IP('data', 'Baz')); + ins.post(new noflo.IP('closeBracket', 'a')); + }); + }); + }); + describe('with returned NULL', () => { + const func = () => null; + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'sync-null', component, done); + }); + it('should send to OUT port', (done) => { + const wrapped = noflo.asCallback('ascomponent/sync-null', + { loader }); + wrapped('World', (err, res) => { + if (err) { + done(err); + return; + } + chai.expect(res).to.be.a('null'); + done(); + }); + }); + }); + describe('with a thrown exception', () => { + const func = function (hello) { + throw new Error(`Hello ${hello}`); + }; + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'sync-throw', component, done); + }); + it('should send to ERROR port', (done) => { + const wrapped = noflo.asCallback('ascomponent/sync-throw', + { loader }); + wrapped('Error', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.equal('Hello Error'); + done(); + }); + }); + }); + }); + describe('with a synchronous function taking a multiple parameters', () => { + describe('with returned value', () => { + const func = (greeting, name) => `${greeting} ${name}`; + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'sync-two', component, done); + }); + it('should be loadable', (done) => { + loader.load('ascomponent/sync-two', done); + }); + it('should contain correct ports', (done) => { + loader.load('ascomponent/sync-two', (err, instance) => { + if (err) { + done(err); + return; + } + chai.expect(Object.keys(instance.inPorts.ports)).to.eql(['greeting', 'name']); + chai.expect(Object.keys(instance.outPorts.ports)).to.eql(['out', 'error']); + done(); + }); + }); + it('should send to OUT port', (done) => { + const wrapped = noflo.asCallback('ascomponent/sync-two', + { loader }); + wrapped({ + greeting: 'Hei', + name: 'Maailma', + }, + (err, res) => { + if (err) { + done(err); + return; + } + chai.expect(res).to.eql({ out: 'Hei Maailma' }); + done(); + }); + }); + }); + describe('with a default value', () => { + before(function () { + if (isBrowser) { return this.skip(); } + }); // Browser runs with ES5 which didn't have defaults + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent((name, greeting = 'Hello') => `${greeting} ${name}`); + loader.registerComponent('ascomponent', 'sync-default', component, done); + }); + it('should be loadable', (done) => { + loader.load('ascomponent/sync-default', done); + }); + it('should contain correct ports', (done) => { + loader.load('ascomponent/sync-default', (err, instance) => { + if (err) { + done(err); + return; + } + chai.expect(Object.keys(instance.inPorts.ports)).to.eql(['name', 'greeting']); + chai.expect(Object.keys(instance.outPorts.ports)).to.eql(['out', 'error']); + chai.expect(instance.inPorts.name.isRequired()).to.equal(true); + chai.expect(instance.inPorts.name.hasDefault()).to.equal(false); + chai.expect(instance.inPorts.greeting.isRequired()).to.equal(false); + chai.expect(instance.inPorts.greeting.hasDefault()).to.equal(true); + done(); + }); + }); + it('should send to OUT port', (done) => { + const wrapped = noflo.asCallback('ascomponent/sync-default', + { loader }); + wrapped( + { name: 'Maailma' }, + (err, res) => { + if (err) { + done(err); + return; + } + chai.expect(res).to.eql({ out: 'Hello Maailma' }); + done(); + }, + ); + }); + }); + }); + describe('with a function returning a Promise', () => { + describe('with a resolved promise', () => { + before(function () { + if (isBrowser && (typeof window.Promise === 'undefined')) { return this.skip(); } + }); + const func = (hello) => new Promise((resolve) => setTimeout(() => resolve(`Hello ${hello}`), + 5)); + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'promise-one', component, done); + }); + it('should send to OUT port', (done) => { + const wrapped = noflo.asCallback('ascomponent/promise-one', + { loader }); + wrapped('World', (err, res) => { + if (err) { + done(err); + return; + } + chai.expect(res).to.equal('Hello World'); + done(); + }); + }); + }); + describe('with a rejected promise', () => { + before(function () { + if (isBrowser && (typeof window.Promise === 'undefined')) { + this.skip(); + } + }); + const func = (hello) => new Promise((resolve, reject) => setTimeout(() => reject(new Error(`Hello ${hello}`)), + 5)); + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'sync-throw', component, done); + }); + it('should send to ERROR port', (done) => { + const wrapped = noflo.asCallback('ascomponent/sync-throw', + { loader }); + wrapped('Error', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.equal('Hello Error'); + done(); + }); + }); + }); + }); + describe('with a synchronous function taking zero parameters', () => { + describe('with returned value', () => { + const func = () => 'Hello there'; + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'sync-zero', component, done); + }); + it('should contain correct ports', (done) => { + loader.load('ascomponent/sync-zero', (err, instance) => { + if (err) { + done(err); + return; + } + chai.expect(Object.keys(instance.inPorts.ports)).to.eql(['in']); + chai.expect(Object.keys(instance.outPorts.ports)).to.eql(['out', 'error']); + done(); + }); + }); + it('should send to OUT port', (done) => { + const wrapped = noflo.asCallback('ascomponent/sync-zero', + { loader }); + wrapped('bang', (err, res) => { + if (err) { + done(err); + return; + } + chai.expect(res).to.equal('Hello there'); + done(); + }); + }); + }); + describe('with a built-in function', () => { + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(Math.random); + loader.registerComponent('ascomponent', 'sync-zero', component, done); + }); + it('should contain correct ports', (done) => { + loader.load('ascomponent/sync-zero', (err, instance) => { + if (err) { + done(err); + return; + } + chai.expect(Object.keys(instance.inPorts.ports)).to.eql(['in']); + chai.expect(Object.keys(instance.outPorts.ports)).to.eql(['out', 'error']); + done(); + }); + }); + it('should send to OUT port', (done) => { + const wrapped = noflo.asCallback('ascomponent/sync-zero', + { loader }); + wrapped('bang', (err, res) => { + if (err) { + done(err); + return; + } + chai.expect(res).to.be.a('number'); + done(); + }); + }); + }); + }); + describe('with an asynchronous function taking a single parameter and callback', () => { + describe('with successful callback', () => { + const func = function (hello, callback) { + setTimeout(() => callback(null, `Hello ${hello}`), + 5); + }; + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'async-one', component, done); + }); + it('should be loadable', (done) => { + loader.load('ascomponent/async-one', done); + }); + it('should contain correct ports', (done) => { + loader.load('ascomponent/async-one', (err, instance) => { + if (err) { + done(err); + return; + } + chai.expect(Object.keys(instance.inPorts.ports)).to.eql(['hello']); + chai.expect(Object.keys(instance.outPorts.ports)).to.eql(['out', 'error']); + done(); + }); + }); + it('should send to OUT port', (done) => { + const wrapped = noflo.asCallback('ascomponent/async-one', + { loader }); + wrapped('World', (err, res) => { + if (err) { + done(err); + return; + } + chai.expect(res).to.equal('Hello World'); + done(); + }); + }); + }); + describe('with failed callback', () => { + const func = function (hello, callback) { + setTimeout(() => callback(new Error(`Hello ${hello}`)), + 5); + }; + it('should be possible to componentize', (done) => { + const component = () => noflo.asComponent(func); + loader.registerComponent('ascomponent', 'async-throw', component, done); + }); + it('should send to ERROR port', (done) => { + const wrapped = noflo.asCallback('ascomponent/async-throw', + { loader }); + wrapped('Error', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.equal('Hello Error'); + done(); + }); + }); + }); + }); +}); diff --git a/spec/Component.coffee b/spec/Component.coffee deleted file mode 100644 index f6f7b5860..000000000 --- a/spec/Component.coffee +++ /dev/null @@ -1,2466 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' -else - noflo = require 'noflo' - -describe 'Component', -> - describe 'with required ports', -> - it 'should throw an error upon sending packet to an unattached required port', -> - s2 = new noflo.internalSocket.InternalSocket - c = new noflo.Component - outPorts: - required_port: - required: true - optional_port: {} - c.outPorts.optional_port.attach s2 - chai.expect(-> c.outPorts.required_port.send('foo')).to.throw() - - it 'should be cool with an attached port', -> - s1 = new noflo.internalSocket.InternalSocket - s2 = new noflo.internalSocket.InternalSocket - c = new noflo.Component - inPorts: - required_port: - required: true - optional_port: {} - c.inPorts.required_port.attach s1 - c.inPorts.optional_port.attach s2 - f = -> - s1.send 'some-more-data' - s2.send 'some-data' - chai.expect(f).to.not.throw() - - describe 'with component creation shorthand', -> - it 'should make component creation easy', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - just_processor: {} - process: (input, output) -> - if input.hasData 'in' - packet = input.getData 'in' - chai.expect(packet).to.equal 'some-data' - output.done() - return - if input.hasData 'just_processor' - packet = input.getData 'just_processor' - chai.expect(packet).to.equal 'some-data' - output.done() - done() - return - - s1 = new noflo.internalSocket.InternalSocket - c.inPorts.in.attach s1 - c.inPorts.in.nodeInstance = c - s2 = new noflo.internalSocket.InternalSocket - c.inPorts.just_processor.attach s1 - c.inPorts.just_processor.nodeInstance = c - s1.send 'some-data' - s2.send 'some-data' - - it 'should throw errors if there is no error port', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - process: (input, output) -> - packet = input.getData 'in' - chai.expect(packet).to.equal 'some-data' - chai.expect(-> output.error(new Error)).to.throw Error - done() - - s1 = new noflo.internalSocket.InternalSocket - c.inPorts.in.attach s1 - c.inPorts.in.nodeInstance = c - s1.send 'some-data' - - it 'should throw errors if there is a non-attached error port', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - outPorts: - error: - datatype: 'object' - required: true - process: (input, output) -> - packet = input.getData 'in' - chai.expect(packet).to.equal 'some-data' - chai.expect(-> output.error(new Error)).to.throw Error - done() - - s1 = new noflo.internalSocket.InternalSocket - c.inPorts.in.attach s1 - c.inPorts.in.nodeInstance = c - s1.send 'some-data' - - it 'should not throw errors if there is a non-required error port', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - outPorts: - error: - required: no - process: (input, output) -> - packet = input.getData 'in' - chai.expect(packet).to.equal 'some-data' - c.error new Error - done() - - s1 = new noflo.internalSocket.InternalSocket - c.inPorts.in.attach s1 - c.inPorts.in.nodeInstance = c - s1.send 'some-data' - - it 'should send errors if there is a connected error port', (done) -> - grps = [] - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - outPorts: - error: - datatype: 'object' - process: (input, output) -> - return unless input.hasData 'in' - packet = input.getData 'in' - chai.expect(packet).to.equal 'some-data' - output.done new Error() - - s1 = new noflo.internalSocket.InternalSocket - s2 = new noflo.internalSocket.InternalSocket - groups = [ - 'foo' - 'bar' - ] - s2.on 'begingroup', (grp) -> - chai.expect(grp).to.equal groups.shift() - s2.on 'data', (err) -> - chai.expect(err).to.be.an.instanceOf Error - chai.expect(groups.length).to.equal 0 - done() - - c.inPorts.in.attach s1 - c.outPorts.error.attach s2 - c.inPorts.in.nodeInstance = c - s1.beginGroup 'foo' - s1.beginGroup 'bar' - s1.send 'some-data' - - describe 'defining ports with invalid names', -> - it 'should throw an error with uppercase letters in inport', -> - shorthand = -> - c = new noflo.Component - inPorts: - fooPort: {} - chai.expect(shorthand).to.throw() - it 'should throw an error with uppercase letters in outport', -> - shorthand = -> - c = new noflo.Component - outPorts: - BarPort: {} - chai.expect(shorthand).to.throw() - it 'should throw an error with special characters in inport', -> - shorthand = -> - c = new noflo.Component - inPorts: - '$%^&*a': {} - chai.expect(shorthand).to.throw() - describe 'with non-existing ports', -> - getComponent = -> - c = new noflo.Component - inPorts: - in: {} - outPorts: - out: {} - getAddressableComponent = -> - c = new noflo.Component - inPorts: - in: - addressable: true - outPorts: - out: - addressable: true - it 'should throw an error when checking attached for non-existing port', (done) -> - c = getComponent() - c.process (input, output) -> - try - input.attached 'foo' - catch e - chai.expect(e).to.be.an 'Error' - chai.expect(e.message).to.contain 'foo' - done() - return - done new Error 'Expected a throw' - sin1 = noflo.internalSocket.createSocket() - c.inPorts.in.attach sin1 - sin1.send 'hello' - it 'should throw an error when checking IP for non-existing port', (done) -> - c = getComponent() - c.process (input, output) -> - try - input.has 'foo' - catch e - chai.expect(e).to.be.an 'Error' - chai.expect(e.message).to.contain 'foo' - done() - return - done new Error 'Expected a throw' - sin1 = noflo.internalSocket.createSocket() - c.inPorts.in.attach sin1 - sin1.send 'hello' - it 'should throw an error when checking IP for non-existing addressable port', (done) -> - c = getComponent() - c.process (input, output) -> - try - input.has ['foo', 0] - catch e - chai.expect(e).to.be.an 'Error' - chai.expect(e.message).to.contain 'foo' - done() - return - done new Error 'Expected a throw' - sin1 = noflo.internalSocket.createSocket() - c.inPorts.in.attach sin1 - sin1.send 'hello' - it 'should throw an error when checking data for non-existing port', (done) -> - c = getComponent() - c.process (input, output) -> - try - input.hasData 'foo' - catch e - chai.expect(e).to.be.an 'Error' - chai.expect(e.message).to.contain 'foo' - done() - return - done new Error 'Expected a throw' - sin1 = noflo.internalSocket.createSocket() - c.inPorts.in.attach sin1 - sin1.send 'hello' - it 'should throw an error when checking stream for non-existing port', (done) -> - c = getComponent() - c.process (input, output) -> - try - input.hasStream 'foo' - catch e - chai.expect(e).to.be.an 'Error' - chai.expect(e.message).to.contain 'foo' - done() - return - done new Error 'Expected a throw' - sin1 = noflo.internalSocket.createSocket() - c.inPorts.in.attach sin1 - sin1.send 'hello' - - describe 'starting a component', -> - - it 'should flag the component as started', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - i = new noflo.internalSocket.InternalSocket - c.inPorts.in.attach(i) - c.start (err) -> - return done err if err - chai.expect(c.started).to.equal(true) - chai.expect(c.isStarted()).to.equal(true) - done() - - describe 'shutting down a component', -> - - it 'should flag the component as not started', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - i = new noflo.internalSocket.InternalSocket - c.inPorts.in.attach(i) - c.start (err) -> - return done err if err - chai.expect(c.isStarted()).to.equal(true) - c.shutdown (err) -> - return done err if err - chai.expect(c.started).to.equal(false) - chai.expect(c.isStarted()).to.equal(false) - done() - - describe 'with object-based IPs', -> - - it 'should speak IP objects', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - process: (input, output) -> - output.sendDone input.get 'in' - - s1 = new noflo.internalSocket.InternalSocket - s2 = new noflo.internalSocket.InternalSocket - - s2.on 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.groups).to.be.an 'array' - chai.expect(ip.groups).to.eql ['foo'] - chai.expect(ip.data).to.be.a 'string' - chai.expect(ip.data).to.equal 'some-data' - done() - - c.inPorts.in.attach s1 - c.outPorts.out.attach s2 - - s1.post new noflo.IP 'data', 'some-data', - groups: ['foo'] - - it 'should support substreams', (done) -> - c = new noflo.Component - forwardBrackets: {} - inPorts: - tags: - datatype: 'string' - outPorts: - html: - datatype: 'string' - process: (input, output) -> - ip = input.get 'tags' - switch ip.type - when 'openBracket' - c.str += "<#{ip.data}>" - c.level++ - when 'data' - c.str += ip.data - when 'closeBracket' - c.str += "" - c.level-- - if c.level is 0 - output.send html: c.str - c.str = '' - output.done() - c.str = '' - c.level = 0 - - d = new noflo.Component - inPorts: - bang: - datatype: 'bang' - outPorts: - tags: - datatype: 'string' - process: (input, output) -> - input.getData 'bang' - output.send tags: new noflo.IP 'openBracket', 'p' - output.send tags: new noflo.IP 'openBracket', 'em' - output.send tags: new noflo.IP 'data', 'Hello' - output.send tags: new noflo.IP 'closeBracket', 'em' - output.send tags: new noflo.IP 'data', ', ' - output.send tags: new noflo.IP 'openBracket', 'strong' - output.send tags: new noflo.IP 'data', 'World!' - output.send tags: new noflo.IP 'closeBracket', 'strong' - output.send tags: new noflo.IP 'closeBracket', 'p' - outout.done() - - s1 = new noflo.internalSocket.InternalSocket - s2 = new noflo.internalSocket.InternalSocket - s3 = new noflo.internalSocket.InternalSocket - - s3.on 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal '

Hello, World!

' - done() - - d.inPorts.bang.attach s1 - d.outPorts.tags.attach s2 - c.inPorts.tags.attach s2 - c.outPorts.html.attach s3 - - s1.post new noflo.IP 'data', 'start' - - describe 'with process function', -> - c = null - sin1 = null - sin2 = null - sin3 = null - sout1 = null - sout2 = null - - beforeEach (done) -> - sin1 = new noflo.internalSocket.InternalSocket - sin2 = new noflo.internalSocket.InternalSocket - sin3 = new noflo.internalSocket.InternalSocket - sout1 = new noflo.internalSocket.InternalSocket - sout2 = new noflo.internalSocket.InternalSocket - done() - - it 'should trigger on IPs', (done) -> - hadIPs = [] - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: datatype: 'string' - outPorts: - baz: datatype: 'boolean' - process: (input, output) -> - hadIPs = [] - hadIPs.push 'foo' if input.has 'foo' - hadIPs.push 'bar' if input.has 'bar' - output.sendDone baz: true - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - count = 0 - sout1.on 'ip', (ip) -> - count++ - if count is 1 - chai.expect(hadIPs).to.eql ['foo'] - if count is 2 - chai.expect(hadIPs).to.eql ['foo', 'bar'] - done() - - sin1.post new noflo.IP 'data', 'first' - sin2.post new noflo.IP 'data', 'second' - - it 'should trigger on IPs to addressable ports', (done) -> - receivedIndexes = [] - c = new noflo.Component - inPorts: - foo: - datatype: 'string' - addressable: true - outPorts: - baz: - datatype: 'boolean' - process: (input, output) -> - # See what inbound connection indexes have data - indexesWithData = input.attached('foo').filter (idx) -> - input.hasData ['foo', idx] - return unless indexesWithData.length - # Read from the first of them - indexToUse = indexesWithData[0] - packet = input.get ['foo', indexToUse] - receivedIndexes.push - idx: indexToUse - payload: packet.data - output.sendDone baz: true - - c.inPorts.foo.attach sin1, 1 - c.inPorts.foo.attach sin2, 0 - c.outPorts.baz.attach sout1 - - count = 0 - sout1.on 'ip', (ip) -> - count++ - if count is 1 - chai.expect(receivedIndexes).to.eql [ - idx: 1 - payload: 'first' - ] - if count is 2 - chai.expect(receivedIndexes).to.eql [ - idx: 1 - payload: 'first' - , - idx: 0 - payload: 'second' - ] - done() - sin1.post new noflo.IP 'data', 'first' - sin2.post new noflo.IP 'data', 'second' - - it 'should be able to send IPs to addressable connections', (done) -> - expected = [ - data: 'first' - index: 1 - , - data: 'second' - index: 0 - ] - c = new noflo.Component - inPorts: - foo: - datatype: 'string' - outPorts: - baz: - datatype: 'boolean' - addressable: true - process: (input, output) -> - return unless input.has 'foo' - packet = input.get 'foo' - output.sendDone new noflo.IP 'data', packet.data, - index: expected.length - 1 - - c.inPorts.foo.attach sin1 - c.outPorts.baz.attach sout1, 1 - c.outPorts.baz.attach sout2, 0 - - sout1.on 'ip', (ip) -> - exp = expected.shift() - received = - data: ip.data - index: 1 - chai.expect(received).to.eql exp - done() unless expected.length - sout2.on 'ip', (ip) -> - exp = expected.shift() - received = - data: ip.data - index: 0 - chai.expect(received).to.eql exp - done() unless expected.length - sin1.post new noflo.IP 'data', 'first' - sin1.post new noflo.IP 'data', 'second' - - it 'trying to send to addressable port without providing index should fail', (done) -> - c = new noflo.Component - inPorts: - foo: - datatype: 'string' - outPorts: - baz: - datatype: 'boolean' - addressable: true - process: (input, output) -> - return unless input.hasData 'foo' - packet = input.get 'foo' - noIndex = new noflo.IP 'data', packet.data - chai.expect(-> output.sendDone noIndex).to.throw Error - done() - - c.inPorts.foo.attach sin1 - c.outPorts.baz.attach sout1, 1 - c.outPorts.baz.attach sout2, 0 - - sout1.on 'ip', (ip) -> - sout2.on 'ip', (ip) -> - - sin1.post new noflo.IP 'data', 'first' - - it 'should be able to send falsy IPs', (done) -> - expected = [ - port: 'out1' - data: 1 - , - port: 'out2' - data: 0 - ] - c = new noflo.Component - inPorts: - foo: - datatype: 'string' - outPorts: - out1: - datatype: 'int' - out2: - datatype: 'int' - process: (input, output) -> - return unless input.has 'foo' - packet = input.get 'foo' - output.sendDone - out1: 1 - out2: 0 - - c.inPorts.foo.attach sin1 - c.outPorts.out1.attach sout1, 1 - c.outPorts.out2.attach sout2, 0 - - sout1.on 'ip', (ip) -> - exp = expected.shift() - received = - port: 'out1' - data: ip.data - chai.expect(received).to.eql exp - done() unless expected.length - sout2.on 'ip', (ip) -> - exp = expected.shift() - received = - port: 'out2' - data: ip.data - chai.expect(received).to.eql exp - done() unless expected.length - sin1.post new noflo.IP 'data', 'first' - - it 'should not be triggered by non-triggering ports', (done) -> - triggered = [] - c = new noflo.Component - inPorts: - foo: - datatype: 'string' - triggering: false - bar: datatype: 'string' - outPorts: - baz: datatype: 'boolean' - process: (input, output) -> - triggered.push input.port.name - output.sendDone baz: true - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - count = 0 - sout1.on 'ip', (ip) -> - count++ - if count is 1 - chai.expect(triggered).to.eql ['bar'] - if count is 2 - chai.expect(triggered).to.eql ['bar', 'bar'] - done() - - sin1.post new noflo.IP 'data', 'first' - sin2.post new noflo.IP 'data', 'second' - sin1.post new noflo.IP 'data', 'first' - sin2.post new noflo.IP 'data', 'second' - - it 'should fetch undefined for premature data', (done) -> - c = new noflo.Component - inPorts: - foo: - datatype: 'string' - bar: - datatype: 'boolean' - triggering: false - control: true - baz: - datatype: 'string' - triggering: false - control: true - process: (input, output) -> - return unless input.has 'foo' - [foo, bar, baz] = input.getData 'foo', 'bar', 'baz' - chai.expect(foo).to.be.a 'string' - chai.expect(bar).to.be.undefined - chai.expect(baz).to.be.undefined - done() - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.inPorts.baz.attach sin3 - - sin1.post new noflo.IP 'data', 'AZ' - sin2.post new noflo.IP 'data', true - sin3.post new noflo.IP 'data', 'first' - - it 'should receive and send complete noflo.IP objects', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: datatype: 'string' - outPorts: - baz: datatype: 'object' - process: (input, output) -> - return unless input.has 'foo', 'bar' - [foo, bar] = input.get 'foo', 'bar' - baz = - foo: foo.data - bar: bar.data - groups: foo.groups - type: bar.type - output.sendDone - baz: new noflo.IP 'data', baz, - groups: ['baz'] - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data.foo).to.equal 'foo' - chai.expect(ip.data.bar).to.equal 'bar' - chai.expect(ip.data.groups).to.eql ['foo'] - chai.expect(ip.data.type).to.equal 'data' - chai.expect(ip.groups).to.eql ['baz'] - done() - - sin1.post new noflo.IP 'data', 'foo', - groups: ['foo'] - sin2.post new noflo.IP 'data', 'bar', - groups: ['bar'] - - it 'should stamp IP objects with the datatype of the outport when sending', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'all' - outPorts: - baz: datatype: 'string' - process: (input, output) -> - return unless input.has 'foo' - foo = input.get 'foo' - output.sendDone - baz: foo - - c.inPorts.foo.attach sin1 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.datatype).to.equal 'string' - done() - - sin1.post new noflo.IP 'data', 'foo' - it 'should stamp IP objects with the datatype of the inport when receiving', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - outPorts: - baz: datatype: 'all' - process: (input, output) -> - return unless input.has 'foo' - foo = input.get 'foo' - output.sendDone - baz: foo - - c.inPorts.foo.attach sin1 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.datatype).to.equal 'string' - done() - - sin1.post new noflo.IP 'data', 'foo' - it 'should stamp IP objects with the schema of the outport when sending', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'all' - outPorts: - baz: - datatype: 'string' - schema: 'text/markdown' - process: (input, output) -> - return unless input.has 'foo' - foo = input.get 'foo' - output.sendDone - baz: foo - - c.inPorts.foo.attach sin1 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.datatype).to.equal 'string' - chai.expect(ip.schema).to.equal 'text/markdown' - done() - - sin1.post new noflo.IP 'data', 'foo' - it 'should stamp IP objects with the schema of the inport when receiving', (done) -> - c = new noflo.Component - inPorts: - foo: - datatype: 'string' - schema: 'text/markdown' - outPorts: - baz: datatype: 'all' - process: (input, output) -> - return unless input.has 'foo' - foo = input.get 'foo' - output.sendDone - baz: foo - - c.inPorts.foo.attach sin1 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.datatype).to.equal 'string' - chai.expect(ip.schema).to.equal 'text/markdown' - done() - - sin1.post new noflo.IP 'data', 'foo' - - it 'should receive and send just IP data if wanted', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: datatype: 'string' - outPorts: - baz: datatype: 'object' - process: (input, output) -> - return unless input.has 'foo', 'bar' - [foo, bar] = input.getData 'foo', 'bar' - baz = - foo: foo - bar: bar - output.sendDone - baz: baz - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data.foo).to.equal 'foo' - chai.expect(ip.data.bar).to.equal 'bar' - done() - - sin1.post new noflo.IP 'data', 'foo', - groups: ['foo'] - sin2.post new noflo.IP 'data', 'bar', - groups: ['bar'] - - it 'should receive IPs and be able to selectively find them', (done) -> - called = 0 - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: datatype: 'string' - outPorts: - baz: datatype: 'object' - process: (input, output) -> - validate = (ip) -> - called++ - ip.type is 'data' and ip.data is 'hello' - unless input.has 'foo', 'bar', validate - return - foo = input.get 'foo' - while foo?.type isnt 'data' - foo = input.get 'foo' - bar = input.getData 'bar' - output.sendDone - baz: "#{foo.data}:#{bar}" - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - shouldHaveSent = false - - sout1.on 'ip', (ip) -> - chai.expect(shouldHaveSent, 'Should not sent before its time').to.equal true - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'hello:hello' - chai.expect(called).to.equal 10 - done() - - sin1.post new noflo.IP 'openBracket', 'a' - sin1.post new noflo.IP 'data', 'hello', - sin1.post new noflo.IP 'closeBracket', 'a' - shouldHaveSent = true - sin2.post new noflo.IP 'data', 'hello' - - it 'should keep last value for controls', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: - datatype: 'string' - control: true - outPorts: - baz: datatype: 'object' - process: (input, output) -> - return unless input.has 'foo', 'bar' - [foo, bar] = input.getData 'foo', 'bar' - baz = - foo: foo - bar: bar - output.sendDone - baz: baz - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data.foo).to.equal 'foo' - chai.expect(ip.data.bar).to.equal 'bar' - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data.foo).to.equal 'boo' - chai.expect(ip.data.bar).to.equal 'bar' - done() - - sin1.post new noflo.IP 'data', 'foo' - sin2.post new noflo.IP 'data', 'bar' - sin1.post new noflo.IP 'data', 'boo' - - it 'should keep last data-typed IP packet for controls', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: - datatype: 'string' - control: true - outPorts: - baz: datatype: 'object' - process: (input, output) -> - return unless input.has 'foo', 'bar' - [foo, bar] = input.getData 'foo', 'bar' - baz = - foo: foo - bar: bar - output.sendDone - baz: baz - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data.foo).to.equal 'foo' - chai.expect(ip.data.bar).to.equal 'bar' - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data.foo).to.equal 'boo' - chai.expect(ip.data.bar).to.equal 'bar' - done() - - sin1.post new noflo.IP 'data', 'foo' - sin2.post new noflo.IP 'openBracket' - sin2.post new noflo.IP 'data', 'bar' - sin2.post new noflo.IP 'closeBracket' - sin1.post new noflo.IP 'data', 'boo' - - it 'should isolate packets with different scopes', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: datatype: 'string' - outPorts: - baz: datatype: 'string' - process: (input, output) -> - return unless input.has 'foo', 'bar' - [foo, bar] = input.getData 'foo', 'bar' - output.sendDone - baz: "#{foo} and #{bar}" - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.scope).to.equal '1' - chai.expect(ip.data).to.equal 'Josh and Laura' - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.scope).to.equal '2' - chai.expect(ip.data).to.equal 'Jane and Luke' - done() - - sin1.post new noflo.IP 'data', 'Josh', scope: '1' - sin2.post new noflo.IP 'data', 'Luke', scope: '2' - sin2.post new noflo.IP 'data', 'Laura', scope: '1' - sin1.post new noflo.IP 'data', 'Jane', scope: '2' - - it 'should be able to change scope', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - outPorts: - baz: datatype: 'string' - process: (input, output) -> - foo = input.getData 'foo' - output.sendDone - baz: new noflo.IP 'data', foo, scope: 'baz' - - c.inPorts.foo.attach sin1 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.scope).to.equal 'baz' - chai.expect(ip.data).to.equal 'foo' - done() - - sin1.post new noflo.IP 'data', 'foo', scope: 'foo' - - it 'should support integer scopes', (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: datatype: 'string' - outPorts: - baz: datatype: 'string' - process: (input, output) -> - return unless input.has 'foo', 'bar' - [foo, bar] = input.getData 'foo', 'bar' - output.sendDone - baz: "#{foo} and #{bar}" - - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.scope).to.equal 1 - chai.expect(ip.data).to.equal 'Josh and Laura' - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.scope).to.equal 0 - chai.expect(ip.data).to.equal 'Jane and Luke' - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.scope).to.be.null - chai.expect(ip.data).to.equal 'Tom and Anna' - done() - - sin1.post new noflo.IP 'data', 'Tom' - sin1.post new noflo.IP 'data', 'Josh', scope: 1 - sin2.post new noflo.IP 'data', 'Luke', scope: 0 - sin2.post new noflo.IP 'data', 'Laura', scope: 1 - sin1.post new noflo.IP 'data', 'Jane', scope: 0 - sin2.post new noflo.IP 'data', 'Anna' - - it 'should preserve order between input and output', (done) -> - c = new noflo.Component - inPorts: - msg: datatype: 'string' - delay: datatype: 'int' - outPorts: - out: datatype: 'object' - ordered: true - process: (input, output) -> - return unless input.has 'msg', 'delay' - [msg, delay] = input.getData 'msg', 'delay' - setTimeout -> - output.sendDone - out: { msg: msg, delay: delay } - , delay - - c.inPorts.msg.attach sin1 - c.inPorts.delay.attach sin2 - c.outPorts.out.attach sout1 - - sample = [ - { delay: 30, msg: "one" } - { delay: 0, msg: "two" } - { delay: 20, msg: "three" } - { delay: 10, msg: "four" } - ] - - sout1.on 'ip', (ip) -> - chai.expect(ip.data).to.eql sample.shift() - done() if sample.length is 0 - - for ip in sample - sin1.post new noflo.IP 'data', ip.msg - sin2.post new noflo.IP 'data', ip.delay - - it 'should ignore order between input and output', (done) -> - c = new noflo.Component - inPorts: - msg: datatype: 'string' - delay: datatype: 'int' - outPorts: - out: datatype: 'object' - ordered: false - process: (input, output) -> - return unless input.has 'msg', 'delay' - [msg, delay] = input.getData 'msg', 'delay' - setTimeout -> - output.sendDone - out: { msg: msg, delay: delay } - , delay - - c.inPorts.msg.attach sin1 - c.inPorts.delay.attach sin2 - c.outPorts.out.attach sout1 - - sample = [ - { delay: 30, msg: "one" } - { delay: 0, msg: "two" } - { delay: 20, msg: "three" } - { delay: 10, msg: "four" } - ] - - count = 0 - sout1.on 'ip', (ip) -> - count++ - switch count - when 1 then src = sample[1] - when 2 then src = sample[3] - when 3 then src = sample[2] - when 4 then src = sample[0] - chai.expect(ip.data).to.eql src - done() if count is 4 - - for ip in sample - sin1.post new noflo.IP 'data', ip.msg - sin2.post new noflo.IP 'data', ip.delay - - it 'should throw errors if there is no error port', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - process: (input, output) -> - packet = input.get 'in' - chai.expect(packet.data).to.equal 'some-data' - chai.expect(-> output.done new Error 'Should fail').to.throw Error - done() - - c.inPorts.in.attach sin1 - sin1.post new noflo.IP 'data', 'some-data' - - it 'should throw errors if there is a non-attached error port', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - outPorts: - error: - datatype: 'object' - required: true - process: (input, output) -> - packet = input.get 'in' - chai.expect(packet.data).to.equal 'some-data' - chai.expect(-> output.sendDone new Error 'Should fail').to.throw Error - done() - - c.inPorts.in.attach sin1 - sin1.post new noflo.IP 'data', 'some-data' - - it 'should not throw errors if there is a non-required error port', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - outPorts: - error: - required: no - process: (input, output) -> - packet = input.get 'in' - chai.expect(packet.data).to.equal 'some-data' - output.sendDone new Error 'Should not fail' - done() - - c.inPorts.in.attach sin1 - sin1.post new noflo.IP 'data', 'some-data' - - it 'should send out string other port if there is only one port aside from error', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'all' - required: true - outPorts: - out: - required: true - error: - required: false - process: (input, output) -> - packet = input.get 'in' - output.sendDone 'some data' - - sout1.on 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.data).to.equal 'some data' - done() - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - - sin1.post new noflo.IP 'data', 'first' - - it 'should send object out other port if there is only one port aside from error', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'all' - required: true - outPorts: - out: - required: true - error: - required: false - process: (input, output) -> - packet = input.get 'in' - output.sendDone some: 'data' - - sout1.on 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.data).to.eql some: 'data' - done() - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - - sin1.post new noflo.IP 'data', 'first' - - it 'should throw an error if sending without specifying a port and there are multiple ports', (done) -> - f = -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - outPorts: - out: - datatype: 'all' - eh: - required: no - process: (input, output) -> - output.sendDone 'test' - - c.inPorts.in.attach sin1 - sin1.post new noflo.IP 'data', 'some-data' - chai.expect(f).to.throw Error - done() - - it 'should send errors if there is a connected error port', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - outPorts: - error: - datatype: 'object' - process: (input, output) -> - packet = input.get 'in' - chai.expect(packet.data).to.equal 'some-data' - chai.expect(packet.scope).to.equal 'some-scope' - output.sendDone new Error 'Should fail' - - sout1.on 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.data).to.be.an.instanceOf Error - chai.expect(ip.scope).to.equal 'some-scope' - done() - - c.inPorts.in.attach sin1 - c.outPorts.error.attach sout1 - sin1.post new noflo.IP 'data', 'some-data', - scope: 'some-scope' - - it 'should send substreams with multiple errors per activation', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - required: true - outPorts: - error: - datatype: 'object' - process: (input, output) -> - packet = input.get 'in' - chai.expect(packet.data).to.equal 'some-data' - chai.expect(packet.scope).to.equal 'some-scope' - errors = [] - errors.push new Error 'One thing is invalid' - errors.push new Error 'Another thing is invalid' - output.sendDone errors - - expected = [ - '<' - 'One thing is invalid' - 'Another thing is invalid' - '>' - ] - actual = [] - count = 0 - - sout1.on 'ip', (ip) -> - count++ - chai.expect(ip).to.be.an 'object' - chai.expect(ip.scope).to.equal 'some-scope' - actual.push '<' if ip.type is 'openBracket' - actual.push '>' if ip.type is 'closeBracket' - if ip.type is 'data' - chai.expect(ip.data).to.be.an.instanceOf Error - actual.push ip.data.message - if count is 4 - chai.expect(actual).to.eql expected - done() - - c.inPorts.in.attach sin1 - c.outPorts.error.attach sout1 - sin1.post new noflo.IP 'data', 'some-data', - scope: 'some-scope' - - it 'should forward brackets for map-style components', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - error: - datatype: 'object' - process: (input, output) -> - str = input.getData() - if typeof str isnt 'string' - return output.sendDone new Error 'Input is not string' - output.pass str.toUpperCase() - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - c.outPorts.error.attach sout2 - - source = [ - '<' - 'foo' - 'bar' - '>' - ] - actual = [] - count = 0 - - sout1.on 'ip', (ip) -> - data = switch ip.type - when 'openBracket' then '<' - when 'closeBracket' then '>' - else ip.data - chai.expect(data).to.equal source[count].toUpperCase() - count++ - done() if count is 4 - - sout2.on 'ip', (ip) -> - return if ip.type isnt 'data' - console.log 'Unexpected error', ip - done ip.data - - for data in source - switch data - when '<' then sin1.post new noflo.IP 'openBracket' - when '>' then sin1.post new noflo.IP 'closeBracket' - else sin1.post new noflo.IP 'data', data - - it 'should forward brackets for map-style components with addressable outport', (done) -> - sent = false - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - addressable: true - process: (input, output) -> - return unless input.hasData() - string = input.getData() - idx = if sent then 0 else 1 - sent = true - output.sendDone new noflo.IP 'data', string, - index: idx - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1, 1 - c.outPorts.out.attach sout2, 0 - - expected = [ - '1 < a' - '1 < foo' - '1 DATA first' - '1 > foo' - '0 < a' - '0 < bar' - '0 DATA second' - '0 > bar' - '0 > a' - '1 > a' - ] - received = [] - sout1.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "1 < #{ip.data}" - when 'data' - received.push "1 DATA #{ip.data}" - when 'closeBracket' - received.push "1 > #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - sout2.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "0 < #{ip.data}" - when 'data' - received.push "0 DATA #{ip.data}" - when 'closeBracket' - received.push "0 > #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - - sin1.post new noflo.IP 'openBracket', 'a' - sin1.post new noflo.IP 'openBracket', 'foo' - sin1.post new noflo.IP 'data', 'first' - sin1.post new noflo.IP 'closeBracket', 'foo' - sin1.post new noflo.IP 'openBracket', 'bar' - sin1.post new noflo.IP 'data', 'second' - sin1.post new noflo.IP 'closeBracket', 'bar' - sin1.post new noflo.IP 'closeBracket', 'a' - - it 'should forward brackets for async map-style components with addressable outport', (done) -> - sent = false - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - addressable: true - process: (input, output) -> - return unless input.hasData() - string = input.getData() - idx = if sent then 0 else 1 - sent = true - setTimeout -> - output.sendDone new noflo.IP 'data', string, - index: idx - , 1 - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1, 1 - c.outPorts.out.attach sout2, 0 - - expected = [ - '1 < a' - '1 < foo' - '1 DATA first' - '1 > foo' - '0 < a' - '0 < bar' - '0 DATA second' - '0 > bar' - '0 > a' - '1 > a' - ] - received = [] - sout1.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "1 < #{ip.data}" - when 'data' - received.push "1 DATA #{ip.data}" - when 'closeBracket' - received.push "1 > #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - sout2.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "0 < #{ip.data}" - when 'data' - received.push "0 DATA #{ip.data}" - when 'closeBracket' - received.push "0 > #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - - sin1.post new noflo.IP 'openBracket', 'a' - sin1.post new noflo.IP 'openBracket', 'foo' - sin1.post new noflo.IP 'data', 'first' - sin1.post new noflo.IP 'closeBracket', 'foo' - sin1.post new noflo.IP 'openBracket', 'bar' - sin1.post new noflo.IP 'data', 'second' - sin1.post new noflo.IP 'closeBracket', 'bar' - sin1.post new noflo.IP 'closeBracket', 'a' - - it 'should forward brackets for map-style components with addressable in/outports', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - addressable: true - outPorts: - out: - datatype: 'string' - addressable: true - process: (input, output) -> - indexesWithData = [] - for idx in input.attached() - indexesWithData.push idx if input.hasData ['in', idx] - return unless indexesWithData.length - indexToUse = indexesWithData[0] - data = input.get ['in', indexToUse] - ip = new noflo.IP 'data', data.data - ip.index = indexToUse - output.sendDone ip - - c.inPorts.in.attach sin1, 1 - c.inPorts.in.attach sin2, 0 - c.outPorts.out.attach sout1, 1 - c.outPorts.out.attach sout2, 0 - - expected = [ - '1 < a' - '1 < foo' - '1 DATA first' - '1 > foo' - '0 < bar' - '0 DATA second' - '0 > bar' - '1 > a' - ] - received = [] - sout1.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "1 < #{ip.data}" - when 'data' - received.push "1 DATA #{ip.data}" - when 'closeBracket' - received.push "1 > #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - sout2.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "0 < #{ip.data}" - when 'data' - received.push "0 DATA #{ip.data}" - when 'closeBracket' - received.push "0 > #{ip.data}" - return unless received.length is expected.length - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - - sin1.post new noflo.IP 'openBracket', 'a' - sin1.post new noflo.IP 'openBracket', 'foo' - sin1.post new noflo.IP 'data', 'first' - sin1.post new noflo.IP 'closeBracket', 'foo' - sin2.post new noflo.IP 'openBracket', 'bar' - sin2.post new noflo.IP 'data', 'second' - sin2.post new noflo.IP 'closeBracket', 'bar' - sin1.post new noflo.IP 'closeBracket', 'a' - - it 'should forward brackets for async map-style components with addressable in/outports', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - addressable: true - outPorts: - out: - datatype: 'string' - addressable: true - process: (input, output) -> - indexesWithData = [] - for idx in input.attached() - indexesWithData.push idx if input.hasData ['in', idx] - return unless indexesWithData.length - data = input.get ['in', indexesWithData[0]] - setTimeout -> - ip = new noflo.IP 'data', data.data - ip.index = data.index - output.sendDone ip - , 1 - - c.inPorts.in.attach sin1, 1 - c.inPorts.in.attach sin2, 0 - c.outPorts.out.attach sout1, 1 - c.outPorts.out.attach sout2, 0 - - expected = [ - '1 < a' - '1 < foo' - '1 DATA first' - '1 > foo' - '0 < bar' - '0 DATA second' - '0 > bar' - '1 > a' - ] - received = [] - sout1.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "1 < #{ip.data}" - when 'data' - received.push "1 DATA #{ip.data}" - when 'closeBracket' - received.push "1 > #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - sout2.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "0 < #{ip.data}" - when 'data' - received.push "0 DATA #{ip.data}" - when 'closeBracket' - received.push "0 > #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - - sin1.post new noflo.IP 'openBracket', 'a' - sin1.post new noflo.IP 'openBracket', 'foo' - sin1.post new noflo.IP 'data', 'first' - sin1.post new noflo.IP 'closeBracket', 'foo' - sin2.post new noflo.IP 'openBracket', 'bar' - sin2.post new noflo.IP 'data', 'second' - sin2.post new noflo.IP 'closeBracket', 'bar' - sin1.post new noflo.IP 'closeBracket', 'a' - - it 'should forward brackets to error port in async components', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - error: - datatype: 'object' - process: (input, output) -> - str = input.getData() - setTimeout -> - if typeof str isnt 'string' - return output.sendDone new Error 'Input is not string' - output.pass str.toUpperCase() - , 10 - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - c.outPorts.error.attach sout2 - - sout1.on 'ip', (ip) -> - # done new Error "Unexpected IP: #{ip.type} #{ip.data}" - - count = 0 - sout2.on 'ip', (ip) -> - count++ - switch count - when 1 - chai.expect(ip.type).to.equal 'openBracket' - when 2 - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.be.an 'error' - when 3 - chai.expect(ip.type).to.equal 'closeBracket' - done() if count is 3 - - sin1.post new noflo.IP 'openBracket', 'foo' - sin1.post new noflo.IP 'data', { bar: 'baz' } - sin1.post new noflo.IP 'closeBracket', 'foo' - - it 'should not forward brackets if error port is not connected', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - required: true - error: - datatype: 'object' - required: true - process: (input, output) -> - str = input.getData() - setTimeout -> - if typeof str isnt 'string' - return output.sendDone new Error 'Input is not string' - output.pass str.toUpperCase() - , 10 - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - # c.outPorts.error.attach sout2 - - sout1.on 'ip', (ip) -> - done() if ip.type is 'closeBracket' - - sout2.on 'ip', (ip) -> - done new Error "Unexpected error IP: #{ip.type} #{ip.data}" - - chai.expect -> - sin1.post new noflo.IP 'openBracket', 'foo' - sin1.post new noflo.IP 'data', 'bar' - sin1.post new noflo.IP 'closeBracket', 'foo' - .to.not.throw() - - it 'should support custom bracket forwarding mappings with auto-ordering', (done) -> - c = new noflo.Component - inPorts: - msg: - datatype: 'string' - delay: - datatype: 'int' - outPorts: - out: - datatype: 'string' - error: - datatype: 'object' - forwardBrackets: - msg: ['out', 'error'] - delay: ['error'] - process: (input, output) -> - return unless input.hasData 'msg', 'delay' - [msg, delay] = input.getData 'msg', 'delay' - if delay < 0 - return output.sendDone new Error 'Delay is negative' - setTimeout -> - output.sendDone - out: { msg: msg, delay: delay } - , delay - - c.inPorts.msg.attach sin1 - c.inPorts.delay.attach sin2 - c.outPorts.out.attach sout1 - c.outPorts.error.attach sout2 - - sample = [ - { delay: 30, msg: "one" } - { delay: 0, msg: "two" } - { delay: 20, msg: "three" } - { delay: 10, msg: "four" } - { delay: -40, msg: 'five'} - ] - - count = 0 - errCount = 0 - sout1.on 'ip', (ip) -> - src = null - switch count - when 0 - chai.expect(ip.type).to.equal 'openBracket' - chai.expect(ip.data).to.equal 'msg' - when 5 - chai.expect(ip.type).to.equal 'closeBracket' - chai.expect(ip.data).to.equal 'msg' - else src = sample[count - 1] - chai.expect(ip.data).to.eql src if src - count++ - # done() if count is 6 - - sout2.on 'ip', (ip) -> - switch errCount - when 0 - chai.expect(ip.type).to.equal 'openBracket' - chai.expect(ip.data).to.equal 'msg' - when 1 - chai.expect(ip.type).to.equal 'openBracket' - chai.expect(ip.data).to.equal 'delay' - when 2 - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.be.an 'error' - when 3 - chai.expect(ip.type).to.equal 'closeBracket' - chai.expect(ip.data).to.equal 'delay' - when 4 - chai.expect(ip.type).to.equal 'closeBracket' - chai.expect(ip.data).to.equal 'msg' - errCount++ - done() if errCount is 5 - - sin1.post new noflo.IP 'openBracket', 'msg' - sin2.post new noflo.IP 'openBracket', 'delay' - - for ip in sample - sin1.post new noflo.IP 'data', ip.msg - sin2.post new noflo.IP 'data', ip.delay - - sin2.post new noflo.IP 'closeBracket', 'delay' - sin1.post new noflo.IP 'closeBracket', 'msg' - - it 'should de-duplicate brackets when asynchronously forwarding from multiple inports', (done) -> - c = new noflo.Component - inPorts: - in1: - datatype: 'string' - in2: - datatype: 'string' - outPorts: - out: - datatype: 'string' - error: - datatype: 'object' - forwardBrackets: - in1: ['out', 'error'] - in2: ['out', 'error'] - process: (input, output) -> - return unless input.hasData 'in1', 'in2' - [one, two] = input.getData 'in1', 'in2' - setTimeout -> - output.sendDone - out: "#{one}:#{two}" - , 1 - - c.inPorts.in1.attach sin1 - c.inPorts.in2.attach sin2 - c.outPorts.out.attach sout1 - c.outPorts.error.attach sout2 - - # Fail early on errors - sout2.on 'ip', (ip) -> - return unless ip.type is 'data' - done ip.data - - expected = [ - '< a' - '< b' - 'DATA one:yksi' - '< c' - 'DATA two:kaksi' - '> c' - 'DATA three:kolme' - '> b' - '> a' - ] - received = [ - ] - - sout1.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "< #{ip.data}" - when 'data' - received.push "DATA #{ip.data}" - when 'closeBracket' - received.push "> #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - - sin1.post new noflo.IP 'openBracket', 'a' - sin1.post new noflo.IP 'openBracket', 'b' - sin1.post new noflo.IP 'data', 'one' - sin1.post new noflo.IP 'openBracket', 'c' - sin1.post new noflo.IP 'data', 'two' - sin1.post new noflo.IP 'closeBracket', 'c' - sin2.post new noflo.IP 'openBracket', 'a' - sin2.post new noflo.IP 'openBracket', 'b' - sin2.post new noflo.IP 'data', 'yksi' - sin2.post new noflo.IP 'data', 'kaksi' - sin1.post new noflo.IP 'data', 'three' - sin1.post new noflo.IP 'closeBracket', 'b' - sin1.post new noflo.IP 'closeBracket', 'a' - sin2.post new noflo.IP 'data', 'kolme' - sin2.post new noflo.IP 'closeBracket', 'b' - sin2.post new noflo.IP 'closeBracket', 'a' - - it 'should de-duplicate brackets when synchronously forwarding from multiple inports', (done) -> - c = new noflo.Component - inPorts: - in1: - datatype: 'string' - in2: - datatype: 'string' - outPorts: - out: - datatype: 'string' - error: - datatype: 'object' - forwardBrackets: - in1: ['out', 'error'] - in2: ['out', 'error'] - process: (input, output) -> - return unless input.hasData 'in1', 'in2' - [one, two] = input.getData 'in1', 'in2' - output.sendDone - out: "#{one}:#{two}" - - c.inPorts.in1.attach sin1 - c.inPorts.in2.attach sin2 - c.outPorts.out.attach sout1 - c.outPorts.error.attach sout2 - - # Fail early on errors - sout2.on 'ip', (ip) -> - return unless ip.type is 'data' - done ip.data - - expected = [ - '< a' - '< b' - 'DATA one:yksi' - '< c' - 'DATA two:kaksi' - '> c' - 'DATA three:kolme' - '> b' - '> a' - ] - received = [ - ] - - sout1.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "< #{ip.data}" - when 'data' - received.push "DATA #{ip.data}" - when 'closeBracket' - received.push "> #{ip.data}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - - sin1.post new noflo.IP 'openBracket', 'a' - sin1.post new noflo.IP 'openBracket', 'b' - sin1.post new noflo.IP 'data', 'one' - sin1.post new noflo.IP 'openBracket', 'c' - sin1.post new noflo.IP 'data', 'two' - sin1.post new noflo.IP 'closeBracket', 'c' - sin2.post new noflo.IP 'openBracket', 'a' - sin2.post new noflo.IP 'openBracket', 'b' - sin2.post new noflo.IP 'data', 'yksi' - sin2.post new noflo.IP 'data', 'kaksi' - sin1.post new noflo.IP 'data', 'three' - sin1.post new noflo.IP 'closeBracket', 'b' - sin1.post new noflo.IP 'closeBracket', 'a' - sin2.post new noflo.IP 'data', 'kolme' - sin2.post new noflo.IP 'closeBracket', 'b' - sin2.post new noflo.IP 'closeBracket', 'a' - - it 'should not apply auto-ordering if that option is false', (done) -> - c = new noflo.Component - inPorts: - msg: datatype: 'string' - delay: datatype: 'int' - outPorts: - out: datatype: 'object' - ordered: false - autoOrdering: false - process: (input, output) -> - # Skip brackets - return input.get input.port.name if input.ip.type isnt 'data' - return unless input.has 'msg', 'delay' - [msg, delay] = input.getData 'msg', 'delay' - setTimeout -> - output.sendDone - out: { msg: msg, delay: delay } - , delay - - c.inPorts.msg.attach sin1 - c.inPorts.delay.attach sin2 - c.outPorts.out.attach sout1 - - sample = [ - { delay: 30, msg: "one" } - { delay: 0, msg: "two" } - { delay: 20, msg: "three" } - { delay: 10, msg: "four" } - ] - - count = 0 - sout1.on 'ip', (ip) -> - count++ - switch count - when 1 then src = sample[1] - when 2 then src = sample[3] - when 3 then src = sample[2] - when 4 then src = sample[0] - chai.expect(ip.data).to.eql src - done() if count is 4 - - sin1.post new noflo.IP 'openBracket', 'msg' - sin2.post new noflo.IP 'openBracket', 'delay' - - for ip in sample - sin1.post new noflo.IP 'data', ip.msg - sin2.post new noflo.IP 'data', ip.delay - - sin1.post new noflo.IP 'closeBracket', 'msg' - sin2.post new noflo.IP 'closeBracket', 'delay' - - it 'should forward noflo.IP metadata for map-style components', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - error: - datatype: 'object' - process: (input, output) -> - str = input.getData() - if typeof str isnt 'string' - return output.sendDone new Error 'Input is not string' - output.pass str.toUpperCase() - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - c.outPorts.error.attach sout2 - - source = [ - 'foo' - 'bar' - 'baz' - ] - count = 0 - sout1.on 'ip', (ip) -> - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.count).to.be.a 'number' - chai.expect(ip.length).to.be.a 'number' - chai.expect(ip.data).to.equal source[ip.count].toUpperCase() - chai.expect(ip.length).to.equal source.length - count++ - done() if count is source.length - - sout2.on 'ip', (ip) -> - console.log 'Unexpected error', ip - done ip.data - - n = 0 - for str in source - sin1.post new noflo.IP 'data', str, - count: n++ - length: source.length - - it 'should be safe dropping IPs', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - error: - datatype: 'object' - process: (input, output) -> - data = input.get 'in' - data.drop() - output.done() - done() - - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - c.outPorts.error.attach sout2 - - sout1.on 'ip', (ip) -> - done ip - - sin1.post new noflo.IP 'data', 'foo', - meta: 'bar' - - describe 'with custom callbacks', -> - - beforeEach (done) -> - c = new noflo.Component - inPorts: - foo: datatype: 'string' - bar: - datatype: 'int' - control: true - outPorts: - baz: datatype: 'object' - err: datatype: 'object' - ordered: true - activateOnInput: false - process: (input, output) -> - return unless input.has 'foo', 'bar' - [foo, bar] = input.getData 'foo', 'bar' - if bar < 0 or bar > 1000 - return output.sendDone - err: new Error "Bar is not correct: #{bar}" - # Start capturing output - input.activate() - output.send - baz: new noflo.IP 'openBracket' - baz = - foo: foo - bar: bar - output.send - baz: baz - setTimeout -> - output.send - baz: new noflo.IP 'closeBracket' - output.done() - , bar - c.inPorts.foo.attach sin1 - c.inPorts.bar.attach sin2 - c.outPorts.baz.attach sout1 - c.outPorts.err.attach sout2 - done() - - it 'should fail on wrong input', (done) -> - sout1.once 'ip', (ip) -> - done new Error 'Unexpected baz' - sout2.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.data).to.be.an 'error' - chai.expect(ip.data.message).to.contain 'Bar' - done() - - sin1.post new noflo.IP 'data', 'fff' - sin2.post new noflo.IP 'data', -120 - - it 'should send substreams', (done) -> - sample = [ - { bar: 30, foo: "one" } - { bar: 0, foo: "two" } - ] - expected = [ - '<' - 'one' - '>' - '<' - 'two' - '>' - ] - actual = [] - count = 0 - sout1.on 'ip', (ip) -> - count++ - switch ip.type - when 'openBracket' - actual.push '<' - when 'closeBracket' - actual.push '>' - else - actual.push ip.data.foo - if count is 6 - chai.expect(actual).to.eql expected - done() - sout2.once 'ip', (ip) -> - done ip.data - - for item in sample - sin2.post new noflo.IP 'data', item.bar - sin1.post new noflo.IP 'data', item.foo - - describe 'using streams', -> - it 'should not trigger without a full stream without getting the whole stream', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - process: (input, output) -> - if input.hasStream 'in' - done new Error 'should never trigger this' - - if (input.has 'in', (ip) -> ip.type is 'closeBracket') - done() - - c.forwardBrackets = {} - c.inPorts.in.attach sin1 - - sin1.post new noflo.IP 'openBracket' - sin1.post new noflo.IP 'openBracket' - sin1.post new noflo.IP 'openBracket' - sin1.post new noflo.IP 'data', 'eh' - sin1.post new noflo.IP 'closeBracket' - - it 'should trigger when forwardingBrackets because then it is only data with no brackets and is a full stream', (done) -> - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - process: (input, output) -> - return unless input.hasStream 'in' - done() - c.forwardBrackets = - in: ['out'] - - c.inPorts.in.attach sin1 - sin1.post new noflo.IP 'data', 'eh' - - it 'should get full stream when it has a single packet stream and it should clear it', (done) -> - c = new noflo.Component - inPorts: - eh: - datatype: 'string' - outPorts: - canada: - datatype: 'string' - process: (input, output) -> - return unless input.hasStream 'eh' - stream = input.getStream 'eh' - packetTypes = stream.map (ip) -> [ip.type, ip.data] - chai.expect(packetTypes).to.eql [ - ['data', 'moose'] - ] - chai.expect(input.has('eh')).to.equal false - done() - - c.inPorts.eh.attach sin1 - sin1.post new noflo.IP 'data', 'moose' - it 'should get full stream when it has a full stream, and it should clear it', (done) -> - c = new noflo.Component - inPorts: - eh: - datatype: 'string' - outPorts: - canada: - datatype: 'string' - process: (input, output) -> - return unless input.hasStream 'eh' - stream = input.getStream 'eh' - packetTypes = stream.map (ip) -> [ip.type, ip.data] - chai.expect(packetTypes).to.eql [ - ['openBracket', null] - ['openBracket', 'foo'] - ['data', 'moose'] - ['closeBracket', 'foo'] - ['closeBracket', null] - ] - chai.expect(input.has('eh')).to.equal false - done() - - c.inPorts.eh.attach sin1 - sin1.post new noflo.IP 'openBracket' - sin1.post new noflo.IP 'openBracket', 'foo' - sin1.post new noflo.IP 'data', 'moose' - sin1.post new noflo.IP 'closeBracket', 'foo' - sin1.post new noflo.IP 'closeBracket' - it 'should get data when it has a full stream', (done) -> - c = new noflo.Component - inPorts: - eh: - datatype: 'string' - outPorts: - canada: - datatype: 'string' - forwardBrackets: - eh: ['canada'] - process: (input, output) -> - return unless input.hasStream 'eh' - data = input.get 'eh' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 'moose' - output.sendDone data - - expected = [ - ['openBracket', null] - ['openBracket', 'foo'] - ['data', 'moose'] - ['closeBracket', 'foo'] - ['closeBracket', null] - ] - received = [] - sout1.on 'ip', (ip) -> - received.push [ip.type, ip.data] - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - c.inPorts.eh.attach sin1 - c.outPorts.canada.attach sout1 - sin1.post new noflo.IP 'openBracket' - sin1.post new noflo.IP 'openBracket', 'foo' - sin1.post new noflo.IP 'data', 'moose' - sin1.post new noflo.IP 'closeBracket', 'foo' - sin1.post new noflo.IP 'closeBracket' - - describe 'with a simple ordered stream', -> - it 'should send packets with brackets in expected order when synchronous', (done) -> - received = [] - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - process: (input, output) -> - return unless input.has 'in' - data = input.getData 'in' - output.sendDone - out: data - c.nodeId = 'Issue465' - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - - sout1.on 'ip', (ip) -> - if ip.type is 'openBracket' - return unless ip.data - received.push "< #{ip.data}" - return - if ip.type is 'closeBracket' - return unless ip.data - received.push "> #{ip.data}" - return - received.push ip.data - sout1.on 'disconnect', -> - chai.expect(received).to.eql [ - '< 1' - '< 2' - 'A' - '> 2' - 'B' - '> 1' - ] - done() - sin1.connect() - sin1.beginGroup 1 - sin1.beginGroup 2 - sin1.send 'A' - sin1.endGroup() - sin1.send 'B' - sin1.endGroup() - sin1.disconnect() - it 'should send packets with brackets in expected order when asynchronous', (done) -> - received = [] - c = new noflo.Component - inPorts: - in: - datatype: 'string' - outPorts: - out: - datatype: 'string' - process: (input, output) -> - return unless input.has 'in' - data = input.getData 'in' - setTimeout -> - output.sendDone - out: data - , 1 - c.nodeId = 'Issue465' - c.inPorts.in.attach sin1 - c.outPorts.out.attach sout1 - - sout1.on 'ip', (ip) -> - if ip.type is 'openBracket' - return unless ip.data - received.push "< #{ip.data}" - return - if ip.type is 'closeBracket' - return unless ip.data - received.push "> #{ip.data}" - return - received.push ip.data - sout1.on 'disconnect', -> - chai.expect(received).to.eql [ - '< 1' - '< 2' - 'A' - '> 2' - 'B' - '> 1' - ] - done() - - sin1.connect() - sin1.beginGroup 1 - sin1.beginGroup 2 - sin1.send 'A' - sin1.endGroup() - sin1.send 'B' - sin1.endGroup() - sin1.disconnect() - - describe 'with generator components', -> - c = null - sin1 = null - sin2 = null - sin3 = null - sout1 = null - sout2 = null - before (done) -> - c = new noflo.Component - inPorts: - interval: - datatype: 'number' - control: true - start: datatype: 'bang' - stop: datatype: 'bang' - outPorts: - out: datatype: 'bang' - err: datatype: 'object' - timer: null - ordered: false - autoOrdering: false - process: (input, output, context) -> - return unless input.has 'interval' - if input.has 'start' - start = input.get 'start' - interval = parseInt input.getData 'interval' - clearInterval @timer if @timer - @timer = setInterval -> - context.activate() - setTimeout -> - output.ports.out.sendIP new noflo.IP 'data', true - context.deactivate() - , 5 # delay of 3 to test async - , interval - if input.has 'stop' - stop = input.get 'stop' - clearInterval @timer if @timer - output.done() - - sin1 = new noflo.internalSocket.InternalSocket - sin2 = new noflo.internalSocket.InternalSocket - sin3 = new noflo.internalSocket.InternalSocket - sout1 = new noflo.internalSocket.InternalSocket - sout2 = new noflo.internalSocket.InternalSocket - c.inPorts.interval.attach sin1 - c.inPorts.start.attach sin2 - c.inPorts.stop.attach sin3 - c.outPorts.out.attach sout1 - c.outPorts.err.attach sout2 - done() - - it 'should emit start event when started', (done) -> - c.on 'start', -> - chai.expect(c.started).to.be.true - done() - c.start (err) -> - return done err if err - - it 'should emit activate/deactivate event on every tick', (done) -> - @timeout 100 - count = 0 - dcount = 0 - c.on 'activate', (load) -> - count++ - c.on 'deactivate', (load) -> - dcount++ - # Stop when the stack of processes grows - if count is 3 and dcount is 3 - sin3.post new noflo.IP 'data', true - done() - sin1.post new noflo.IP 'data', 2 - sin2.post new noflo.IP 'data', true - - it 'should emit end event when stopped and no activate after it', (done) -> - c.on 'end', -> - chai.expect(c.started).to.be.false - done() - c.on 'activate', (load) -> - unless c.started - done new Error 'Unexpected activate after end' - c.shutdown (err) -> - done err if err diff --git a/spec/Component.js b/spec/Component.js new file mode 100644 index 000000000..693360b3d --- /dev/null +++ b/spec/Component.js @@ -0,0 +1,3047 @@ +let chai; let noflo; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); +} else { + noflo = require('noflo'); +} + +describe('Component', () => { + describe('with required ports', () => { + it('should throw an error upon sending packet to an unattached required port', () => { + const s2 = new noflo.internalSocket.InternalSocket(); + const c = new noflo.Component({ + outPorts: { + required_port: { + required: true, + }, + optional_port: {}, + }, + }); + c.outPorts.optional_port.attach(s2); + chai.expect(() => c.outPorts.required_port.send('foo')).to.throw(); + }); + it('should be cool with an attached port', () => { + const s1 = new noflo.internalSocket.InternalSocket(); + const s2 = new noflo.internalSocket.InternalSocket(); + const c = new noflo.Component({ + inPorts: { + required_port: { + required: true, + }, + optional_port: {}, + }, + }); + c.inPorts.required_port.attach(s1); + c.inPorts.optional_port.attach(s2); + const f = function () { + s1.send('some-more-data'); + s2.send('some-data'); + }; + chai.expect(f).to.not.throw(); + }); + }); + describe('with component creation shorthand', () => { + it('should make component creation easy', (done) => { + const c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + just_processor: {}, + }, + process(input, output) { + let packet; + if (input.hasData('in')) { + packet = input.getData('in'); + chai.expect(packet).to.equal('some-data'); + output.done(); + return; + } + if (input.hasData('just_processor')) { + packet = input.getData('just_processor'); + chai.expect(packet).to.equal('some-data'); + output.done(); + done(); + } + }, + }); + + const s1 = new noflo.internalSocket.InternalSocket(); + c.inPorts.in.attach(s1); + c.inPorts.in.nodeInstance = c; + const s2 = new noflo.internalSocket.InternalSocket(); + c.inPorts.just_processor.attach(s1); + c.inPorts.just_processor.nodeInstance = c; + s1.send('some-data'); + s2.send('some-data'); + }); + it('should throw errors if there is no error port', (done) => { + const c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + process(input, output) { + const packet = input.getData('in'); + chai.expect(packet).to.equal('some-data'); + chai.expect(() => output.error(new Error())).to.throw(Error); + done(); + }, + }); + + const s1 = new noflo.internalSocket.InternalSocket(); + c.inPorts.in.attach(s1); + c.inPorts.in.nodeInstance = c; + s1.send('some-data'); + }); + it('should throw errors if there is a non-attached error port', (done) => { + const c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + outPorts: { + error: { + datatype: 'object', + required: true, + }, + }, + process(input, output) { + const packet = input.getData('in'); + chai.expect(packet).to.equal('some-data'); + chai.expect(() => output.error(new Error())).to.throw(Error); + done(); + }, + }); + + const s1 = new noflo.internalSocket.InternalSocket(); + c.inPorts.in.attach(s1); + c.inPorts.in.nodeInstance = c; + s1.send('some-data'); + }); + it('should not throw errors if there is a non-required error port', (done) => { + const c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + outPorts: { + error: { + required: false, + }, + }, + process(input) { + const packet = input.getData('in'); + chai.expect(packet).to.equal('some-data'); + c.error(new Error()); + done(); + }, + }); + + const s1 = new noflo.internalSocket.InternalSocket(); + c.inPorts.in.attach(s1); + c.inPorts.in.nodeInstance = c; + s1.send('some-data'); + }); + it('should send errors if there is a connected error port', (done) => { + const c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + outPorts: { + error: { + datatype: 'object', + }, + }, + process(input, output) { + if (!input.hasData('in')) { return; } + const packet = input.getData('in'); + chai.expect(packet).to.equal('some-data'); + output.done(new Error()); + }, + }); + + const s1 = new noflo.internalSocket.InternalSocket(); + const s2 = new noflo.internalSocket.InternalSocket(); + const groups = [ + 'foo', + 'bar', + ]; + s2.on('begingroup', (grp) => { + chai.expect(grp).to.equal(groups.shift()); + }); + s2.on('data', (err) => { + chai.expect(err).to.be.an.instanceOf(Error); + chai.expect(groups.length).to.equal(0); + done(); + }); + + c.inPorts.in.attach(s1); + c.outPorts.error.attach(s2); + c.inPorts.in.nodeInstance = c; + s1.beginGroup('foo'); + s1.beginGroup('bar'); + s1.send('some-data'); + }); + }); + describe('defining ports with invalid names', () => { + it('should throw an error with uppercase letters in inport', () => { + const shorthand = () => new noflo.Component({ + inPorts: { + fooPort: {}, + }, + }); + chai.expect(shorthand).to.throw(); + }); + it('should throw an error with uppercase letters in outport', () => { + const shorthand = () => new noflo.Component({ + outPorts: { + BarPort: {}, + }, + }); + chai.expect(shorthand).to.throw(); + }); + it('should throw an error with special characters in inport', () => { + const shorthand = () => new noflo.Component({ + inPorts: { + '$%^&*a': {}, + }, + }); + chai.expect(shorthand).to.throw(); + }); + }); + describe('with non-existing ports', () => { + const getComponent = function () { + return new noflo.Component({ + inPorts: { + in: {}, + }, + outPorts: { + out: {}, + }, + }); + }; + it('should throw an error when checking attached for non-existing port', (done) => { + const c = getComponent(); + c.process((input) => { + try { + input.attached('foo'); + } catch (e) { + chai.expect(e).to.be.an('Error'); + chai.expect(e.message).to.contain('foo'); + done(); + return; + } + done(new Error('Expected a throw')); + }); + const sin1 = noflo.internalSocket.createSocket(); + c.inPorts.in.attach(sin1); + sin1.send('hello'); + }); + it('should throw an error when checking IP for non-existing port', (done) => { + const c = getComponent(); + c.process((input) => { + try { + input.has('foo'); + } catch (e) { + chai.expect(e).to.be.an('Error'); + chai.expect(e.message).to.contain('foo'); + done(); + return; + } + done(new Error('Expected a throw')); + }); + const sin1 = noflo.internalSocket.createSocket(); + c.inPorts.in.attach(sin1); + sin1.send('hello'); + }); + it('should throw an error when checking IP for non-existing addressable port', (done) => { + const c = getComponent(); + c.process((input) => { + try { + input.has(['foo', 0]); + } catch (e) { + chai.expect(e).to.be.an('Error'); + chai.expect(e.message).to.contain('foo'); + done(); + return; + } + done(new Error('Expected a throw')); + }); + const sin1 = noflo.internalSocket.createSocket(); + c.inPorts.in.attach(sin1); + sin1.send('hello'); + }); + it('should throw an error when checking data for non-existing port', (done) => { + const c = getComponent(); + c.process((input) => { + try { + input.hasData('foo'); + } catch (e) { + chai.expect(e).to.be.an('Error'); + chai.expect(e.message).to.contain('foo'); + done(); + return; + } + done(new Error('Expected a throw')); + }); + const sin1 = noflo.internalSocket.createSocket(); + c.inPorts.in.attach(sin1); + sin1.send('hello'); + }); + it('should throw an error when checking stream for non-existing port', (done) => { + const c = getComponent(); + c.process((input) => { + try { + input.hasStream('foo'); + } catch (e) { + chai.expect(e).to.be.an('Error'); + chai.expect(e.message).to.contain('foo'); + done(); + return; + } + done(new Error('Expected a throw')); + }); + const sin1 = noflo.internalSocket.createSocket(); + c.inPorts.in.attach(sin1); + sin1.send('hello'); + }); + }); + describe('starting a component', () => { + it('should flag the component as started', (done) => { + const c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + }); + const i = new noflo.internalSocket.InternalSocket(); + c.inPorts.in.attach(i); + c.start((err) => { + if (err) { + done(err); + return; + } + chai.expect(c.started).to.equal(true); + chai.expect(c.isStarted()).to.equal(true); + done(); + }); + }); + }); + describe('shutting down a component', () => { + it('should flag the component as not started', (done) => { + const c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + }); + const i = new noflo.internalSocket.InternalSocket(); + c.inPorts.in.attach(i); + c.start((err) => { + if (err) { + done(err); + return; + } + chai.expect(c.isStarted()).to.equal(true); + c.shutdown((err) => { + if (err) { + done(err); + return; + } + chai.expect(c.started).to.equal(false); + chai.expect(c.isStarted()).to.equal(false); + done(); + }); + }); + }); + }); + describe('with object-based IPs', () => { + it('should speak IP objects', (done) => { + const c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + }, + process(input, output) { + output.sendDone(input.get('in')); + }, + }); + + const s1 = new noflo.internalSocket.InternalSocket(); + const s2 = new noflo.internalSocket.InternalSocket(); + + s2.on('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.groups).to.be.an('array'); + chai.expect(ip.groups).to.eql(['foo']); + chai.expect(ip.data).to.be.a('string'); + chai.expect(ip.data).to.equal('some-data'); + done(); + }); + + c.inPorts.in.attach(s1); + c.outPorts.out.attach(s2); + + s1.post(new noflo.IP('data', 'some-data', + { groups: ['foo'] })); + }); + it('should support substreams', (done) => { + const c = new noflo.Component({ + forwardBrackets: {}, + inPorts: { + tags: { + datatype: 'string', + }, + }, + outPorts: { + html: { + datatype: 'string', + }, + }, + process(input, output) { + const ip = input.get('tags'); + switch (ip.type) { + case 'openBracket': + c.str += `<${ip.data}>`; + c.level++; + break; + case 'data': + c.str += ip.data; + break; + case 'closeBracket': + c.str += ``; + c.level--; + if (c.level === 0) { + output.send({ html: c.str }); + c.str = ''; + } + break; + } + output.done(); + }, + }); + c.str = ''; + c.level = 0; + + const d = new noflo.Component({ + inPorts: { + bang: { + datatype: 'bang', + }, + }, + outPorts: { + tags: { + datatype: 'string', + }, + }, + process(input, output) { + input.getData('bang'); + output.send({ tags: new noflo.IP('openBracket', 'p') }); + output.send({ tags: new noflo.IP('openBracket', 'em') }); + output.send({ tags: new noflo.IP('data', 'Hello') }); + output.send({ tags: new noflo.IP('closeBracket', 'em') }); + output.send({ tags: new noflo.IP('data', ', ') }); + output.send({ tags: new noflo.IP('openBracket', 'strong') }); + output.send({ tags: new noflo.IP('data', 'World!') }); + output.send({ tags: new noflo.IP('closeBracket', 'strong') }); + output.send({ tags: new noflo.IP('closeBracket', 'p') }); + outout.done(); + }, + }); + + const s1 = new noflo.internalSocket.InternalSocket(); + const s2 = new noflo.internalSocket.InternalSocket(); + const s3 = new noflo.internalSocket.InternalSocket(); + + s3.on('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('

Hello, World!

'); + done(); + }); + + d.inPorts.bang.attach(s1); + d.outPorts.tags.attach(s2); + c.inPorts.tags.attach(s2); + c.outPorts.html.attach(s3); + + s1.post(new noflo.IP('data', 'start')); + }); + }); + describe('with process function', () => { + let c = null; + let sin1 = null; + let sin2 = null; + let sin3 = null; + let sout1 = null; + let sout2 = null; + + beforeEach((done) => { + sin1 = new noflo.internalSocket.InternalSocket(); + sin2 = new noflo.internalSocket.InternalSocket(); + sin3 = new noflo.internalSocket.InternalSocket(); + sout1 = new noflo.internalSocket.InternalSocket(); + sout2 = new noflo.internalSocket.InternalSocket(); + done(); + }); + + it('should trigger on IPs', (done) => { + let hadIPs = []; + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'boolean' }, + }, + process(input, output) { + hadIPs = []; + if (input.has('foo')) { hadIPs.push('foo'); } + if (input.has('bar')) { hadIPs.push('bar'); } + output.sendDone({ baz: true }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + let count = 0; + sout1.on('ip', () => { + count++; + if (count === 1) { + chai.expect(hadIPs).to.eql(['foo']); + } + if (count === 2) { + chai.expect(hadIPs).to.eql(['foo', 'bar']); + done(); + } + }); + + sin1.post(new noflo.IP('data', 'first')); + sin2.post(new noflo.IP('data', 'second')); + }); + it('should trigger on IPs to addressable ports', (done) => { + const receivedIndexes = []; + c = new noflo.Component({ + inPorts: { + foo: { + datatype: 'string', + addressable: true, + }, + }, + outPorts: { + baz: { + datatype: 'boolean', + }, + }, + process(input, output) { + // See what inbound connection indexes have data + const indexesWithData = input.attached('foo').filter((idx) => input.hasData(['foo', idx])); + if (!indexesWithData.length) { return; } + // Read from the first of them + const indexToUse = indexesWithData[0]; + const packet = input.get(['foo', indexToUse]); + receivedIndexes.push({ + idx: indexToUse, + payload: packet.data, + }); + output.sendDone({ baz: true }); + }, + }); + + c.inPorts.foo.attach(sin1, 1); + c.inPorts.foo.attach(sin2, 0); + c.outPorts.baz.attach(sout1); + + let count = 0; + sout1.on('ip', () => { + count++; + if (count === 1) { + chai.expect(receivedIndexes).to.eql([{ + idx: 1, + payload: 'first', + }, + ]); + } + if (count === 2) { + chai.expect(receivedIndexes).to.eql([{ + idx: 1, + payload: 'first', + }, + { + idx: 0, + payload: 'second', + }, + ]); + done(); + } + }); + sin1.post(new noflo.IP('data', 'first')); + sin2.post(new noflo.IP('data', 'second')); + }); + it('should be able to send IPs to addressable connections', (done) => { + const expected = [{ + data: 'first', + index: 1, + }, + { + data: 'second', + index: 0, + }, + ]; + c = new noflo.Component({ + inPorts: { + foo: { + datatype: 'string', + }, + }, + outPorts: { + baz: { + datatype: 'boolean', + addressable: true, + }, + }, + process(input, output) { + if (!input.has('foo')) { return; } + const packet = input.get('foo'); + output.sendDone(new noflo.IP('data', packet.data, + { index: expected.length - 1 })); + }, + }); + + c.inPorts.foo.attach(sin1); + c.outPorts.baz.attach(sout1, 1); + c.outPorts.baz.attach(sout2, 0); + + sout1.on('ip', (ip) => { + const exp = expected.shift(); + const received = { + data: ip.data, + index: 1, + }; + chai.expect(received).to.eql(exp); + if (!expected.length) { done(); } + }); + sout2.on('ip', (ip) => { + const exp = expected.shift(); + const received = { + data: ip.data, + index: 0, + }; + chai.expect(received).to.eql(exp); + if (!expected.length) { done(); } + }); + sin1.post(new noflo.IP('data', 'first')); + sin1.post(new noflo.IP('data', 'second')); + }); + it('trying to send to addressable port without providing index should fail', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { + datatype: 'string', + }, + }, + outPorts: { + baz: { + datatype: 'boolean', + addressable: true, + }, + }, + process(input, output) { + if (!input.hasData('foo')) { return; } + const packet = input.get('foo'); + const noIndex = new noflo.IP('data', packet.data); + chai.expect(() => output.sendDone(noIndex)).to.throw(Error); + done(); + }, + }); + + c.inPorts.foo.attach(sin1); + c.outPorts.baz.attach(sout1, 1); + c.outPorts.baz.attach(sout2, 0); + + sout1.on('ip', () => {}); + sout2.on('ip', () => {}); + + sin1.post(new noflo.IP('data', 'first')); + }); + it('should be able to send falsy IPs', (done) => { + const expected = [{ + port: 'out1', + data: 1, + }, + { + port: 'out2', + data: 0, + }, + ]; + c = new noflo.Component({ + inPorts: { + foo: { + datatype: 'string', + }, + }, + outPorts: { + out1: { + datatype: 'int', + }, + out2: { + datatype: 'int', + }, + }, + process(input, output) { + if (!input.has('foo')) { return; } + input.get('foo'); + output.sendDone({ + out1: 1, + out2: 0, + }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.outPorts.out1.attach(sout1, 1); + c.outPorts.out2.attach(sout2, 0); + + sout1.on('ip', (ip) => { + const exp = expected.shift(); + const received = { + port: 'out1', + data: ip.data, + }; + chai.expect(received).to.eql(exp); + if (!expected.length) { done(); } + }); + sout2.on('ip', (ip) => { + const exp = expected.shift(); + const received = { + port: 'out2', + data: ip.data, + }; + chai.expect(received).to.eql(exp); + if (!expected.length) { done(); } + }); + sin1.post(new noflo.IP('data', 'first')); + }); + it('should not be triggered by non-triggering ports', (done) => { + const triggered = []; + c = new noflo.Component({ + inPorts: { + foo: { + datatype: 'string', + triggering: false, + }, + bar: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'boolean' }, + }, + process(input, output) { + triggered.push(input.port.name); + output.sendDone({ baz: true }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + let count = 0; + sout1.on('ip', () => { + count++; + if (count === 1) { + chai.expect(triggered).to.eql(['bar']); + } + if (count === 2) { + chai.expect(triggered).to.eql(['bar', 'bar']); + done(); + } + }); + + sin1.post(new noflo.IP('data', 'first')); + sin2.post(new noflo.IP('data', 'second')); + sin1.post(new noflo.IP('data', 'first')); + sin2.post(new noflo.IP('data', 'second')); + }); + it('should fetch undefined for premature data', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { + datatype: 'string', + }, + bar: { + datatype: 'boolean', + triggering: false, + control: true, + }, + baz: { + datatype: 'string', + triggering: false, + control: true, + }, + }, + process(input) { + if (!input.has('foo')) { return; } + const [foo, bar, baz] = input.getData('foo', 'bar', 'baz'); + chai.expect(foo).to.be.a('string'); + chai.expect(bar).to.be.undefined; + chai.expect(baz).to.be.undefined; + done(); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.inPorts.baz.attach(sin3); + + sin1.post(new noflo.IP('data', 'AZ')); + sin2.post(new noflo.IP('data', true)); + sin3.post(new noflo.IP('data', 'first')); + }); + it('should receive and send complete noflo.IP objects', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'object' }, + }, + process(input, output) { + if (!input.has('foo', 'bar')) { return; } + const [foo, bar] = input.get('foo', 'bar'); + const baz = { + foo: foo.data, + bar: bar.data, + groups: foo.groups, + type: bar.type, + }; + output.sendDone({ + baz: new noflo.IP('data', baz, + { groups: ['baz'] }), + }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data.foo).to.equal('foo'); + chai.expect(ip.data.bar).to.equal('bar'); + chai.expect(ip.data.groups).to.eql(['foo']); + chai.expect(ip.data.type).to.equal('data'); + chai.expect(ip.groups).to.eql(['baz']); + done(); + }); + + sin1.post(new noflo.IP('data', 'foo', + { groups: ['foo'] })); + sin2.post(new noflo.IP('data', 'bar', + { groups: ['bar'] })); + }); + it('should stamp IP objects with the datatype of the outport when sending', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'all' }, + }, + outPorts: { + baz: { datatype: 'string' }, + }, + process(input, output) { + if (!input.has('foo')) { return; } + const foo = input.get('foo'); + output.sendDone({ baz: foo }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.datatype).to.equal('string'); + done(); + }); + + sin1.post(new noflo.IP('data', 'foo')); + }); + it('should stamp IP objects with the datatype of the inport when receiving', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'all' }, + }, + process(input, output) { + if (!input.has('foo')) { return; } + const foo = input.get('foo'); + output.sendDone({ baz: foo }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.datatype).to.equal('string'); + done(); + }); + + sin1.post(new noflo.IP('data', 'foo')); + }); + it('should stamp IP objects with the schema of the outport when sending', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'all' }, + }, + outPorts: { + baz: { + datatype: 'string', + schema: 'text/markdown', + }, + }, + process(input, output) { + if (!input.has('foo')) { return; } + const foo = input.get('foo'); + output.sendDone({ baz: foo }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.datatype).to.equal('string'); + chai.expect(ip.schema).to.equal('text/markdown'); + done(); + }); + + sin1.post(new noflo.IP('data', 'foo')); + }); + it('should stamp IP objects with the schema of the inport when receiving', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { + datatype: 'string', + schema: 'text/markdown', + }, + }, + outPorts: { + baz: { datatype: 'all' }, + }, + process(input, output) { + if (!input.has('foo')) { return; } + const foo = input.get('foo'); + output.sendDone({ baz: foo }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.datatype).to.equal('string'); + chai.expect(ip.schema).to.equal('text/markdown'); + done(); + }); + + sin1.post(new noflo.IP('data', 'foo')); + }); + it('should receive and send just IP data if wanted', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'object' }, + }, + process(input, output) { + if (!input.has('foo', 'bar')) { return; } + const [foo, bar] = input.getData('foo', 'bar'); + const baz = { + foo, + bar, + }; + output.sendDone({ baz }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data.foo).to.equal('foo'); + chai.expect(ip.data.bar).to.equal('bar'); + done(); + }); + + sin1.post(new noflo.IP('data', 'foo', + { groups: ['foo'] })); + sin2.post(new noflo.IP('data', 'bar', + { groups: ['bar'] })); + }); + it('should receive IPs and be able to selectively find them', (done) => { + let called = 0; + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'object' }, + }, + process(input, output) { + const validate = function (ip) { + called++; + return (ip.type === 'data') && (ip.data === 'hello'); + }; + if (!input.has('foo', 'bar', validate)) { + return; + } + let foo = input.get('foo'); + while ((foo != null ? foo.type : undefined) !== 'data') { + foo = input.get('foo'); + } + const bar = input.getData('bar'); + output.sendDone({ baz: `${foo.data}:${bar}` }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + let shouldHaveSent = false; + + sout1.on('ip', (ip) => { + chai.expect(shouldHaveSent, 'Should not sent before its time').to.equal(true); + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('hello:hello'); + chai.expect(called).to.equal(10); + done(); + }); + + sin1.post(new noflo.IP('openBracket', 'a')); + sin1.post(new noflo.IP('data', 'hello', + sin1.post(new noflo.IP('closeBracket', 'a')))); + shouldHaveSent = true; + sin2.post(new noflo.IP('data', 'hello')); + }); + it('should keep last value for controls', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { + datatype: 'string', + control: true, + }, + }, + outPorts: { + baz: { datatype: 'object' }, + }, + process(input, output) { + if (!input.has('foo', 'bar')) { return; } + const [foo, bar] = input.getData('foo', 'bar'); + const baz = { + foo, + bar, + }; + output.sendDone({ baz }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data.foo).to.equal('foo'); + chai.expect(ip.data.bar).to.equal('bar'); + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data.foo).to.equal('boo'); + chai.expect(ip.data.bar).to.equal('bar'); + done(); + }); + }); + + sin1.post(new noflo.IP('data', 'foo')); + sin2.post(new noflo.IP('data', 'bar')); + sin1.post(new noflo.IP('data', 'boo')); + }); + it('should keep last data-typed IP packet for controls', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { + datatype: 'string', + control: true, + }, + }, + outPorts: { + baz: { datatype: 'object' }, + }, + process(input, output) { + if (!input.has('foo', 'bar')) { return; } + const [foo, bar] = input.getData('foo', 'bar'); + const baz = { + foo, + bar, + }; + output.sendDone({ baz }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data.foo).to.equal('foo'); + chai.expect(ip.data.bar).to.equal('bar'); + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data.foo).to.equal('boo'); + chai.expect(ip.data.bar).to.equal('bar'); + done(); + }); + }); + + sin1.post(new noflo.IP('data', 'foo')); + sin2.post(new noflo.IP('openBracket')); + sin2.post(new noflo.IP('data', 'bar')); + sin2.post(new noflo.IP('closeBracket')); + sin1.post(new noflo.IP('data', 'boo')); + }); + it('should isolate packets with different scopes', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'string' }, + }, + process(input, output) { + if (!input.has('foo', 'bar')) { return; } + const [foo, bar] = input.getData('foo', 'bar'); + output.sendDone({ baz: `${foo} and ${bar}` }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.scope).to.equal('1'); + chai.expect(ip.data).to.equal('Josh and Laura'); + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.scope).to.equal('2'); + chai.expect(ip.data).to.equal('Jane and Luke'); + done(); + }); + }); + + sin1.post(new noflo.IP('data', 'Josh', { scope: '1' })); + sin2.post(new noflo.IP('data', 'Luke', { scope: '2' })); + sin2.post(new noflo.IP('data', 'Laura', { scope: '1' })); + sin1.post(new noflo.IP('data', 'Jane', { scope: '2' })); + }); + it('should be able to change scope', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'string' }, + }, + process(input, output) { + const foo = input.getData('foo'); + output.sendDone({ baz: new noflo.IP('data', foo, { scope: 'baz' }) }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.scope).to.equal('baz'); + chai.expect(ip.data).to.equal('foo'); + done(); + }); + + sin1.post(new noflo.IP('data', 'foo', { scope: 'foo' })); + }); + it('should support integer scopes', (done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { datatype: 'string' }, + }, + outPorts: { + baz: { datatype: 'string' }, + }, + process(input, output) { + if (!input.has('foo', 'bar')) { return; } + const [foo, bar] = input.getData('foo', 'bar'); + output.sendDone({ baz: `${foo} and ${bar}` }); + }, + }); + + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.scope).to.equal(1); + chai.expect(ip.data).to.equal('Josh and Laura'); + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.scope).to.equal(0); + chai.expect(ip.data).to.equal('Jane and Luke'); + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.scope).to.be.null; + chai.expect(ip.data).to.equal('Tom and Anna'); + done(); + }); + }); + }); + + sin1.post(new noflo.IP('data', 'Tom')); + sin1.post(new noflo.IP('data', 'Josh', { scope: 1 })); + sin2.post(new noflo.IP('data', 'Luke', { scope: 0 })); + sin2.post(new noflo.IP('data', 'Laura', { scope: 1 })); + sin1.post(new noflo.IP('data', 'Jane', { scope: 0 })); + sin2.post(new noflo.IP('data', 'Anna')); + }); + it('should preserve order between input and output', (done) => { + c = new noflo.Component({ + inPorts: { + msg: { datatype: 'string' }, + delay: { datatype: 'int' }, + }, + outPorts: { + out: { datatype: 'object' }, + }, + ordered: true, + process(input, output) { + if (!input.has('msg', 'delay')) { return; } + const [msg, delay] = input.getData('msg', 'delay'); + setTimeout(() => output.sendDone({ out: { msg, delay } }), + delay); + }, + }); + + c.inPorts.msg.attach(sin1); + c.inPorts.delay.attach(sin2); + c.outPorts.out.attach(sout1); + + const sample = [ + { delay: 30, msg: 'one' }, + { delay: 0, msg: 'two' }, + { delay: 20, msg: 'three' }, + { delay: 10, msg: 'four' }, + ]; + + sout1.on('ip', (ip) => { + chai.expect(ip.data).to.eql(sample.shift()); + if (sample.length === 0) { done(); } + }); + + for (const ip of sample) { + sin1.post(new noflo.IP('data', ip.msg)); + sin2.post(new noflo.IP('data', ip.delay)); + } + }); + it('should ignore order between input and output', (done) => { + c = new noflo.Component({ + inPorts: { + msg: { datatype: 'string' }, + delay: { datatype: 'int' }, + }, + outPorts: { + out: { datatype: 'object' }, + }, + ordered: false, + process(input, output) { + if (!input.has('msg', 'delay')) { return; } + const [msg, delay] = input.getData('msg', 'delay'); + setTimeout(() => output.sendDone({ out: { msg, delay } }), + delay); + }, + }); + + c.inPorts.msg.attach(sin1); + c.inPorts.delay.attach(sin2); + c.outPorts.out.attach(sout1); + + const sample = [ + { delay: 30, msg: 'one' }, + { delay: 0, msg: 'two' }, + { delay: 20, msg: 'three' }, + { delay: 10, msg: 'four' }, + ]; + + let count = 0; + sout1.on('ip', (ip) => { + let src; + count++; + switch (count) { + case 1: src = sample[1]; break; + case 2: src = sample[3]; break; + case 3: src = sample[2]; break; + case 4: src = sample[0]; break; + } + chai.expect(ip.data).to.eql(src); + if (count === 4) { done(); } + }); + + for (const ip of sample) { + sin1.post(new noflo.IP('data', ip.msg)); + sin2.post(new noflo.IP('data', ip.delay)); + } + }); + it('should throw errors if there is no error port', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + process(input, output) { + const packet = input.get('in'); + chai.expect(packet.data).to.equal('some-data'); + chai.expect(() => output.done(new Error('Should fail'))).to.throw(Error); + done(); + }, + }); + + c.inPorts.in.attach(sin1); + sin1.post(new noflo.IP('data', 'some-data')); + }); + it('should throw errors if there is a non-attached error port', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + outPorts: { + error: { + datatype: 'object', + required: true, + }, + }, + process(input, output) { + const packet = input.get('in'); + chai.expect(packet.data).to.equal('some-data'); + chai.expect(() => output.sendDone(new Error('Should fail'))).to.throw(Error); + done(); + }, + }); + + c.inPorts.in.attach(sin1); + sin1.post(new noflo.IP('data', 'some-data')); + }); + it('should not throw errors if there is a non-required error port', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + outPorts: { + error: { + required: false, + }, + }, + process(input, output) { + const packet = input.get('in'); + chai.expect(packet.data).to.equal('some-data'); + output.sendDone(new Error('Should not fail')); + done(); + }, + }); + + c.inPorts.in.attach(sin1); + sin1.post(new noflo.IP('data', 'some-data')); + }); + it('should send out string other port if there is only one port aside from error', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'all', + required: true, + }, + }, + outPorts: { + out: { + required: true, + }, + error: { + required: false, + }, + }, + process(input, output) { + input.get('in'); + output.sendDone('some data'); + }, + }); + + sout1.on('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.data).to.equal('some data'); + done(); + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + + sin1.post(new noflo.IP('data', 'first')); + }); + it('should send object out other port if there is only one port aside from error', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'all', + required: true, + }, + }, + outPorts: { + out: { + required: true, + }, + error: { + required: false, + }, + }, + process(input, output) { + input.get('in'); + output.sendDone({ some: 'data' }); + }, + }); + + sout1.on('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.data).to.eql({ some: 'data' }); + done(); + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + + sin1.post(new noflo.IP('data', 'first')); + }); + it('should throw an error if sending without specifying a port and there are multiple ports', (done) => { + const f = function () { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + outPorts: { + out: { + datatype: 'all', + }, + eh: { + required: false, + }, + }, + process(input, output) { + output.sendDone('test'); + }, + }); + + c.inPorts.in.attach(sin1); + sin1.post(new noflo.IP('data', 'some-data')); + }; + chai.expect(f).to.throw(Error); + done(); + }); + it('should send errors if there is a connected error port', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + outPorts: { + error: { + datatype: 'object', + }, + }, + process(input, output) { + const packet = input.get('in'); + chai.expect(packet.data).to.equal('some-data'); + chai.expect(packet.scope).to.equal('some-scope'); + output.sendDone(new Error('Should fail')); + }, + }); + + sout1.on('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.data).to.be.an.instanceOf(Error); + chai.expect(ip.scope).to.equal('some-scope'); + done(); + }); + + c.inPorts.in.attach(sin1); + c.outPorts.error.attach(sout1); + sin1.post(new noflo.IP('data', 'some-data', + { scope: 'some-scope' })); + }); + it('should send substreams with multiple errors per activation', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + required: true, + }, + }, + outPorts: { + error: { + datatype: 'object', + }, + }, + process(input, output) { + const packet = input.get('in'); + chai.expect(packet.data).to.equal('some-data'); + chai.expect(packet.scope).to.equal('some-scope'); + const errors = []; + errors.push(new Error('One thing is invalid')); + errors.push(new Error('Another thing is invalid')); + output.sendDone(errors); + }, + }); + + const expected = [ + '<', + 'One thing is invalid', + 'Another thing is invalid', + '>', + ]; + const actual = []; + let count = 0; + + sout1.on('ip', (ip) => { + count++; + chai.expect(ip).to.be.an('object'); + chai.expect(ip.scope).to.equal('some-scope'); + if (ip.type === 'openBracket') { actual.push('<'); } + if (ip.type === 'closeBracket') { actual.push('>'); } + if (ip.type === 'data') { + chai.expect(ip.data).to.be.an.instanceOf(Error); + actual.push(ip.data.message); + } + if (count === 4) { + chai.expect(actual).to.eql(expected); + done(); + } + }); + + c.inPorts.in.attach(sin1); + c.outPorts.error.attach(sout1); + sin1.post(new noflo.IP('data', 'some-data', + { scope: 'some-scope' })); + }); + it('should forward brackets for map-style components', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + error: { + datatype: 'object', + }, + }, + process(input, output) { + const str = input.getData(); + if (typeof str !== 'string') { + output.sendDone(new Error('Input is not string')); + return; + } + output.pass(str.toUpperCase()); + }, + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + c.outPorts.error.attach(sout2); + + const source = [ + '<', + 'foo', + 'bar', + '>', + ]; + let count = 0; + + sout1.on('ip', (ip) => { + const data = (() => { + switch (ip.type) { + case 'openBracket': return '<'; + case 'closeBracket': return '>'; + default: return ip.data; + } + })(); + chai.expect(data).to.equal(source[count].toUpperCase()); + count++; + if (count === 4) { done(); } + }); + + sout2.on('ip', (ip) => { + if (ip.type !== 'data') { return; } + console.log('Unexpected error', ip); + done(ip.data); + }); + + for (const data of source) { + switch (data) { + case '<': sin1.post(new noflo.IP('openBracket')); break; + case '>': sin1.post(new noflo.IP('closeBracket')); break; + default: sin1.post(new noflo.IP('data', data)); + } + } + }); + it('should forward brackets for map-style components with addressable outport', (done) => { + let sent = false; + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + addressable: true, + }, + }, + process(input, output) { + if (!input.hasData()) { return; } + const string = input.getData(); + const idx = sent ? 0 : 1; + sent = true; + output.sendDone(new noflo.IP('data', string, + { index: idx })); + }, + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1, 1); + c.outPorts.out.attach(sout2, 0); + + const expected = [ + '1 < a', + '1 < foo', + '1 DATA first', + '1 > foo', + '0 < a', + '0 < bar', + '0 DATA second', + '0 > bar', + '0 > a', + '1 > a', + ]; + const received = []; + sout1.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`1 < ${ip.data}`); + break; + case 'data': + received.push(`1 DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`1 > ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + sout2.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`0 < ${ip.data}`); + break; + case 'data': + received.push(`0 DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`0 > ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + + sin1.post(new noflo.IP('openBracket', 'a')); + sin1.post(new noflo.IP('openBracket', 'foo')); + sin1.post(new noflo.IP('data', 'first')); + sin1.post(new noflo.IP('closeBracket', 'foo')); + sin1.post(new noflo.IP('openBracket', 'bar')); + sin1.post(new noflo.IP('data', 'second')); + sin1.post(new noflo.IP('closeBracket', 'bar')); + sin1.post(new noflo.IP('closeBracket', 'a')); + }); + it('should forward brackets for async map-style components with addressable outport', (done) => { + let sent = false; + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + addressable: true, + }, + }, + process(input, output) { + if (!input.hasData()) { return; } + const string = input.getData(); + const idx = sent ? 0 : 1; + sent = true; + setTimeout(() => output.sendDone(new noflo.IP('data', string, + { index: idx })), + 1); + }, + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1, 1); + c.outPorts.out.attach(sout2, 0); + + const expected = [ + '1 < a', + '1 < foo', + '1 DATA first', + '1 > foo', + '0 < a', + '0 < bar', + '0 DATA second', + '0 > bar', + '0 > a', + '1 > a', + ]; + const received = []; + sout1.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`1 < ${ip.data}`); + break; + case 'data': + received.push(`1 DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`1 > ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + sout2.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`0 < ${ip.data}`); + break; + case 'data': + received.push(`0 DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`0 > ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + + sin1.post(new noflo.IP('openBracket', 'a')); + sin1.post(new noflo.IP('openBracket', 'foo')); + sin1.post(new noflo.IP('data', 'first')); + sin1.post(new noflo.IP('closeBracket', 'foo')); + sin1.post(new noflo.IP('openBracket', 'bar')); + sin1.post(new noflo.IP('data', 'second')); + sin1.post(new noflo.IP('closeBracket', 'bar')); + sin1.post(new noflo.IP('closeBracket', 'a')); + }); + it('should forward brackets for map-style components with addressable in/outports', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + addressable: true, + }, + }, + outPorts: { + out: { + datatype: 'string', + addressable: true, + }, + }, + process(input, output) { + const indexesWithData = []; + for (const idx of input.attached()) { + if (input.hasData(['in', idx])) { indexesWithData.push(idx); } + } + if (!indexesWithData.length) { return; } + const indexToUse = indexesWithData[0]; + const data = input.get(['in', indexToUse]); + const ip = new noflo.IP('data', data.data); + ip.index = indexToUse; + output.sendDone(ip); + }, + }); + + c.inPorts.in.attach(sin1, 1); + c.inPorts.in.attach(sin2, 0); + c.outPorts.out.attach(sout1, 1); + c.outPorts.out.attach(sout2, 0); + + const expected = [ + '1 < a', + '1 < foo', + '1 DATA first', + '1 > foo', + '0 < bar', + '0 DATA second', + '0 > bar', + '1 > a', + ]; + const received = []; + sout1.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`1 < ${ip.data}`); + break; + case 'data': + received.push(`1 DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`1 > ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + sout2.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`0 < ${ip.data}`); + break; + case 'data': + received.push(`0 DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`0 > ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + + sin1.post(new noflo.IP('openBracket', 'a')); + sin1.post(new noflo.IP('openBracket', 'foo')); + sin1.post(new noflo.IP('data', 'first')); + sin1.post(new noflo.IP('closeBracket', 'foo')); + sin2.post(new noflo.IP('openBracket', 'bar')); + sin2.post(new noflo.IP('data', 'second')); + sin2.post(new noflo.IP('closeBracket', 'bar')); + sin1.post(new noflo.IP('closeBracket', 'a')); + }); + it('should forward brackets for async map-style components with addressable in/outports', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + addressable: true, + }, + }, + outPorts: { + out: { + datatype: 'string', + addressable: true, + }, + }, + process(input, output) { + const indexesWithData = []; + for (const idx of input.attached()) { + if (input.hasData(['in', idx])) { indexesWithData.push(idx); } + } + if (!indexesWithData.length) { return; } + const data = input.get(['in', indexesWithData[0]]); + setTimeout(() => { + const ip = new noflo.IP('data', data.data); + ip.index = data.index; + output.sendDone(ip); + }, + 1); + }, + }); + + c.inPorts.in.attach(sin1, 1); + c.inPorts.in.attach(sin2, 0); + c.outPorts.out.attach(sout1, 1); + c.outPorts.out.attach(sout2, 0); + + const expected = [ + '1 < a', + '1 < foo', + '1 DATA first', + '1 > foo', + '0 < bar', + '0 DATA second', + '0 > bar', + '1 > a', + ]; + const received = []; + sout1.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`1 < ${ip.data}`); + break; + case 'data': + received.push(`1 DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`1 > ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + sout2.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`0 < ${ip.data}`); + break; + case 'data': + received.push(`0 DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`0 > ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + + sin1.post(new noflo.IP('openBracket', 'a')); + sin1.post(new noflo.IP('openBracket', 'foo')); + sin1.post(new noflo.IP('data', 'first')); + sin1.post(new noflo.IP('closeBracket', 'foo')); + sin2.post(new noflo.IP('openBracket', 'bar')); + sin2.post(new noflo.IP('data', 'second')); + sin2.post(new noflo.IP('closeBracket', 'bar')); + sin1.post(new noflo.IP('closeBracket', 'a')); + }); + it('should forward brackets to error port in async components', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + error: { + datatype: 'object', + }, + }, + process(input, output) { + const str = input.getData(); + setTimeout(() => { + if (typeof str !== 'string') { + output.sendDone(new Error('Input is not string')); + return; + } + output.pass(str.toUpperCase()); + }, + 10); + }, + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + c.outPorts.error.attach(sout2); + + sout1.on('ip', () => {}); + // done new Error "Unexpected IP: #{ip.type} #{ip.data}" + + let count = 0; + sout2.on('ip', (ip) => { + count++; + switch (count) { + case 1: + chai.expect(ip.type).to.equal('openBracket'); + break; + case 2: + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.be.an('error'); + break; + case 3: + chai.expect(ip.type).to.equal('closeBracket'); + break; + } + if (count === 3) { done(); } + }); + + sin1.post(new noflo.IP('openBracket', 'foo')); + sin1.post(new noflo.IP('data', { bar: 'baz' })); + sin1.post(new noflo.IP('closeBracket', 'foo')); + }); + it('should not forward brackets if error port is not connected', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + required: true, + }, + error: { + datatype: 'object', + required: true, + }, + }, + process(input, output) { + const str = input.getData(); + setTimeout(() => { + if (typeof str !== 'string') { + output.sendDone(new Error('Input is not string')); + return; + } + output.pass(str.toUpperCase()); + }, + 10); + }, + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + // c.outPorts.error.attach sout2 + + sout1.on('ip', (ip) => { + if (ip.type === 'closeBracket') { done(); } + }); + + sout2.on('ip', (ip) => { + done(new Error(`Unexpected error IP: ${ip.type} ${ip.data}`)); + }); + + chai.expect(() => { + sin1.post(new noflo.IP('openBracket', 'foo')); + sin1.post(new noflo.IP('data', 'bar')); + sin1.post(new noflo.IP('closeBracket', 'foo')); + }).to.not.throw(); + }); + it('should support custom bracket forwarding mappings with auto-ordering', (done) => { + c = new noflo.Component({ + inPorts: { + msg: { + datatype: 'string', + }, + delay: { + datatype: 'int', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + error: { + datatype: 'object', + }, + }, + forwardBrackets: { + msg: ['out', 'error'], + delay: ['error'], + }, + process(input, output) { + if (!input.hasData('msg', 'delay')) { return; } + const [msg, delay] = input.getData('msg', 'delay'); + if (delay < 0) { + output.sendDone(new Error('Delay is negative')); + return; + } + setTimeout(() => { + output.sendDone({ out: { msg, delay } }); + }, + delay); + }, + }); + + c.inPorts.msg.attach(sin1); + c.inPorts.delay.attach(sin2); + c.outPorts.out.attach(sout1); + c.outPorts.error.attach(sout2); + + const sample = [ + { delay: 30, msg: 'one' }, + { delay: 0, msg: 'two' }, + { delay: 20, msg: 'three' }, + { delay: 10, msg: 'four' }, + { delay: -40, msg: 'five' }, + ]; + + let count = 0; + let errCount = 0; + sout1.on('ip', (ip) => { + let src = null; + switch (count) { + case 0: + chai.expect(ip.type).to.equal('openBracket'); + chai.expect(ip.data).to.equal('msg'); + break; + case 5: + chai.expect(ip.type).to.equal('closeBracket'); + chai.expect(ip.data).to.equal('msg'); + break; + default: src = sample[count - 1]; + } + if (src) { chai.expect(ip.data).to.eql(src); } + count++; + // done() if count is 6 + }); + + sout2.on('ip', (ip) => { + switch (errCount) { + case 0: + chai.expect(ip.type).to.equal('openBracket'); + chai.expect(ip.data).to.equal('msg'); + break; + case 1: + chai.expect(ip.type).to.equal('openBracket'); + chai.expect(ip.data).to.equal('delay'); + break; + case 2: + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.be.an('error'); + break; + case 3: + chai.expect(ip.type).to.equal('closeBracket'); + chai.expect(ip.data).to.equal('delay'); + break; + case 4: + chai.expect(ip.type).to.equal('closeBracket'); + chai.expect(ip.data).to.equal('msg'); + break; + } + errCount++; + if (errCount === 5) { done(); } + }); + + sin1.post(new noflo.IP('openBracket', 'msg')); + sin2.post(new noflo.IP('openBracket', 'delay')); + + for (const ip of sample) { + sin1.post(new noflo.IP('data', ip.msg)); + sin2.post(new noflo.IP('data', ip.delay)); + } + + sin2.post(new noflo.IP('closeBracket', 'delay')); + sin1.post(new noflo.IP('closeBracket', 'msg')); + }); + it('should de-duplicate brackets when asynchronously forwarding from multiple inports', (done) => { + c = new noflo.Component({ + inPorts: { + in1: { + datatype: 'string', + }, + in2: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + error: { + datatype: 'object', + }, + }, + forwardBrackets: { + in1: ['out', 'error'], + in2: ['out', 'error'], + }, + process(input, output) { + if (!input.hasData('in1', 'in2')) { return; } + const [one, two] = input.getData('in1', 'in2'); + setTimeout(() => output.sendDone({ out: `${one}:${two}` }), + 1); + }, + }); + + c.inPorts.in1.attach(sin1); + c.inPorts.in2.attach(sin2); + c.outPorts.out.attach(sout1); + c.outPorts.error.attach(sout2); + + // Fail early on errors + sout2.on('ip', (ip) => { + if (ip.type !== 'data') { return; } + done(ip.data); + }); + + const expected = [ + '< a', + '< b', + 'DATA one:yksi', + '< c', + 'DATA two:kaksi', + '> c', + 'DATA three:kolme', + '> b', + '> a', + ]; + const received = [ + ]; + + sout1.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`< ${ip.data}`); + break; + case 'data': + received.push(`DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`> ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + + sin1.post(new noflo.IP('openBracket', 'a')); + sin1.post(new noflo.IP('openBracket', 'b')); + sin1.post(new noflo.IP('data', 'one')); + sin1.post(new noflo.IP('openBracket', 'c')); + sin1.post(new noflo.IP('data', 'two')); + sin1.post(new noflo.IP('closeBracket', 'c')); + sin2.post(new noflo.IP('openBracket', 'a')); + sin2.post(new noflo.IP('openBracket', 'b')); + sin2.post(new noflo.IP('data', 'yksi')); + sin2.post(new noflo.IP('data', 'kaksi')); + sin1.post(new noflo.IP('data', 'three')); + sin1.post(new noflo.IP('closeBracket', 'b')); + sin1.post(new noflo.IP('closeBracket', 'a')); + sin2.post(new noflo.IP('data', 'kolme')); + sin2.post(new noflo.IP('closeBracket', 'b')); + sin2.post(new noflo.IP('closeBracket', 'a')); + }); + it('should de-duplicate brackets when synchronously forwarding from multiple inports', (done) => { + c = new noflo.Component({ + inPorts: { + in1: { + datatype: 'string', + }, + in2: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + error: { + datatype: 'object', + }, + }, + forwardBrackets: { + in1: ['out', 'error'], + in2: ['out', 'error'], + }, + process(input, output) { + if (!input.hasData('in1', 'in2')) { return; } + const [one, two] = input.getData('in1', 'in2'); + output.sendDone({ out: `${one}:${two}` }); + }, + }); + + c.inPorts.in1.attach(sin1); + c.inPorts.in2.attach(sin2); + c.outPorts.out.attach(sout1); + c.outPorts.error.attach(sout2); + + // Fail early on errors + sout2.on('ip', (ip) => { + if (ip.type !== 'data') { return; } + done(ip.data); + }); + + const expected = [ + '< a', + '< b', + 'DATA one:yksi', + '< c', + 'DATA two:kaksi', + '> c', + 'DATA three:kolme', + '> b', + '> a', + ]; + const received = [ + ]; + + sout1.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`< ${ip.data}`); + break; + case 'data': + received.push(`DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`> ${ip.data}`); + break; + } + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + + sin1.post(new noflo.IP('openBracket', 'a')); + sin1.post(new noflo.IP('openBracket', 'b')); + sin1.post(new noflo.IP('data', 'one')); + sin1.post(new noflo.IP('openBracket', 'c')); + sin1.post(new noflo.IP('data', 'two')); + sin1.post(new noflo.IP('closeBracket', 'c')); + sin2.post(new noflo.IP('openBracket', 'a')); + sin2.post(new noflo.IP('openBracket', 'b')); + sin2.post(new noflo.IP('data', 'yksi')); + sin2.post(new noflo.IP('data', 'kaksi')); + sin1.post(new noflo.IP('data', 'three')); + sin1.post(new noflo.IP('closeBracket', 'b')); + sin1.post(new noflo.IP('closeBracket', 'a')); + sin2.post(new noflo.IP('data', 'kolme')); + sin2.post(new noflo.IP('closeBracket', 'b')); + sin2.post(new noflo.IP('closeBracket', 'a')); + }); + it('should not apply auto-ordering if that option is false', (done) => { + c = new noflo.Component({ + inPorts: { + msg: { datatype: 'string' }, + delay: { datatype: 'int' }, + }, + outPorts: { + out: { datatype: 'object' }, + }, + ordered: false, + autoOrdering: false, + process(input, output) { + // Skip brackets + if (input.ip.type !== 'data') { return input.get(input.port.name); } + if (!input.has('msg', 'delay')) { return; } + const [msg, delay] = input.getData('msg', 'delay'); + setTimeout(() => output.sendDone({ out: { msg, delay } }), + delay); + }, + }); + + c.inPorts.msg.attach(sin1); + c.inPorts.delay.attach(sin2); + c.outPorts.out.attach(sout1); + + const sample = [ + { delay: 30, msg: 'one' }, + { delay: 0, msg: 'two' }, + { delay: 20, msg: 'three' }, + { delay: 10, msg: 'four' }, + ]; + + let count = 0; + sout1.on('ip', (ip) => { + let src; + count++; + switch (count) { + case 1: src = sample[1]; break; + case 2: src = sample[3]; break; + case 3: src = sample[2]; break; + case 4: src = sample[0]; break; + } + chai.expect(ip.data).to.eql(src); + if (count === 4) { done(); } + }); + + sin1.post(new noflo.IP('openBracket', 'msg')); + sin2.post(new noflo.IP('openBracket', 'delay')); + + for (const ip of sample) { + sin1.post(new noflo.IP('data', ip.msg)); + sin2.post(new noflo.IP('data', ip.delay)); + } + + sin1.post(new noflo.IP('closeBracket', 'msg')); + sin2.post(new noflo.IP('closeBracket', 'delay')); + }); + it('should forward noflo.IP metadata for map-style components', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + error: { + datatype: 'object', + }, + }, + process(input, output) { + const str = input.getData(); + if (typeof str !== 'string') { + output.sendDone(new Error('Input is not string')); + return; + } + output.pass(str.toUpperCase()); + }, + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + c.outPorts.error.attach(sout2); + + const source = [ + 'foo', + 'bar', + 'baz', + ]; + let count = 0; + sout1.on('ip', (ip) => { + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.count).to.be.a('number'); + chai.expect(ip.length).to.be.a('number'); + chai.expect(ip.data).to.equal(source[ip.count].toUpperCase()); + chai.expect(ip.length).to.equal(source.length); + count++; + if (count === source.length) { done(); } + }); + + sout2.on('ip', (ip) => { + console.log('Unexpected error', ip); + done(ip.data); + }); + + let n = 0; + for (const str of source) { + sin1.post(new noflo.IP('data', str, { + count: n++, + length: source.length, + })); + } + }); + it('should be safe dropping IPs', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + error: { + datatype: 'object', + }, + }, + process(input, output) { + const data = input.get('in'); + data.drop(); + output.done(); + done(); + }, + }); + + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + c.outPorts.error.attach(sout2); + + sout1.on('ip', (ip) => { + done(ip); + }); + + sin1.post(new noflo.IP('data', 'foo', + { meta: 'bar' })); + }); + describe('with custom callbacks', () => { + beforeEach((done) => { + c = new noflo.Component({ + inPorts: { + foo: { datatype: 'string' }, + bar: { + datatype: 'int', + control: true, + }, + }, + outPorts: { + baz: { datatype: 'object' }, + err: { datatype: 'object' }, + }, + ordered: true, + activateOnInput: false, + process(input, output) { + if (!input.has('foo', 'bar')) { return; } + const [foo, bar] = input.getData('foo', 'bar'); + if ((bar < 0) || (bar > 1000)) { + output.sendDone({ err: new Error(`Bar is not correct: ${bar}`) }); + return; + } + // Start capturing output + input.activate(); + output.send({ baz: new noflo.IP('openBracket') }); + const baz = { + foo, + bar, + }; + output.send({ baz }); + setTimeout(() => { + output.send({ baz: new noflo.IP('closeBracket') }); + output.done(); + }, + bar); + }, + }); + c.inPorts.foo.attach(sin1); + c.inPorts.bar.attach(sin2); + c.outPorts.baz.attach(sout1); + c.outPorts.err.attach(sout2); + done(); + }); + it('should fail on wrong input', (done) => { + sout1.once('ip', () => { + done(new Error('Unexpected baz')); + }); + sout2.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.data).to.be.an('error'); + chai.expect(ip.data.message).to.contain('Bar'); + done(); + }); + + sin1.post(new noflo.IP('data', 'fff')); + sin2.post(new noflo.IP('data', -120)); + }); + it('should send substreams', (done) => { + const sample = [ + { bar: 30, foo: 'one' }, + { bar: 0, foo: 'two' }, + ]; + const expected = [ + '<', + 'one', + '>', + '<', + 'two', + '>', + ]; + const actual = []; + let count = 0; + sout1.on('ip', (ip) => { + count++; + switch (ip.type) { + case 'openBracket': + actual.push('<'); + break; + case 'closeBracket': + actual.push('>'); + break; + default: + actual.push(ip.data.foo); + } + if (count === 6) { + chai.expect(actual).to.eql(expected); + done(); + } + }); + sout2.once('ip', (ip) => { + done(ip.data); + }); + + for (const item of sample) { + sin2.post(new noflo.IP('data', item.bar)); + sin1.post(new noflo.IP('data', item.foo)); + } + }); + }); + describe('using streams', () => { + it('should not trigger without a full stream without getting the whole stream', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + }, + process(input) { + if (input.hasStream('in')) { + done(new Error('should never trigger this')); + } + + if (input.has('in', (ip) => ip.type === 'closeBracket')) { + done(); + } + }, + }); + + c.forwardBrackets = {}; + c.inPorts.in.attach(sin1); + + sin1.post(new noflo.IP('openBracket')); + sin1.post(new noflo.IP('openBracket')); + sin1.post(new noflo.IP('openBracket')); + sin1.post(new noflo.IP('data', 'eh')); + sin1.post(new noflo.IP('closeBracket')); + }); + it('should trigger when forwardingBrackets because then it is only data with no brackets and is a full stream', (done) => { + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + }, + process(input) { + if (!input.hasStream('in')) { return; } + done(); + }, + }); + c.forwardBrackets = { in: ['out'] }; + + c.inPorts.in.attach(sin1); + sin1.post(new noflo.IP('data', 'eh')); + }); + it('should get full stream when it has a single packet stream and it should clear it', (done) => { + c = new noflo.Component({ + inPorts: { + eh: { + datatype: 'string', + }, + }, + outPorts: { + canada: { + datatype: 'string', + }, + }, + process(input) { + if (!input.hasStream('eh')) { return; } + const stream = input.getStream('eh'); + const packetTypes = stream.map((ip) => [ip.type, ip.data]); + chai.expect(packetTypes).to.eql([ + ['data', 'moose'], + ]); + chai.expect(input.has('eh')).to.equal(false); + done(); + }, + }); + + c.inPorts.eh.attach(sin1); + sin1.post(new noflo.IP('data', 'moose')); + }); + it('should get full stream when it has a full stream, and it should clear it', (done) => { + c = new noflo.Component({ + inPorts: { + eh: { + datatype: 'string', + }, + }, + outPorts: { + canada: { + datatype: 'string', + }, + }, + process(input) { + if (!input.hasStream('eh')) { return; } + const stream = input.getStream('eh'); + const packetTypes = stream.map((ip) => [ip.type, ip.data]); + chai.expect(packetTypes).to.eql([ + ['openBracket', null], + ['openBracket', 'foo'], + ['data', 'moose'], + ['closeBracket', 'foo'], + ['closeBracket', null], + ]); + chai.expect(input.has('eh')).to.equal(false); + done(); + }, + }); + + c.inPorts.eh.attach(sin1); + sin1.post(new noflo.IP('openBracket')); + sin1.post(new noflo.IP('openBracket', 'foo')); + sin1.post(new noflo.IP('data', 'moose')); + sin1.post(new noflo.IP('closeBracket', 'foo')); + sin1.post(new noflo.IP('closeBracket')); + }); + it('should get data when it has a full stream', (done) => { + c = new noflo.Component({ + inPorts: { + eh: { + datatype: 'string', + }, + }, + outPorts: { + canada: { + datatype: 'string', + }, + }, + forwardBrackets: { + eh: ['canada'], + }, + process(input, output) { + if (!input.hasStream('eh')) { return; } + const data = input.get('eh'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal('moose'); + output.sendDone(data); + }, + }); + + const expected = [ + ['openBracket', null], + ['openBracket', 'foo'], + ['data', 'moose'], + ['closeBracket', 'foo'], + ['closeBracket', null], + ]; + const received = []; + sout1.on('ip', (ip) => { + received.push([ip.type, ip.data]); + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + c.inPorts.eh.attach(sin1); + c.outPorts.canada.attach(sout1); + sin1.post(new noflo.IP('openBracket')); + sin1.post(new noflo.IP('openBracket', 'foo')); + sin1.post(new noflo.IP('data', 'moose')); + sin1.post(new noflo.IP('closeBracket', 'foo')); + sin1.post(new noflo.IP('closeBracket')); + }); + }); + describe('with a simple ordered stream', () => { + it('should send packets with brackets in expected order when synchronous', (done) => { + const received = []; + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + }, + process(input, output) { + if (!input.has('in')) { return; } + const data = input.getData('in'); + output.sendDone({ out: data }); + }, + }); + c.nodeId = 'Issue465'; + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + + sout1.on('ip', (ip) => { + if (ip.type === 'openBracket') { + if (!ip.data) { return; } + received.push(`< ${ip.data}`); + return; + } + if (ip.type === 'closeBracket') { + if (!ip.data) { return; } + received.push(`> ${ip.data}`); + return; + } + received.push(ip.data); + }); + sout1.on('disconnect', () => { + chai.expect(received).to.eql([ + '< 1', + '< 2', + 'A', + '> 2', + 'B', + '> 1', + ]); + done(); + }); + sin1.connect(); + sin1.beginGroup(1); + sin1.beginGroup(2); + sin1.send('A'); + sin1.endGroup(); + sin1.send('B'); + sin1.endGroup(); + sin1.disconnect(); + }); + it('should send packets with brackets in expected order when asynchronous', (done) => { + const received = []; + c = new noflo.Component({ + inPorts: { + in: { + datatype: 'string', + }, + }, + outPorts: { + out: { + datatype: 'string', + }, + }, + process(input, output) { + if (!input.has('in')) { return; } + const data = input.getData('in'); + setTimeout(() => output.sendDone({ out: data }), + 1); + }, + }); + c.nodeId = 'Issue465'; + c.inPorts.in.attach(sin1); + c.outPorts.out.attach(sout1); + + sout1.on('ip', (ip) => { + if (ip.type === 'openBracket') { + if (!ip.data) { return; } + received.push(`< ${ip.data}`); + return; + } + if (ip.type === 'closeBracket') { + if (!ip.data) { return; } + received.push(`> ${ip.data}`); + return; + } + received.push(ip.data); + }); + sout1.on('disconnect', () => { + chai.expect(received).to.eql([ + '< 1', + '< 2', + 'A', + '> 2', + 'B', + '> 1', + ]); + done(); + }); + + sin1.connect(); + sin1.beginGroup(1); + sin1.beginGroup(2); + sin1.send('A'); + sin1.endGroup(); + sin1.send('B'); + sin1.endGroup(); + sin1.disconnect(); + }); + }); + }); + describe('with generator components', () => { + let c = null; + let sin1 = null; + let sin2 = null; + let sin3 = null; + let sout1 = null; + let sout2 = null; + before((done) => { + c = new noflo.Component({ + inPorts: { + interval: { + datatype: 'number', + control: true, + }, + start: { datatype: 'bang' }, + stop: { datatype: 'bang' }, + }, + outPorts: { + out: { datatype: 'bang' }, + err: { datatype: 'object' }, + }, + timer: null, + ordered: false, + autoOrdering: false, + process(input, output, context) { + if (!input.has('interval')) { return; } + if (input.has('start')) { + input.get('start'); + const interval = parseInt(input.getData('interval'), 10); + if (this.timer) { clearInterval(this.timer); } + this.timer = setInterval(() => { + context.activate(); + setTimeout(() => { + output.ports.out.sendIP(new noflo.IP('data', true)); + context.deactivate(); + }, + 5); // delay of 3 to test async + }, + interval); + } + if (input.has('stop')) { + input.get('stop'); + if (this.timer) { clearInterval(this.timer); } + } + output.done(); + }, + }); + + sin1 = new noflo.internalSocket.InternalSocket(); + sin2 = new noflo.internalSocket.InternalSocket(); + sin3 = new noflo.internalSocket.InternalSocket(); + sout1 = new noflo.internalSocket.InternalSocket(); + sout2 = new noflo.internalSocket.InternalSocket(); + c.inPorts.interval.attach(sin1); + c.inPorts.start.attach(sin2); + c.inPorts.stop.attach(sin3); + c.outPorts.out.attach(sout1); + c.outPorts.err.attach(sout2); + done(); + }); + + it('should emit start event when started', (done) => { + c.on('start', () => { + chai.expect(c.started).to.be.true; + done(); + }); + c.start((err) => { + if (err) { + done(err); + } + }); + }); + it('should emit activate/deactivate event on every tick', function (done) { + this.timeout(100); + let count = 0; + let dcount = 0; + c.on('activate', () => { + count++; + }); + c.on('deactivate', () => { + dcount++; + // Stop when the stack of processes grows + if ((count === 3) && (dcount === 3)) { + sin3.post(new noflo.IP('data', true)); + done(); + } + }); + sin1.post(new noflo.IP('data', 2)); + sin2.post(new noflo.IP('data', true)); + }); + it('should emit end event when stopped and no activate after it', (done) => { + c.on('end', () => { + chai.expect(c.started).to.be.false; + done(); + }); + c.on('activate', () => { + if (!c.started) { + done(new Error('Unexpected activate after end')); + } + }); + c.shutdown((err) => { + if (err) { done(err); } + }); + }); + }); +}); diff --git a/spec/ComponentExample.coffee b/spec/ComponentExample.coffee deleted file mode 100644 index fa28c984f..000000000 --- a/spec/ComponentExample.coffee +++ /dev/null @@ -1,79 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' -else - noflo = require 'noflo' - -describe 'MergeObjects component', -> - c = null - sin1 = null - sin2 = null - sin3 = null - sout1 = null - sout2 = null - obj1 = - name: 'Patrick' - age: 21 - obj2 = - title: 'Attorney' - age: 33 - before (done) -> - return @skip() if noflo.isBrowser() - MergeObjects = require './components/MergeObjects.coffee' - c = MergeObjects.getComponent() - sin1 = new noflo.internalSocket.InternalSocket - sin2 = new noflo.internalSocket.InternalSocket - sin3 = new noflo.internalSocket.InternalSocket - sout1 = new noflo.internalSocket.InternalSocket - sout2 = new noflo.internalSocket.InternalSocket - c.inPorts.obj1.attach sin1 - c.inPorts.obj2.attach sin2 - c.inPorts.overwrite.attach sin3 - c.outPorts.result.attach sout1 - c.outPorts.error.attach sout2 - done() - beforeEach (done) -> - sout1.removeAllListeners() - sout2.removeAllListeners() - done() - - it 'should not trigger if input is not complete', (done) -> - sout1.once 'ip', (ip) -> - done new Error "Premature result" - sout2.once 'ip', (ip) -> - done new Error "Premature error" - - sin1.post new noflo.IP 'data', obj1 - sin2.post new noflo.IP 'data', obj2 - - setTimeout done, 10 - - it 'should merge objects when input is complete', (done) -> - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.be.an 'object' - chai.expect(ip.data.name).to.equal obj1.name - chai.expect(ip.data.title).to.equal obj2.title - chai.expect(ip.data.age).to.equal obj1.age - done() - sout2.once 'ip', (ip) -> - done ip - - sin3.post new noflo.IP 'data', false - - it 'should obey the overwrite control', (done) -> - sout1.once 'ip', (ip) -> - chai.expect(ip).to.be.an 'object' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.be.an 'object' - chai.expect(ip.data.name).to.equal obj1.name - chai.expect(ip.data.title).to.equal obj2.title - chai.expect(ip.data.age).to.equal obj2.age - done() - sout2.once 'ip', (ip) -> - done ip - - sin3.post new noflo.IP 'data', true - sin1.post new noflo.IP 'data', obj1 - sin2.post new noflo.IP 'data', obj2 diff --git a/spec/ComponentExample.js b/spec/ComponentExample.js new file mode 100644 index 000000000..a9b915704 --- /dev/null +++ b/spec/ComponentExample.js @@ -0,0 +1,98 @@ +let chai; let noflo; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); +} else { + noflo = require('noflo'); +} + +describe('MergeObjects component', () => { + let c = null; + let sin1 = null; + let sin2 = null; + let sin3 = null; + let sout1 = null; + let sout2 = null; + const obj1 = { + name: 'Patrick', + age: 21, + }; + const obj2 = { + title: 'Attorney', + age: 33, + }; + before(function (done) { + if (noflo.isBrowser()) { + this.skip(); + return; + } + const MergeObjects = require('./components/MergeObjects'); + c = MergeObjects.getComponent(); + sin1 = new noflo.internalSocket.InternalSocket(); + sin2 = new noflo.internalSocket.InternalSocket(); + sin3 = new noflo.internalSocket.InternalSocket(); + sout1 = new noflo.internalSocket.InternalSocket(); + sout2 = new noflo.internalSocket.InternalSocket(); + c.inPorts.obj1.attach(sin1); + c.inPorts.obj2.attach(sin2); + c.inPorts.overwrite.attach(sin3); + c.outPorts.result.attach(sout1); + c.outPorts.error.attach(sout2); + done(); + }); + beforeEach((done) => { + sout1.removeAllListeners(); + sout2.removeAllListeners(); + done(); + }); + + it('should not trigger if input is not complete', (done) => { + sout1.once('ip', () => { + done(new Error('Premature result')); + }); + sout2.once('ip', () => { + done(new Error('Premature error')); + }); + + sin1.post(new noflo.IP('data', obj1)); + sin2.post(new noflo.IP('data', obj2)); + + setTimeout(done, 10); + }); + + it('should merge objects when input is complete', (done) => { + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.be.an('object'); + chai.expect(ip.data.name).to.equal(obj1.name); + chai.expect(ip.data.title).to.equal(obj2.title); + chai.expect(ip.data.age).to.equal(obj1.age); + done(); + }); + sout2.once('ip', (ip) => { + done(ip); + }); + + sin3.post(new noflo.IP('data', false)); + }); + + it('should obey the overwrite control', (done) => { + sout1.once('ip', (ip) => { + chai.expect(ip).to.be.an('object'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.be.an('object'); + chai.expect(ip.data.name).to.equal(obj1.name); + chai.expect(ip.data.title).to.equal(obj2.title); + chai.expect(ip.data.age).to.equal(obj2.age); + done(); + }); + sout2.once('ip', (ip) => { + done(ip); + }); + + sin3.post(new noflo.IP('data', true)); + sin1.post(new noflo.IP('data', obj1)); + sin2.post(new noflo.IP('data', obj2)); + }); +}); diff --git a/spec/ComponentLoader.coffee b/spec/ComponentLoader.coffee deleted file mode 100644 index 4058727be..000000000 --- a/spec/ComponentLoader.coffee +++ /dev/null @@ -1,674 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - shippingLanguage = 'coffeescript' - path = require 'path' - root = path.resolve __dirname, '../' - urlPrefix = './' -else - noflo = require 'noflo' - shippingLanguage = 'javascript' - root = 'noflo' - urlPrefix = '/' - -describe 'ComponentLoader with no external packages installed', -> - l = new noflo.ComponentLoader root - class Split extends noflo.Component - constructor: -> - options = - inPorts: - in: {} - outPorts: - out: {} - process: (input, output) -> - output.sendDone input.get 'in' - super options - Split.getComponent = -> new Split - - Merge = -> - inst = new noflo.Component - inst.inPorts.add 'in' - inst.outPorts.add 'out' - inst.process (input, output) -> - output.sendDone input.get 'in' - inst - - it 'should initially know of no components', -> - chai.expect(l.components).to.be.null - it 'should not initially be ready', -> - chai.expect(l.ready).to.be.false - it 'should not initially be processing', -> - chai.expect(l.processing).to.be.false - it 'should not have any packages in the checked list', -> - chai.expect(l.checked).to.not.exist - - describe 'normalizing names', -> - it 'should return simple module names as-is', -> - normalized = l.getModulePrefix 'foo' - chai.expect(normalized).to.equal 'foo' - it 'should return empty for NoFlo core', -> - normalized = l.getModulePrefix 'noflo' - chai.expect(normalized).to.equal '' - it 'should strip noflo-', -> - normalized = l.getModulePrefix 'noflo-image' - chai.expect(normalized).to.equal 'image' - it 'should strip NPM scopes', -> - normalized = l.getModulePrefix '@noflo/foo' - chai.expect(normalized).to.equal 'foo' - it 'should strip NPM scopes and noflo-', -> - normalized = l.getModulePrefix '@noflo/noflo-image' - chai.expect(normalized).to.equal 'image' - - it 'should be able to read a list of components', (done) -> - @timeout 60 * 1000 - ready = false - l.once 'ready', -> - ready = true - chai.expect(l.ready, 'should have the ready bit').to.equal true - l.listComponents (err, components) -> - return done err if err - chai.expect(l.processing, 'should have stopped processing').to.equal false - chai.expect(l.components, 'should contain components').not.to.be.empty - chai.expect(components, 'should have returned the full list').to.equal l.components - chai.expect(l.ready, 'should have been set ready').to.equal true - chai.expect(ready, 'should have emitted ready').to.equal true - done() - - unless noflo.isBrowser() - # Browser component registry can be synchronous - chai.expect(l.processing, 'should have started processing').to.equal true - - describe 'calling listComponents twice simultaneously', -> - it 'should return the same results', (done) -> - loader = new noflo.ComponentLoader root - received = [] - loader.listComponents (err, components) -> - return done err if err - received.push components - return unless received.length is 2 - chai.expect(received[0]).to.equal received[1] - done() - loader.listComponents (err, components) -> - return done err if err - received.push components - return unless received.length is 2 - chai.expect(received[0]).to.equal received[1] - done() - - describe 'after listing components', -> - it 'should have the Graph component registered', -> - chai.expect(l.components.Graph).not.to.be.empty - - describe 'loading the Graph component', -> - instance = null - it 'should be able to load the component', (done) -> - l.load 'Graph', (err, inst) -> - return done err if err - chai.expect(inst).to.be.an 'object' - chai.expect(inst.componentName).to.equal 'Graph' - instance = inst - done() - it 'should contain input ports', -> - chai.expect(instance.inPorts).to.be.an 'object' - chai.expect(instance.inPorts.graph).to.be.an 'object' - it 'should have "on" method on the input port', -> - chai.expect(instance.inPorts.graph.on).to.be.a 'function' - it 'it should know that Graph is a subgraph', -> - chai.expect(instance.isSubgraph()).to.equal true - it 'should know the description for the Graph', -> - chai.expect(instance.description).to.be.a 'string' - it 'should be able to provide an icon for the Graph', -> - chai.expect(instance.getIcon()).to.be.a 'string' - chai.expect(instance.getIcon()).to.equal 'sitemap' - it 'should be able to load the component with non-ready ComponentLoader', (done) -> - loader = new noflo.ComponentLoader root - loader.load 'Graph', (err, inst) -> - return done err if err - chai.expect(inst).to.be.an 'object' - chai.expect(inst.componentName).to.equal 'Graph' - instance = inst - done() - - describe 'loading a subgraph', -> - l = new noflo.ComponentLoader root - file = "#{urlPrefix}spec/fixtures/subgraph.fbp" - it 'should remove `graph` and `start` ports', (done) -> - l.listComponents (err, components) -> - return done err if err - l.components.Merge = Merge - l.components.Subgraph = file - l.components.Split = Split - l.load 'Subgraph', (err, inst) -> - return done err if err - chai.expect(inst).to.be.an 'object' - inst.once 'ready', -> - chai.expect(inst.inPorts.ports).not.to.have.keys ['graph','start'] - chai.expect(inst.inPorts.ports).to.have.keys ['in'] - chai.expect(inst.outPorts.ports).to.have.keys ['out'] - done() - it 'should not automatically start the subgraph if there is no `start` port', (done) -> - l.listComponents (err, components) -> - return done err if err - l.components.Merge = Merge - l.components.Subgraph = file - l.components.Split = Split - l.load 'Subgraph', (err, inst) -> - return done err if err - chai.expect(inst).to.be.an 'object' - inst.once 'ready', -> - chai.expect(inst.started).to.equal(false) - done() - it 'should also work with a passed graph object', (done) -> - noflo.graph.loadFile file, (err, graph) -> - return done err if err - l.listComponents (err, components) -> - return done err if err - l.components.Merge = Merge - l.components.Subgraph = graph - l.components.Split = Split - l.load 'Subgraph', (err, inst) -> - return done err if err - chai.expect(inst).to.be.an 'object' - inst.once 'ready', -> - chai.expect(inst.inPorts.ports).not.to.have.keys ['graph','start'] - chai.expect(inst.inPorts.ports).to.have.keys ['in'] - chai.expect(inst.outPorts.ports).to.have.keys ['out'] - done() - - describe 'loading the Graph component', -> - instance = null - it 'should be able to load the component', (done) -> - l.load 'Graph', (err, graph) -> - return done err if err - chai.expect(graph).to.be.an 'object' - instance = graph - done() - it 'should have a reference to the Component Loader\'s baseDir', -> - chai.expect(instance.baseDir).to.equal l.baseDir - - describe 'loading a component', -> - loader = null - before (done) -> - loader = new noflo.ComponentLoader root - loader.listComponents done - it 'should return an error on an invalid component type', (done) -> - loader.components['InvalidComponent'] = true - loader.load 'InvalidComponent', (err, c) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.equal 'Invalid type boolean for component InvalidComponent.' - done() - it 'should return an error on a missing component path', (done) -> - loader.components['InvalidComponent'] = 'missing-file.js' - if noflo.isBrowser() - str = 'Dynamic loading of' - else - str = 'Cannot find module' - loader.load 'InvalidComponent', (err, c) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain str - done() - - describe 'register a component at runtime', -> - class FooSplit extends noflo.Component - constructor: -> - options = - inPorts: - in: {} - outPorts: - out: {} - super options - FooSplit.getComponent = -> new FooSplit - instance = null - l.libraryIcons.foo = 'star' - it 'should be available in the components list', -> - l.registerComponent 'foo', 'Split', FooSplit - chai.expect(l.components).to.contain.keys ['foo/Split', 'Graph'] - it 'should be able to load the component', (done) -> - l.load 'foo/Split', (err, split) -> - return done err if err - chai.expect(split).to.be.an 'object' - instance = split - done() - it 'should have the correct ports', -> - chai.expect(instance.inPorts.ports).to.have.keys ['in'] - chai.expect(instance.outPorts.ports).to.have.keys ['out'] - it 'should have inherited its icon from the library', -> - chai.expect(instance.getIcon()).to.equal 'star' - it 'should emit an event on icon change', (done) -> - instance.once 'icon', (newIcon) -> - chai.expect(newIcon).to.equal 'smile' - done() - instance.setIcon 'smile' - it 'new instances should still contain the original icon', (done) -> - l.load 'foo/Split', (err, split) -> - return done err if err - chai.expect(split).to.be.an 'object' - chai.expect(split.getIcon()).to.equal 'star' - done() - it 'after setting an icon for the Component class, new instances should have that', (done) -> - FooSplit::icon = 'trophy' - l.load 'foo/Split', (err, split) -> - return done err if err - chai.expect(split).to.be.an 'object' - chai.expect(split.getIcon()).to.equal 'trophy' - done() - it 'should not affect the original instance', -> - chai.expect(instance.getIcon()).to.equal 'smile' - - describe 'reading sources', -> - before -> - # getSource not implemented in webpack loader yet - return @skip() if noflo.isBrowser() - it 'should be able to provide source code for a component', (done) -> - l.getSource 'Graph', (err, component) -> - return done err if err - chai.expect(component).to.be.an 'object' - chai.expect(component.code).to.be.a 'string' - chai.expect(component.code.indexOf('noflo.Component')).to.not.equal -1 - chai.expect(component.code.indexOf('exports.getComponent')).to.not.equal -1 - chai.expect(component.name).to.equal 'Graph' - chai.expect(component.library).to.equal '' - chai.expect(component.language).to.equal shippingLanguage - done() - it 'should return an error for missing components', (done) -> - l.getSource 'foo/BarBaz', (err, src) -> - chai.expect(err).to.be.an 'error' - done() - it 'should return an error for non-file components', (done) -> - l.getSource 'foo/Split', (err, src) -> - chai.expect(err).to.be.an 'error' - done() - it 'should be able to provide source for a graph file component', (done) -> - file = "#{urlPrefix}spec/fixtures/subgraph.fbp" - l.components.Subgraph = file - l.getSource 'Subgraph', (err, src) -> - return done err if err - chai.expect(src.code).to.not.be.empty - chai.expect(src.language).to.equal 'json' - done() - it 'should be able to provide source for a graph object component', (done) -> - file = "#{urlPrefix}spec/fixtures/subgraph.fbp" - noflo.graph.loadFile file, (err, graph) -> - return done err if err - l.components.Subgraph2 = graph - l.getSource 'Subgraph2', (err, src) -> - return done err if err - chai.expect(src.code).to.not.be.empty - chai.expect(src.language).to.equal 'json' - done() - it 'should be able to get the source for non-ready ComponentLoader', (done) -> - loader = new noflo.ComponentLoader root - loader.getSource 'Graph', (err, component) -> - return done err if err - chai.expect(component).to.be.an 'object' - chai.expect(component.code).to.be.a 'string' - chai.expect(component.code.indexOf('noflo.Component')).to.not.equal -1 - chai.expect(component.code.indexOf('exports.getComponent')).to.not.equal -1 - chai.expect(component.name).to.equal 'Graph' - chai.expect(component.library).to.equal '' - chai.expect(component.language).to.equal shippingLanguage - done() - - describe 'writing sources', -> - describe 'with working code', -> - describe 'with ES5', -> - workingSource = """ - var noflo = require('noflo'); - - exports.getComponent = function() { - var c = new noflo.Component(); - c.inPorts.add('in'); - c.outPorts.add('out'); - c.process(function (input, output) { - output.sendDone(input.get('in')); - }); - return c; - };""" - - it 'should be able to set the source', (done) -> - @timeout 10000 - unless noflo.isBrowser() - workingSource = workingSource.replace "'noflo'", "'../src/lib/NoFlo'" - l.setSource 'foo', 'RepeatData', workingSource, 'javascript', (err) -> - return done err if err - done() - it 'should be a loadable component', (done) -> - l.load 'foo/RepeatData', (err, inst) -> - return done err if err - chai.expect(inst).to.be.an 'object' - chai.expect(inst.inPorts).to.contain.keys ['in'] - chai.expect(inst.outPorts).to.contain.keys ['out'] - ins = new noflo.internalSocket.InternalSocket - out = new noflo.internalSocket.InternalSocket - inst.inPorts.in.attach ins - inst.outPorts.out.attach out - out.on 'ip', (ip) -> - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'ES5' - done() - ins.send 'ES5' - it 'should be able to set the source for non-ready ComponentLoader', (done) -> - @timeout 10000 - loader = new noflo.ComponentLoader root - loader.setSource 'foo', 'RepeatData', workingSource, 'javascript', done - describe 'with ES6', -> - before -> - # PhantomJS doesn't work with ES6 - return @skip() if noflo.isBrowser() - workingSource = """ - const noflo = require('noflo'); - - exports.getComponent = () => { - const c = new noflo.Component(); - c.inPorts.add('in'); - c.outPorts.add('out'); - c.process((input, output) => { - output.sendDone(input.get('in')); - }); - return c; - };""" - - it 'should be able to set the source', (done) -> - @timeout 10000 - unless noflo.isBrowser() - workingSource = workingSource.replace "'noflo'", "'../src/lib/NoFlo'" - l.setSource 'foo', 'RepeatDataES6', workingSource, 'es6', (err) -> - return done err if err - done() - it 'should be a loadable component', (done) -> - l.load 'foo/RepeatDataES6', (err, inst) -> - return done err if err - chai.expect(inst).to.be.an 'object' - chai.expect(inst.inPorts).to.contain.keys ['in'] - chai.expect(inst.outPorts).to.contain.keys ['out'] - ins = new noflo.internalSocket.InternalSocket - out = new noflo.internalSocket.InternalSocket - inst.inPorts.in.attach ins - inst.outPorts.out.attach out - out.on 'ip', (ip) -> - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'ES6' - done() - ins.send 'ES6' - describe 'with CoffeeScript', -> - before -> - # CoffeeScript tests work in browser only if we have CoffeeScript - # compiler loaded - return @skip() if noflo.isBrowser() and not window.CoffeeScript - workingSource = """ - noflo = require 'noflo' - exports.getComponent = -> - c = new noflo.Component - c.inPorts.add 'in' - c.outPorts.add 'out' - c.process (input, output) -> - output.sendDone input.get 'in' - """ - - it 'should be able to set the source', (done) -> - @timeout 10000 - unless noflo.isBrowser() - workingSource = workingSource.replace "'noflo'", "'../src/lib/NoFlo'" - l.setSource 'foo', 'RepeatDataCoffee', workingSource, 'coffeescript', (err) -> - return done err if err - done() - it 'should be a loadable component', (done) -> - l.load 'foo/RepeatDataCoffee', (err, inst) -> - return done err if err - chai.expect(inst).to.be.an 'object' - chai.expect(inst.inPorts).to.contain.keys ['in'] - chai.expect(inst.outPorts).to.contain.keys ['out'] - ins = new noflo.internalSocket.InternalSocket - out = new noflo.internalSocket.InternalSocket - inst.inPorts.in.attach ins - inst.outPorts.out.attach out - out.on 'ip', (ip) -> - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'CoffeeScript' - done() - ins.send 'CoffeeScript' - - describe 'with non-working code', -> - describe 'without exports', -> - nonWorkingSource = """ - var noflo = require('noflo'); - var getComponent = function() { - var c = new noflo.Component(); - - c.inPorts.add('in', function(packet, outPorts) { - if (packet.event !== 'data') { - return; - } - // Do something with the packet, then - c.outPorts.out.send(packet.data); - }); - - c.outPorts.add('out'); - - return c; - };""" - - it 'should not be able to set the source', (done) -> - unless noflo.isBrowser() - nonWorkingSource = nonWorkingSource.replace "'noflo'", "'../src/lib/NoFlo'" - l.setSource 'foo', 'NotWorking', nonWorkingSource, 'js', (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'runnable component' - done() - it 'should not be a loadable component', (done) -> - l.load 'foo/NotWorking', (err, inst) -> - chai.expect(err).to.be.an 'error' - chai.expect(inst).to.be.an 'undefined' - done() - describe 'with non-existing import', -> - nonWorkingSource = """ - var noflo = require('noflo'); - var notFound = require('./this_file_does_not_exist.js'); - - exports.getComponent = function() { - var c = new noflo.Component(); - - c.inPorts.add('in', function(packet, outPorts) { - if (packet.event !== 'data') { - return; - } - // Do something with the packet, then - c.outPorts.out.send(packet.data); - }); - - c.outPorts.add('out'); - - return c; - };""" - - it 'should not be able to set the source', (done) -> - unless noflo.isBrowser() - nonWorkingSource = nonWorkingSource.replace "'noflo'", "'../src/lib/NoFlo'" - l.setSource 'foo', 'NotWorking', nonWorkingSource, 'js', (err) -> - chai.expect(err).to.be.an 'error' - done() - it 'should not be a loadable component', (done) -> - l.load 'foo/NotWorking', (err, inst) -> - chai.expect(err).to.be.an 'error' - chai.expect(inst).to.be.an 'undefined' - done() - describe 'with deprecated process callback', -> - nonWorkingSource = """ - var noflo = require('noflo'); - exports.getComponent = function() { - var c = new noflo.Component(); - - c.inPorts.add('in', { - process: function(packet, outPorts) { - if (packet.event !== 'data') { - return; - } - // Do something with the packet, then - c.outPorts.out.send(packet.data); - } - }); - - c.outPorts.add('out'); - - return c; - };""" - - it 'should be able to set the source', (done) -> - unless noflo.isBrowser() - nonWorkingSource = nonWorkingSource.replace "'noflo'", "'../src/lib/NoFlo'" - l.setSource 'foo', 'NotWorkingProcess', nonWorkingSource, 'js', done - it 'should not be a loadable component', (done) -> - l.load 'foo/NotWorkingProcess', (err, inst) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'process callback is deprecated' - chai.expect(inst).to.be.an 'undefined' - done() - -describe 'ComponentLoader with a fixture project', -> - l = null - before -> - return @skip() if noflo.isBrowser() - it 'should be possible to instantiate', -> - l = new noflo.ComponentLoader path.resolve __dirname, 'fixtures/componentloader' - it 'should initially know of no components', -> - chai.expect(l.components).to.be.a 'null' - it 'should not initially be ready', -> - chai.expect(l.ready).to.be.false - it 'should be able to read a list of components', (done) -> - ready = false - l.once 'ready', -> - chai.expect(l.ready).to.equal true - ready = l.ready - l.listComponents (err, components) -> - return done err if err - chai.expect(l.processing).to.equal false - chai.expect(l.components).not.to.be.empty - chai.expect(components).to.equal l.components - chai.expect(l.ready).to.equal true - chai.expect(ready).to.equal true - done() - chai.expect(l.processing).to.equal true - it 'should be able to load a local component', (done) -> - l.load 'componentloader/Output', (err, instance) -> - chai.expect(err).to.be.a 'null' - chai.expect(instance.description).to.equal 'Output stuff' - chai.expect(instance.icon).to.equal 'cloud' - done() - it 'should be able to load a component from a dependency', (done) -> - l.load 'example/Forward', (err, instance) -> - chai.expect(err).to.be.a 'null' - chai.expect(instance.description).to.equal 'Forward stuff' - chai.expect(instance.icon).to.equal 'car' - done() - it 'should be able to load a dynamically registered component from a dependency', (done) -> - l.load 'example/Hello', (err, instance) -> - chai.expect(err).to.be.a 'null' - chai.expect(instance.description).to.equal 'Hello stuff' - chai.expect(instance.icon).to.equal 'bicycle' - done() - it 'should be able to load core Graph component', (done) -> - l.load 'Graph', (err, instance) -> - chai.expect(err).to.be.a 'null' - chai.expect(instance.icon).to.equal 'sitemap' - done() - it 'should fail loading a missing component', (done) -> - l.load 'componentloader/Missing', (err, instance) -> - chai.expect(err).to.be.an 'error' - done() - -describe 'ComponentLoader with a fixture project and caching', -> - l = null - fixtureRoot = null - before -> - return @skip() if noflo.isBrowser() - fixtureRoot = path.resolve __dirname, 'fixtures/componentloader' - after (done) -> - return done() if noflo.isBrowser() - manifestPath = path.resolve fixtureRoot, 'fbp.json' - { unlink } = require 'fs' - unlink manifestPath, done - it 'should be possible to pre-heat the cache file', (done) -> - @timeout 8000 - { exec } = require 'child_process' - exec "node #{path.resolve(__dirname, '../bin/noflo-cache-preheat')}", - cwd: fixtureRoot - , done - it 'should have populated a fbp-manifest file', (done) -> - manifestPath = path.resolve fixtureRoot, 'fbp.json' - { stat } = require 'fs' - stat manifestPath, (err, stats) -> - return done err if err - chai.expect(stats.isFile()).to.equal true - done() - it 'should be possible to instantiate', -> - l = new noflo.ComponentLoader fixtureRoot, - cache: true - it 'should initially know of no components', -> - chai.expect(l.components).to.be.a 'null' - it 'should not initially be ready', -> - chai.expect(l.ready).to.be.false - it 'should be able to read a list of components', (done) -> - ready = false - l.once 'ready', -> - chai.expect(l.ready).to.equal true - ready = l.ready - l.listComponents (err, components) -> - return done err if err - chai.expect(l.processing).to.equal false - chai.expect(l.components).not.to.be.empty - chai.expect(components).to.equal l.components - chai.expect(l.ready).to.equal true - chai.expect(ready).to.equal true - done() - chai.expect(l.processing).to.equal true - it 'should be able to load a local component', (done) -> - l.load 'componentloader/Output', (err, instance) -> - chai.expect(err).to.be.a 'null' - chai.expect(instance.description).to.equal 'Output stuff' - chai.expect(instance.icon).to.equal 'cloud' - done() - it 'should be able to load a component from a dependency', (done) -> - l.load 'example/Forward', (err, instance) -> - chai.expect(err).to.be.a 'null' - chai.expect(instance.description).to.equal 'Forward stuff' - chai.expect(instance.icon).to.equal 'car' - done() - it 'should be able to load a dynamically registered component from a dependency', (done) -> - l.load 'example/Hello', (err, instance) -> - chai.expect(err).to.be.a 'null' - chai.expect(instance.description).to.equal 'Hello stuff' - chai.expect(instance.icon).to.equal 'bicycle' - done() - it 'should be able to load core Graph component', (done) -> - l.load 'Graph', (err, instance) -> - chai.expect(err).to.be.a 'null' - chai.expect(instance.icon).to.equal 'sitemap' - done() - it 'should fail loading a missing component', (done) -> - l.load 'componentloader/Missing', (err, instance) -> - chai.expect(err).to.be.an 'error' - done() - it 'should fail with missing manifest without discover option', (done) -> - l = new noflo.ComponentLoader fixtureRoot, - cache: true - discover: false - manifest: 'fbp2.json' - l.listComponents (err) -> - chai.expect(err).to.be.an 'error' - done() - it 'should be able to use a custom manifest file', (done) -> - @timeout 8000 - manifestPath = path.resolve fixtureRoot, 'fbp2.json' - l = new noflo.ComponentLoader fixtureRoot, - cache: true - discover: true - manifest: 'fbp2.json' - l.listComponents (err, components) -> - return done err if err - chai.expect(l.processing).to.equal false - chai.expect(l.components).not.to.be.empty - done() - it 'should have saved the new manifest', (done) -> - manifestPath = path.resolve fixtureRoot, 'fbp2.json' - { unlink } = require 'fs' - unlink manifestPath, done diff --git a/spec/ComponentLoader.js b/spec/ComponentLoader.js new file mode 100644 index 000000000..394cf80c6 --- /dev/null +++ b/spec/ComponentLoader.js @@ -0,0 +1,969 @@ +/* eslint-disable + max-classes-per-file +*/ +let chai; +let noflo; +let path; +let root; +let shippingLanguage; +let urlPrefix; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + shippingLanguage = 'javascript'; + path = require('path'); + root = path.resolve(__dirname, '../'); + urlPrefix = './'; +} else { + noflo = require('noflo'); + shippingLanguage = 'javascript'; + root = 'noflo'; + urlPrefix = '/'; +} + +describe('ComponentLoader with no external packages installed', () => { + let l = new noflo.ComponentLoader(root); + class Split extends noflo.Component { + constructor() { + const options = { + inPorts: { + in: {}, + }, + outPorts: { + out: {}, + }, + process(input, output) { + output.sendDone(input.get('in')); + }, + }; + super(options); + } + } + Split.getComponent = () => new Split(); + + const Merge = function () { + const inst = new noflo.Component(); + inst.inPorts.add('in'); + inst.outPorts.add('out'); + inst.process((input, output) => output.sendDone(input.get('in'))); + return inst; + }; + + it('should initially know of no components', () => { + chai.expect(l.components).to.be.null; + }); + it('should not initially be ready', () => { + chai.expect(l.ready).to.be.false; + }); + it('should not initially be processing', () => { + chai.expect(l.processing).to.be.false; + }); + it('should not have any packages in the checked list', () => { + chai.expect(l.checked).to.not.exist; + }); + describe('normalizing names', () => { + it('should return simple module names as-is', () => { + const normalized = l.getModulePrefix('foo'); + chai.expect(normalized).to.equal('foo'); + }); + it('should return empty for NoFlo core', () => { + const normalized = l.getModulePrefix('noflo'); + chai.expect(normalized).to.equal(''); + }); + it('should strip noflo-', () => { + const normalized = l.getModulePrefix('noflo-image'); + chai.expect(normalized).to.equal('image'); + }); + it('should strip NPM scopes', () => { + const normalized = l.getModulePrefix('@noflo/foo'); + chai.expect(normalized).to.equal('foo'); + }); + it('should strip NPM scopes and noflo-', () => { + const normalized = l.getModulePrefix('@noflo/noflo-image'); + chai.expect(normalized).to.equal('image'); + }); + }); + it('should be able to read a list of components', function (done) { + this.timeout(60 * 1000); + let ready = false; + l.once('ready', () => { + ready = true; + chai.expect(l.ready, 'should have the ready bit').to.equal(true); + }); + l.listComponents((err, components) => { + if (err) { + done(err); + return; + } + chai.expect(l.processing, 'should have stopped processing').to.equal(false); + chai.expect(l.components, 'should contain components').not.to.be.empty; + chai.expect(components, 'should have returned the full list').to.equal(l.components); + chai.expect(l.ready, 'should have been set ready').to.equal(true); + chai.expect(ready, 'should have emitted ready').to.equal(true); + done(); + }); + + if (!noflo.isBrowser()) { + // Browser component registry can be synchronous + chai.expect(l.processing, 'should have started processing').to.equal(true); + } + }); + describe('calling listComponents twice simultaneously', () => { + it('should return the same results', (done) => { + const loader = new noflo.ComponentLoader(root); + const received = []; + loader.listComponents((err, components) => { + if (err) { + done(err); + return; + } + received.push(components); + if (received.length !== 2) { return; } + chai.expect(received[0]).to.equal(received[1]); + done(); + }); + loader.listComponents((err, components) => { + if (err) { + done(err); + return; + } + received.push(components); + if (received.length !== 2) { return; } + chai.expect(received[0]).to.equal(received[1]); + done(); + }); + }); + }); + describe('after listing components', () => { + it('should have the Graph component registered', () => { + chai.expect(l.components.Graph).not.to.be.empty; + }); + }); + describe('loading the Graph component', () => { + let instance = null; + it('should be able to load the component', (done) => { + l.load('Graph', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst).to.be.an('object'); + chai.expect(inst.componentName).to.equal('Graph'); + instance = inst; + done(); + }); + }); + it('should contain input ports', () => { + chai.expect(instance.inPorts).to.be.an('object'); + chai.expect(instance.inPorts.graph).to.be.an('object'); + }); + it('should have "on" method on the input port', () => { + chai.expect(instance.inPorts.graph.on).to.be.a('function'); + }); + it('it should know that Graph is a subgraph', () => { + chai.expect(instance.isSubgraph()).to.equal(true); + }); + it('should know the description for the Graph', () => { + chai.expect(instance.getDescription()).to.be.a('string'); + }); + it('should be able to provide an icon for the Graph', () => { + chai.expect(instance.getIcon()).to.be.a('string'); + chai.expect(instance.getIcon()).to.equal('sitemap'); + }); + it('should be able to load the component with non-ready ComponentLoader', (done) => { + const loader = new noflo.ComponentLoader(root); + loader.load('Graph', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst).to.be.an('object'); + chai.expect(inst.componentName).to.equal('Graph'); + instance = inst; + done(); + }); + }); + }); + + describe('loading a subgraph', () => { + l = new noflo.ComponentLoader(root); + const file = `${urlPrefix}spec/fixtures/subgraph.fbp`; + it('should remove `graph` and `start` ports', (done) => { + l.listComponents((err) => { + if (err) { + done(err); + return; + } + l.components.Merge = Merge; + l.components.Subgraph = file; + l.components.Split = Split; + l.load('Subgraph', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst).to.be.an('object'); + inst.once('ready', () => { + chai.expect(inst.inPorts.ports).not.to.have.keys(['graph', 'start']); + chai.expect(inst.inPorts.ports).to.have.keys(['in']); + chai.expect(inst.outPorts.ports).to.have.keys(['out']); + done(); + }); + }); + }); + }); + it('should not automatically start the subgraph if there is no `start` port', (done) => { + l.listComponents((err) => { + if (err) { + done(err); + return; + } + l.components.Merge = Merge; + l.components.Subgraph = file; + l.components.Split = Split; + l.load('Subgraph', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst).to.be.an('object'); + inst.once('ready', () => { + chai.expect(inst.started).to.equal(false); + done(); + }); + }); + }); + }); + it('should also work with a passed graph object', (done) => { + noflo.graph.loadFile(file, (err, graph) => { + if (err) { + done(err); + return; + } + l.listComponents((err) => { + if (err) { + done(err); + return; + } + l.components.Merge = Merge; + l.components.Subgraph = graph; + l.components.Split = Split; + l.load('Subgraph', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst).to.be.an('object'); + inst.once('ready', () => { + chai.expect(inst.inPorts.ports).not.to.have.keys(['graph', 'start']); + chai.expect(inst.inPorts.ports).to.have.keys(['in']); + chai.expect(inst.outPorts.ports).to.have.keys(['out']); + done(); + }); + }); + }); + }); + }); + }); + describe('loading the Graph component', () => { + let instance = null; + it('should be able to load the component', (done) => { + l.load('Graph', (err, graph) => { + if (err) { + done(err); + return; + } + chai.expect(graph).to.be.an('object'); + instance = graph; + done(); + }); + }); + it('should have a reference to the Component Loader\'s baseDir', () => { + chai.expect(instance.baseDir).to.equal(l.baseDir); + }); + }); + describe('loading a component', () => { + let loader = null; + before((done) => { + loader = new noflo.ComponentLoader(root); + loader.listComponents(done); + }); + it('should return an error on an invalid component type', (done) => { + loader.components.InvalidComponent = true; + loader.load('InvalidComponent', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.equal('Invalid type boolean for component InvalidComponent.'); + done(); + }); + }); + it('should return an error on a missing component path', (done) => { + let str; + loader.components.InvalidComponent = 'missing-file.js'; + if (noflo.isBrowser()) { + str = 'Dynamic loading of'; + } else { + str = 'Cannot find module'; + } + loader.load('InvalidComponent', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain(str); + done(); + }); + }); + }); + describe('register a component at runtime', () => { + class FooSplit extends noflo.Component { + constructor() { + const options = { + inPorts: { + in: {}, + }, + outPorts: { + out: {}, + }, + }; + super(options); + } + } + FooSplit.getComponent = () => new FooSplit(); + let instance = null; + l.libraryIcons.foo = 'star'; + it('should be available in the components list', () => { + l.registerComponent('foo', 'Split', FooSplit); + chai.expect(l.components).to.contain.keys(['foo/Split', 'Graph']); + }); + it('should be able to load the component', (done) => { + l.load('foo/Split', (err, split) => { + if (err) { + done(err); + return; + } + chai.expect(split).to.be.an('object'); + instance = split; + done(); + }); + }); + it('should have the correct ports', () => { + chai.expect(instance.inPorts.ports).to.have.keys(['in']); + chai.expect(instance.outPorts.ports).to.have.keys(['out']); + }); + it('should have inherited its icon from the library', () => { + chai.expect(instance.getIcon()).to.equal('star'); + }); + it('should emit an event on icon change', (done) => { + instance.once('icon', (newIcon) => { + chai.expect(newIcon).to.equal('smile'); + done(); + }); + instance.setIcon('smile'); + }); + it('new instances should still contain the original icon', (done) => { + l.load('foo/Split', (err, split) => { + if (err) { + done(err); + return; + } + chai.expect(split).to.be.an('object'); + chai.expect(split.getIcon()).to.equal('star'); + done(); + }); + }); + // TODO reconsider this test after full decaffeination + it.skip('after setting an icon for the Component class, new instances should have that', (done) => { + FooSplit.prototype.icon = 'trophy'; + l.load('foo/Split', (err, split) => { + if (err) { + done(err); + return; + } + chai.expect(split).to.be.an('object'); + chai.expect(split.getIcon()).to.equal('trophy'); + done(); + }); + }); + it('should not affect the original instance', () => { + chai.expect(instance.getIcon()).to.equal('smile'); + }); + }); + describe('reading sources', () => { + before(function () { + // getSource not implemented in webpack loader yet + if (noflo.isBrowser()) { + this.skip(); + } + }); + it('should be able to provide source code for a component', (done) => { + l.getSource('Graph', (err, component) => { + if (err) { + done(err); + return; + } + chai.expect(component).to.be.an('object'); + chai.expect(component.code).to.be.a('string'); + chai.expect(component.code.indexOf('noflo.Component')).to.not.equal(-1); + chai.expect(component.code.indexOf('exports.getComponent')).to.not.equal(-1); + chai.expect(component.name).to.equal('Graph'); + chai.expect(component.library).to.equal(''); + chai.expect(component.language).to.equal(shippingLanguage); + done(); + }); + }); + it('should return an error for missing components', (done) => { + l.getSource('foo/BarBaz', (err) => { + chai.expect(err).to.be.an('error'); + done(); + }); + }); + it('should return an error for non-file components', (done) => { + l.getSource('foo/Split', (err) => { + chai.expect(err).to.be.an('error'); + done(); + }); + }); + it('should be able to provide source for a graph file component', (done) => { + const file = `${urlPrefix}spec/fixtures/subgraph.fbp`; + l.components.Subgraph = file; + l.getSource('Subgraph', (err, src) => { + if (err) { + done(err); + return; + } + chai.expect(src.code).to.not.be.empty; + chai.expect(src.language).to.equal('json'); + done(); + }); + }); + it('should be able to provide source for a graph object component', (done) => { + const file = `${urlPrefix}spec/fixtures/subgraph.fbp`; + noflo.graph.loadFile(file, (err, graph) => { + if (err) { + done(err); + return; + } + l.components.Subgraph2 = graph; + l.getSource('Subgraph2', (err, src) => { + if (err) { + done(err); + return; + } + chai.expect(src.code).to.not.be.empty; + chai.expect(src.language).to.equal('json'); + done(); + }); + }); + }); + it('should be able to get the source for non-ready ComponentLoader', (done) => { + const loader = new noflo.ComponentLoader(root); + loader.getSource('Graph', (err, component) => { + if (err) { + done(err); + return; + } + chai.expect(component).to.be.an('object'); + chai.expect(component.code).to.be.a('string'); + chai.expect(component.code.indexOf('noflo.Component')).to.not.equal(-1); + chai.expect(component.code.indexOf('exports.getComponent')).to.not.equal(-1); + chai.expect(component.name).to.equal('Graph'); + chai.expect(component.library).to.equal(''); + chai.expect(component.language).to.equal(shippingLanguage); + done(); + }); + }); + }); + describe('writing sources', () => { + describe('with working code', () => { + describe('with ES5', () => { + let workingSource = `\ +var noflo = require('noflo'); + +exports.getComponent = function() { + var c = new noflo.Component(); + c.inPorts.add('in'); + c.outPorts.add('out'); + c.process(function (input, output) { + output.sendDone(input.get('in')); + }); + return c; +};`; + + it('should be able to set the source', function (done) { + this.timeout(10000); + if (!noflo.isBrowser()) { + workingSource = workingSource.replace("'noflo'", "'../src/lib/NoFlo'"); + } + l.setSource('foo', 'RepeatData', workingSource, 'javascript', (err) => { + if (err) { + done(err); + return; + } + done(); + }); + }); + it('should be a loadable component', (done) => { + l.load('foo/RepeatData', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst).to.be.an('object'); + chai.expect(inst.inPorts).to.contain.keys(['in']); + chai.expect(inst.outPorts).to.contain.keys(['out']); + const ins = new noflo.internalSocket.InternalSocket(); + const out = new noflo.internalSocket.InternalSocket(); + inst.inPorts.in.attach(ins); + inst.outPorts.out.attach(out); + out.on('ip', (ip) => { + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('ES5'); + done(); + }); + ins.send('ES5'); + }); + }); + it('should be able to set the source for non-ready ComponentLoader', function (done) { + this.timeout(10000); + const loader = new noflo.ComponentLoader(root); + loader.setSource('foo', 'RepeatData', workingSource, 'javascript', done); + }); + }); + describe('with ES6', () => { + before(function () { + // PhantomJS doesn't work with ES6 + if (noflo.isBrowser()) { + this.skip(); + } + }); + let workingSource = `\ +const noflo = require('noflo'); + +exports.getComponent = () => { + const c = new noflo.Component(); + c.inPorts.add('in'); + c.outPorts.add('out'); + c.process((input, output) => { + output.sendDone(input.get('in')); + }); + return c; +};`; + + it('should be able to set the source', function (done) { + this.timeout(10000); + if (!noflo.isBrowser()) { + workingSource = workingSource.replace("'noflo'", "'../src/lib/NoFlo'"); + } + l.setSource('foo', 'RepeatDataES6', workingSource, 'es6', (err) => { + if (err) { + done(err); + return; + } + done(); + }); + }); + it('should be a loadable component', (done) => { + l.load('foo/RepeatDataES6', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst).to.be.an('object'); + chai.expect(inst.inPorts).to.contain.keys(['in']); + chai.expect(inst.outPorts).to.contain.keys(['out']); + const ins = new noflo.internalSocket.InternalSocket(); + const out = new noflo.internalSocket.InternalSocket(); + inst.inPorts.in.attach(ins); + inst.outPorts.out.attach(out); + out.on('ip', (ip) => { + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('ES6'); + done(); + }); + ins.send('ES6'); + }); + }); + }); + describe('with CoffeeScript', () => { + before(function () { + // CoffeeScript tests work in browser only if we have CoffeeScript + // compiler loaded + if (noflo.isBrowser() && !window.CoffeeScript) { + this.skip(); + } + }); + let workingSource = `\ +noflo = require 'noflo' +exports.getComponent = -> + c = new noflo.Component + c.inPorts.add 'in' + c.outPorts.add 'out' + c.process (input, output) -> + output.sendDone input.get 'in'\ +`; + + it('should be able to set the source', function (done) { + this.timeout(10000); + if (!noflo.isBrowser()) { + workingSource = workingSource.replace("'noflo'", "'../src/lib/NoFlo'"); + } + l.setSource('foo', 'RepeatDataCoffee', workingSource, 'coffeescript', (err) => { + if (err) { + done(err); + return; + } + done(); + }); + }); + it('should be a loadable component', (done) => { + l.load('foo/RepeatDataCoffee', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst).to.be.an('object'); + chai.expect(inst.inPorts).to.contain.keys(['in']); + chai.expect(inst.outPorts).to.contain.keys(['out']); + const ins = new noflo.internalSocket.InternalSocket(); + const out = new noflo.internalSocket.InternalSocket(); + inst.inPorts.in.attach(ins); + inst.outPorts.out.attach(out); + out.on('ip', (ip) => { + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('CoffeeScript'); + done(); + }); + ins.send('CoffeeScript'); + }); + }); + }); + }); + describe('with non-working code', () => { + describe('without exports', () => { + let nonWorkingSource = `\ +var noflo = require('noflo'); +var getComponent = function() { + var c = new noflo.Component(); + + c.inPorts.add('in', function(packet, outPorts) { + if (packet.event !== 'data') { + return; + } + // Do something with the packet, then + c.outPorts.out.send(packet.data); + }); + + c.outPorts.add('out'); + + return c; +};`; + + it('should not be able to set the source', (done) => { + if (!noflo.isBrowser()) { + nonWorkingSource = nonWorkingSource.replace("'noflo'", "'../src/lib/NoFlo'"); + } + l.setSource('foo', 'NotWorking', nonWorkingSource, 'js', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('runnable component'); + done(); + }); + }); + it('should not be a loadable component', (done) => { + l.load('foo/NotWorking', (err, inst) => { + chai.expect(err).to.be.an('error'); + chai.expect(inst).to.be.an('undefined'); + done(); + }); + }); + }); + describe('with non-existing import', () => { + let nonWorkingSource = `\ +var noflo = require('noflo'); +var notFound = require('./this_file_does_not_exist.js'); + +exports.getComponent = function() { + var c = new noflo.Component(); + + c.inPorts.add('in', function(packet, outPorts) { + if (packet.event !== 'data') { + return; + } + // Do something with the packet, then + c.outPorts.out.send(packet.data); + }); + + c.outPorts.add('out'); + + return c; +};`; + + it('should not be able to set the source', (done) => { + if (!noflo.isBrowser()) { + nonWorkingSource = nonWorkingSource.replace("'noflo'", "'../src/lib/NoFlo'"); + } + l.setSource('foo', 'NotWorking', nonWorkingSource, 'js', (err) => { + chai.expect(err).to.be.an('error'); + done(); + }); + }); + it('should not be a loadable component', (done) => { + l.load('foo/NotWorking', (err, inst) => { + chai.expect(err).to.be.an('error'); + chai.expect(inst).to.be.an('undefined'); + done(); + }); + }); + }); + describe('with deprecated process callback', () => { + let nonWorkingSource = `\ +var noflo = require('noflo'); +exports.getComponent = function() { + var c = new noflo.Component(); + + c.inPorts.add('in', { + process: function(packet, outPorts) { + if (packet.event !== 'data') { + return; + } + // Do something with the packet, then + c.outPorts.out.send(packet.data); + } + }); + + c.outPorts.add('out'); + + return c; +};`; + + it('should be able to set the source', (done) => { + if (!noflo.isBrowser()) { + nonWorkingSource = nonWorkingSource.replace("'noflo'", "'../src/lib/NoFlo'"); + } + l.setSource('foo', 'NotWorkingProcess', nonWorkingSource, 'js', done); + }); + it('should not be a loadable component', (done) => { + l.load('foo/NotWorkingProcess', (err, inst) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('process callback is deprecated'); + chai.expect(inst).to.be.an('undefined'); + done(); + }); + }); + }); + }); + }); +}); +describe('ComponentLoader with a fixture project', () => { + let l = null; + before(function () { + if (noflo.isBrowser()) { + this.skip(); + } + }); + it('should be possible to instantiate', () => { + l = new noflo.ComponentLoader(path.resolve(__dirname, 'fixtures/componentloader')); + }); + it('should initially know of no components', () => { + chai.expect(l.components).to.be.a('null'); + }); + it('should not initially be ready', () => { + chai.expect(l.ready).to.be.false; + }); + it('should be able to read a list of components', (done) => { + let ready = false; + l.once('ready', () => { + chai.expect(l.ready).to.equal(true); + ({ + ready, + } = l); + }); + l.listComponents((err, components) => { + if (err) { + done(err); + return; + } + chai.expect(l.processing).to.equal(false); + chai.expect(l.components).not.to.be.empty; + chai.expect(components).to.equal(l.components); + chai.expect(l.ready).to.equal(true); + chai.expect(ready).to.equal(true); + done(); + }); + chai.expect(l.processing).to.equal(true); + }); + it('should be able to load a local component', (done) => { + l.load('componentloader/Output', (err, instance) => { + chai.expect(err).to.be.a('null'); + chai.expect(instance.description).to.equal('Output stuff'); + chai.expect(instance.icon).to.equal('cloud'); + done(); + }); + }); + it('should be able to load a component from a dependency', (done) => { + l.load('example/Forward', (err, instance) => { + chai.expect(err).to.be.a('null'); + chai.expect(instance.description).to.equal('Forward stuff'); + chai.expect(instance.icon).to.equal('car'); + done(); + }); + }); + it('should be able to load a dynamically registered component from a dependency', (done) => { + l.load('example/Hello', (err, instance) => { + chai.expect(err).to.be.a('null'); + chai.expect(instance.description).to.equal('Hello stuff'); + chai.expect(instance.icon).to.equal('bicycle'); + done(); + }); + }); + it('should be able to load core Graph component', (done) => { + l.load('Graph', (err, instance) => { + chai.expect(err).to.be.a('null'); + chai.expect(instance.icon).to.equal('sitemap'); + done(); + }); + }); + it('should fail loading a missing component', (done) => { + l.load('componentloader/Missing', (err) => { + chai.expect(err).to.be.an('error'); + done(); + }); + }); +}); +describe('ComponentLoader with a fixture project and caching', () => { + let l = null; + let fixtureRoot = null; + before(function () { + if (noflo.isBrowser()) { + this.skip(); + return; + } + fixtureRoot = path.resolve(__dirname, 'fixtures/componentloader'); + }); + after((done) => { + if (noflo.isBrowser()) { + done(); + return; + } + const manifestPath = path.resolve(fixtureRoot, 'fbp.json'); + const { unlink } = require('fs'); + unlink(manifestPath, done); + }); + it('should be possible to pre-heat the cache file', function (done) { + this.timeout(8000); + const { exec } = require('child_process'); + exec(`node ${path.resolve(__dirname, '../bin/noflo-cache-preheat')}`, + { cwd: fixtureRoot }, + done); + }); + it('should have populated a fbp-manifest file', (done) => { + const manifestPath = path.resolve(fixtureRoot, 'fbp.json'); + const { stat } = require('fs'); + stat(manifestPath, (err, stats) => { + if (err) { + done(err); + return; + } + chai.expect(stats.isFile()).to.equal(true); + done(); + }); + }); + it('should be possible to instantiate', () => { + l = new noflo.ComponentLoader(fixtureRoot, + { cache: true }); + }); + it('should initially know of no components', () => { + chai.expect(l.components).to.be.a('null'); + }); + it('should not initially be ready', () => { + chai.expect(l.ready).to.be.false; + }); + it('should be able to read a list of components', (done) => { + let ready = false; + l.once('ready', () => { + chai.expect(l.ready).to.equal(true); + ({ + ready, + } = l); + }); + l.listComponents((err, components) => { + if (err) { + done(err); + return; + } + chai.expect(l.processing).to.equal(false); + chai.expect(l.components).not.to.be.empty; + chai.expect(components).to.equal(l.components); + chai.expect(l.ready).to.equal(true); + chai.expect(ready).to.equal(true); + done(); + }); + chai.expect(l.processing).to.equal(true); + }); + it('should be able to load a local component', (done) => { + l.load('componentloader/Output', (err, instance) => { + chai.expect(err).to.be.a('null'); + chai.expect(instance.description).to.equal('Output stuff'); + chai.expect(instance.icon).to.equal('cloud'); + done(); + }); + }); + it('should be able to load a component from a dependency', (done) => { + l.load('example/Forward', (err, instance) => { + chai.expect(err).to.be.a('null'); + chai.expect(instance.description).to.equal('Forward stuff'); + chai.expect(instance.icon).to.equal('car'); + done(); + }); + }); + it('should be able to load a dynamically registered component from a dependency', (done) => { + l.load('example/Hello', (err, instance) => { + chai.expect(err).to.be.a('null'); + chai.expect(instance.description).to.equal('Hello stuff'); + chai.expect(instance.icon).to.equal('bicycle'); + done(); + }); + }); + it('should be able to load core Graph component', (done) => { + l.load('Graph', (err, instance) => { + chai.expect(err).to.be.a('null'); + chai.expect(instance.icon).to.equal('sitemap'); + done(); + }); + }); + it('should fail loading a missing component', (done) => { + l.load('componentloader/Missing', (err) => { + chai.expect(err).to.be.an('error'); + done(); + }); + }); + it('should fail with missing manifest without discover option', (done) => { + l = new noflo.ComponentLoader(fixtureRoot, { + cache: true, + discover: false, + manifest: 'fbp2.json', + }); + l.listComponents((err) => { + chai.expect(err).to.be.an('error'); + done(); + }); + }); + it('should be able to use a custom manifest file', function (done) { + this.timeout(8000); + l = new noflo.ComponentLoader(fixtureRoot, { + cache: true, + discover: true, + manifest: 'fbp2.json', + }); + l.listComponents((err) => { + if (err) { + done(err); + return; + } + chai.expect(l.processing).to.equal(false); + chai.expect(l.components).not.to.be.empty; + done(); + }); + }); + it('should have saved the new manifest', (done) => { + const manifestPath = path.resolve(fixtureRoot, 'fbp2.json'); + const { unlink } = require('fs'); + unlink(manifestPath, done); + }); +}); diff --git a/spec/Helpers.coffee b/spec/Helpers.coffee deleted file mode 100644 index 2072c6f19..000000000 --- a/spec/Helpers.coffee +++ /dev/null @@ -1,1583 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' -else - noflo = require 'noflo' - -describe 'Component traits', -> - describe 'WirePattern', -> - describe 'when grouping by packet groups', -> - c = null - x = null - y = null - z = null - p = null - beforeEach (done) -> - c = new noflo.Component - c.inPorts.add 'x', - required: true - datatype: 'int' - .add 'y', - required: true - datatype: 'int' - .add 'z', - required: true - datatype: 'int' - c.outPorts.add 'point' - x = new noflo.internalSocket.createSocket() - y = new noflo.internalSocket.createSocket() - z = new noflo.internalSocket.createSocket() - p = new noflo.internalSocket.createSocket() - c.inPorts.x.attach x - c.inPorts.y.attach y - c.inPorts.z.attach z - c.outPorts.point.attach p - done() - afterEach -> - c.outPorts.point.detach p - - it 'should pass data and groups to the callback', (done) -> - src = - 111: {x: 1, y: 2, z: 3} - 222: {x: 4, y: 5, z: 6} - 333: {x: 7, y: 8, z: 9} - noflo.helpers.WirePattern c, - in: ['x', 'y', 'z'] - out: 'point' - group: true - forwardGroups: true - async: true - , (data, groups, out, callback) -> - chai.expect(groups.length).to.be.above 0 - chai.expect(data).to.deep.equal src[groups[0]] - out.send data - do callback - - groups = [] - count = 0 - p.on 'begingroup', (grp) -> - groups.push grp - p.on 'endgroup', -> - groups.pop() - p.on 'data', (data) -> - count++ - p.on 'disconnect', -> - done() if count is 3 and groups.length is 0 - - for key, grp of src - x.beginGroup key - y.beginGroup key - z.beginGroup key - x.send grp.x - y.send grp.y - z.send grp.z - x.endGroup() - y.endGroup() - z.endGroup() - x.disconnect() - y.disconnect() - z.disconnect() - - it 'should work without a group provided', (done) -> - noflo.helpers.WirePattern c, - in: ['x', 'y', 'z'] - out: 'point' - async: true - , (data, groups, out, callback) -> - chai.expect(groups.length).to.equal 0 - out.send {x: data.x, y: data.y, z: data.z} - do callback - - p.once 'data', (data) -> - chai.expect(data).to.deep.equal {x: 123, y: 456, z: 789} - done() - - x.send 123 - x.disconnect() - y.send 456 - y.disconnect() - z.send 789 - z.disconnect() - - it 'should process inputs for different groups independently with group: true', (done) -> - src = - 1: {x: 1, y: 2, z: 3} - 2: {x: 4, y: 5, z: 6} - 3: {x: 7, y: 8, z: 9} - inOrder = [ - [ 1, 'x' ] - [ 3, 'z' ] - [ 2, 'y' ] - [ 2, 'x' ] - [ 1, 'z' ] - [ 2, 'z' ] - [ 3, 'x' ] - [ 1, 'y' ] - [ 3, 'y' ] - ] - outOrder = [ 2, 1, 3 ] - - noflo.helpers.WirePattern c, - in: ['x', 'y', 'z'] - out: 'point' - group: true - forwardGroups: true - async: true - , (data, groups, out, callback) -> - out.send {x: data.x, y: data.y, z: data.z} - do callback - - groups = [] - - p.on 'begingroup', (grp) -> - groups.push grp - p.on 'endgroup', (grp) -> - groups.pop() - p.on 'data', (data) -> - chai.expect(groups.length).to.equal 1 - chai.expect(groups[0]).to.equal outOrder[0] - chai.expect(data).to.deep.equal src[outOrder[0]] - outOrder.shift() - done() unless outOrder.length - - for tuple in inOrder - input = null - switch tuple[1] - when 'x' - input = x - when 'y' - input = y - when 'z' - input = z - input.beginGroup tuple[0] - input.send src[tuple[0]][tuple[1]] - input.endGroup() - input.disconnect() - - it 'should support asynchronous handlers', (done) -> - point = - x: 123 - y: 456 - z: 789 - - noflo.helpers.WirePattern c, - in: ['x', 'y', 'z'] - out: 'point' - async: true - group: true - forwardGroups: true - , (data, groups, out, callback) -> - setTimeout -> - out.send {x: data.x, y: data.y, z: data.z} - callback() - , 100 - - counter = 0 - hadData = false - p.on 'begingroup', (grp) -> - counter++ - p.on 'endgroup', -> - counter-- - p.once 'data', (data) -> - chai.expect(data).to.deep.equal point - hadData = true - p.once 'disconnect', -> - chai.expect(counter).to.equal 0 - chai.expect(hadData).to.be.true - done() - - x.beginGroup 'async' - y.beginGroup 'async' - z.beginGroup 'async' - x.send point.x - y.send point.y - z.send point.z - x.endGroup() - y.endGroup() - z.endGroup() - x.disconnect() - y.disconnect() - z.disconnect() - - it 'should not forward groups if forwarding is off', (done) -> - point = - x: 123 - y: 456 - noflo.helpers.WirePattern c, - in: ['x', 'y'] - out: 'point' - async: true - , (data, groups, out, callback) -> - out.send { x: data.x, y: data.y } - do callback - - counter = 0 - hadData = false - p.on 'begingroup', (grp) -> - counter++ - p.on 'data', (data) -> - chai.expect(data).to.deep.equal point - hadData = true - p.once 'disconnect', -> - chai.expect(counter).to.equal 0 - chai.expect(hadData).to.be.true - done() - - x.beginGroup 'doNotForwardMe' - y.beginGroup 'doNotForwardMe' - x.send point.x - y.send point.y - x.endGroup() - y.endGroup() - x.disconnect() - y.disconnect() - - it 'should forward groups from a specific port only', (done) -> - point = - x: 123 - y: 456 - z: 789 - refGroups = ['boo'] - noflo.helpers.WirePattern c, - in: ['x', 'y', 'z'] - out: 'point' - forwardGroups: 'y' - async: true - , (data, groups, out, callback) -> - out.send { x: data.x, y: data.y, z: data.z } - do callback - - groups = [] - p.on 'begingroup', (grp) -> - groups.push grp - p.on 'data', (data) -> - chai.expect(data).to.deep.equal point - p.once 'disconnect', -> - chai.expect(groups).to.deep.equal refGroups - done() - - x.beginGroup 'foo' - y.beginGroup 'boo' - z.beginGroup 'bar' - x.send point.x - y.send point.y - z.send point.z - x.endGroup() - y.endGroup() - z.endGroup() - x.disconnect() - y.disconnect() - z.disconnect() - - it 'should forward groups from selected ports only', (done) -> - point = - x: 123 - y: 456 - z: 789 - refGroups = ['foo', 'bar'] - noflo.helpers.WirePattern c, - in: ['x', 'y', 'z'] - out: 'point' - forwardGroups: [ 'x', 'z' ] - async: true - , (data, groups, out, callback) -> - out.send { x: data.x, y: data.y, z: data.z } - do callback - - groups = [] - p.on 'begingroup', (grp) -> - groups.push grp - p.on 'data', (data) -> - chai.expect(data).to.deep.equal point - p.once 'disconnect', -> - chai.expect(groups).to.deep.equal refGroups - done() - - x.beginGroup 'foo' - y.beginGroup 'boo' - z.beginGroup 'bar' - x.send point.x - y.send point.y - z.send point.z - x.endGroup() - y.endGroup() - z.endGroup() - x.disconnect() - y.disconnect() - z.disconnect() - - describe 'when `this` context is important', -> - c = new noflo.Component - c.inPorts.add 'x', - required: true - datatype: 'int' - .add 'y', - required: true - datatype: 'int' - .add 'z', - required: true - datatype: 'int' - c.outPorts.add 'point' - x = new noflo.internalSocket.createSocket() - y = new noflo.internalSocket.createSocket() - z = new noflo.internalSocket.createSocket() - p = new noflo.internalSocket.createSocket() - c.inPorts.x.attach x - c.inPorts.y.attach y - c.inPorts.z.attach z - c.outPorts.point.attach p - - it 'should correctly bind component to `this` context', (done) -> - noflo.helpers.WirePattern c, - in: ['x', 'y', 'z'] - async: true - out: 'point' - , (data, groups, out, callback) -> - chai.expect(this).to.deep.equal c - out.send {x: data.x, y: data.y, z: data.z} - callback() - - p.once 'data', (data) -> - done() - - x.send 123 - x.disconnect() - y.send 456 - y.disconnect() - z.send 789 - z.disconnect() - - describe 'when packet order matters', -> - c = new noflo.Component - c.inPorts.add 'delay', datatype: 'int' - .add 'msg', datatype: 'string' - c.outPorts.add 'out', datatype: 'object' - .add 'load', datatype: 'int' - delay = new noflo.internalSocket.createSocket() - msg = new noflo.internalSocket.createSocket() - out = new noflo.internalSocket.createSocket() - load = new noflo.internalSocket.createSocket() - c.inPorts.delay.attach delay - c.inPorts.msg.attach msg - c.outPorts.out.attach out - c.outPorts.load.attach load - - it 'should preserve input order at the output', (done) -> - noflo.helpers.WirePattern c, - in: ['delay', 'msg'] - async: true - ordered: true - group: false - , (data, groups, res, callback) -> - setTimeout -> - res.send { delay: data.delay, msg: data.msg } - callback() - , data.delay - - sample = [ - { delay: 30, msg: "one" } - { delay: 0, msg: "two" } - { delay: 20, msg: "three" } - { delay: 10, msg: "four" } - ] - - out.on 'data', (data) -> - chai.expect(data).to.deep.equal sample.shift() - out.on 'disconnect', -> - done() if sample.length is 0 - - expected = [1, 2, 3, 4, 3, 2, 1, 0] - load.on 'data', (data) -> - chai.expect(data).to.equal expected.shift() - - idx = 0 - for ip in sample - delay.beginGroup idx - delay.send ip.delay - delay.endGroup() - msg.beginGroup idx - msg.send ip.msg - msg.endGroup() - delay.disconnect() - msg.disconnect() - idx++ - - it 'should throw if sync mode is used', (done) -> - f = -> - noflo.helpers.WirePattern c, - in: ['delay', 'msg'] - , (data, groups, res) -> - - chai.expect(f).to.throw Error - done() - - it 'should throw if async callback doesn\'t have needed amount of arguments', (done) -> - f = -> - noflo.helpers.WirePattern c, - in: ['delay', 'msg'] - async: true - , (data, groups, res) -> - - chai.expect(f).to.throw Error - done() - - it 'should throw if receiveStreams is used', (done) -> - f = -> - noflo.helpers.WirePattern c, - in: ['delay', 'msg'] - async: true - ordered: true - group: false - receiveStreams: ['delay', 'msg'] - , (data, groups, res, callback) -> - callback() - - chai.expect(f).to.throw Error - done() - - it 'should throw if sendStreams is used', (done) -> - f = -> - noflo.helpers.WirePattern c, - in: ['delay', 'msg'] - async: true - ordered: true - group: false - sendStreams: ['out'] - , (data, groups, res, callback) -> - callback() - - chai.expect(f).to.throw Error - done() - - # it 'should support complex substreams', (done) -> - # out.removeAllListeners() - # load.removeAllListeners() - # c.cntr = 0 - # helpers.WirePattern c, - # in: ['delay', 'msg'] - # async: true - # ordered: true - # group: false - # receiveStreams: ['delay', 'msg'] - # , (data, groups, res, callback) -> - # # Substream to object conversion validation - # # (the hard way) - # chai.expect(data.delay instanceof Substream).to.be.true - # chai.expect(data.msg instanceof Substream).to.be.true - # delayObj = data.delay.toObject() - # msgObj = data.msg.toObject() - # index0 = this.cntr.toString() - # chai.expect(Object.keys(delayObj)[0]).to.equal index0 - # chai.expect(Object.keys(msgObj)[0]).to.equal index0 - # subDelay = delayObj[index0] - # subMsg = msgObj[index0] - # index1 = (10 + this.cntr).toString() - # chai.expect(Object.keys(subDelay)[0]).to.equal index1 - # chai.expect(Object.keys(subMsg)[0]).to.equal index1 - # delayData = subDelay[index1] - # msgData = subMsg[index1] - # chai.expect(delayData).to.equal sample[c.cntr].delay - # chai.expect(msgData).to.equal sample[c.cntr].msg - # this.cntr++ - - # setTimeout -> - # # Substream tree traversal (the easy way) - # for k0, v0 of msgObj - # res.beginGroup k0 - # res.send k0 - # for k1, v1 of v0 - # res.beginGroup k1 - # res.send - # delay: delayObj[k0][k1] - # msg: msgObj[k0][k1] - # res.endGroup() - # res.send k1 - # res.endGroup() - # callback() - # , data.delay - - # sample = [ - # { delay: 30, msg: "one" } - # { delay: 0, msg: "two" } - # { delay: 20, msg: "three" } - # { delay: 10, msg: "four" } - # ] - - # expected = [ - # '0', '0', '10', sample[0], '10' - # '1', '1', '11', sample[1], '11' - # '2', '2', '12', sample[2], '12' - # '3', '3', '13', sample[3], '13' - # ] - - # out.on 'begingroup', (grp) -> - # chai.expect(grp).to.equal expected.shift() - # out.on 'data', (data) -> - # chai.expect(data).to.deep.equal expected.shift() - # out.on 'disconnect', -> - # done() if expected.length is 0 - - # for i in [0..3] - # delay.beginGroup i - # delay.beginGroup 10 + i - # delay.send sample[i].delay - # delay.endGroup() - # delay.endGroup() - # msg.beginGroup i - # msg.beginGroup 10 + i - # msg.send sample[i].msg - # msg.endGroup() - # msg.endGroup() - # delay.disconnect() - # msg.disconnect() - - describe 'when grouping by field', -> - c = new noflo.Component - c.inPorts.add 'user', datatype: 'object' - .add 'message', datatype: 'object' - c.outPorts.add 'signedmessage' - usr = new noflo.internalSocket.createSocket() - msg = new noflo.internalSocket.createSocket() - umsg = new noflo.internalSocket.createSocket() - c.inPorts.user.attach usr - c.inPorts.message.attach msg - c.outPorts.signedmessage.attach umsg - - it 'should match objects by specific field', (done) -> - noflo.helpers.WirePattern c, - in: ['user', 'message'] - out: 'signedmessage' - async: true - field: 'request' - , (data, groups, out, callback) -> - setTimeout -> - out.send - request: data.request - user: data.user.name - text: data.message.text - callback() - , 10 - - users = - 14: {request: 14, id: 21, name: 'Josh'} - 12: {request: 12, id: 25, name: 'Leo'} - 34: {request: 34, id: 84, name: 'Anica'} - messages = - 34: {request: 34, id: 234, text: 'Hello world'} - 12: {request: 12, id: 82, text: 'Aloha amigos'} - 14: {request: 14, id: 249, text: 'Node.js ftw'} - - counter = 0 - umsg.on 'data', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.request).to.be.ok - chai.expect(data.user).to.equal users[data.request].name - chai.expect(data.text).to.equal messages[data.request].text - counter++ - done() if counter is 3 - - # Send input asynchronously with mixed delays - for req, user of users - do (req, user) -> - setTimeout -> - usr.send user - usr.disconnect() - , req - for req, mesg of messages - do (req, mesg) -> - setTimeout -> - msg.send mesg - msg.disconnect() - , req - - describe 'when there are multiple output routes', -> - it 'should send output to one or more of them', (done) -> - numbers = ['cero', 'uno', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve'] - c = new noflo.Component - c.inPorts.add 'num', datatype: 'int' - .add 'str', datatype: 'string' - c.outPorts.add 'odd', datatype: 'object' - .add 'even', datatype: 'object' - num = new noflo.internalSocket.createSocket() - str = new noflo.internalSocket.createSocket() - odd = new noflo.internalSocket.createSocket() - even = new noflo.internalSocket.createSocket() - c.inPorts.num.attach num - c.inPorts.str.attach str - c.outPorts.odd.attach odd - c.outPorts.even.attach even - - noflo.helpers.WirePattern c, - in: ['num', 'str'] - out: ['odd', 'even'] - async: true - ordered: true - forwardGroups: true - , (data, groups, outs, callback) -> - setTimeout -> - if data.num % 2 is 1 - outs.odd.send data - else - outs.even.send data - callback() - , 0 - - expected = [] - numbers.forEach (n, idx) -> - if idx % 2 is 1 - port = 'odd' - else - port = 'even' - expected.push "#{port} < #{idx}" - expected.push "#{port} DATA #{n}" - expected.push "#{port} > #{idx}" - received = [] - odd.on 'begingroup', (grp) -> - received.push "odd < #{grp}" - odd.on 'data', (data) -> - received.push "odd DATA #{data.str}" - odd.on 'endgroup', (grp) -> - received.push "odd > #{grp}" - odd.on 'disconnect', -> - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - even.on 'begingroup', (grp) -> - received.push "even < #{grp}" - even.on 'data', (data) -> - received.push "even DATA #{data.str}" - even.on 'endgroup', (grp) -> - received.push "even > #{grp}" - even.on 'disconnect', -> - return unless received.length >= expected.length - chai.expect(received).to.eql expected - done() - - for i in [0...10] - num.beginGroup i - num.send i - num.endGroup i - num.disconnect() - str.beginGroup i - str.send numbers[i] - str.endGroup i - str.disconnect() - - it 'should send output to one or more of indexes', (done) -> - c = new noflo.Component - c.inPorts.add 'num', datatype: 'int' - .add 'str', datatype: 'string' - c.outPorts.add 'out', - datatype: 'object' - addressable: true - num = new noflo.internalSocket.createSocket() - str = new noflo.internalSocket.createSocket() - odd = new noflo.internalSocket.createSocket() - even = new noflo.internalSocket.createSocket() - c.inPorts.num.attach num - c.inPorts.str.attach str - c.outPorts.out.attach odd - c.outPorts.out.attach even - numbers = ['cero', 'uno', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve'] - - noflo.helpers.WirePattern c, - in: ['num', 'str'] - out: 'out' - async: true - ordered: true - forwardGroups: true - , (data, groups, outs, callback) -> - setTimeout -> - if data.num % 2 is 1 - outs.send data, 0 - else - outs.send data, 1 - callback() - , 0 - - expected = [] - numbers.forEach (n, idx) -> - if idx % 2 is 1 - port = 'odd' - else - port = 'even' - expected.push "#{port} < #{idx}" - expected.push "#{port} DATA #{n}" - expected.push "#{port} > #{idx}" - received = [] - odd.on 'begingroup', (grp) -> - received.push "odd < #{grp}" - odd.on 'data', (data) -> - received.push "odd DATA #{data.str}" - odd.on 'endgroup', (grp) -> - received.push "odd > #{grp}" - odd.on 'disconnect', -> - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - even.on 'begingroup', (grp) -> - received.push "even < #{grp}" - even.on 'data', (data) -> - received.push "even DATA #{data.str}" - even.on 'endgroup', (grp) -> - received.push "even > #{grp}" - even.on 'disconnect', -> - return unless received.length >= expected.length - chai.expect(received).to.eql expected - done() - - for i in [0...10] - num.beginGroup i - num.send i - num.endGroup i - num.disconnect() - str.beginGroup i - str.send numbers[i] - str.endGroup i - str.disconnect() - - describe 'when there are parameter ports', -> - c = null - p1 = p2 = p3 = d1 = d2 = out = err = 0 - beforeEach -> - c = new noflo.Component - c.inPorts.add 'param1', - datatype: 'string' - required: true - .add 'param2', - datatype: 'int' - required: false - .add 'param3', - datatype: 'int' - required: true - default: 0 - .add 'data1', - datatype: 'string' - .add 'data2', - datatype: 'int' - c.outPorts.add 'out', - datatype: 'object' - .add 'error', - datatype: 'object' - p1 = new noflo.internalSocket.createSocket() - p2 = new noflo.internalSocket.createSocket() - p3 = new noflo.internalSocket.createSocket() - d1 = new noflo.internalSocket.createSocket() - d2 = new noflo.internalSocket.createSocket() - out = new noflo.internalSocket.createSocket() - err = new noflo.internalSocket.createSocket() - c.inPorts.param1.attach p1 - c.inPorts.param2.attach p2 - c.inPorts.param3.attach p3 - c.inPorts.data1.attach d1 - c.inPorts.data2.attach d2 - c.outPorts.out.attach out - c.outPorts.error.attach err - - it 'should wait for required params without default value', (done) -> - noflo.helpers.WirePattern c, - in: ['data1', 'data2'] - out: 'out' - params: ['param1', 'param2', 'param3'] - async: true - , (input, groups, out, callback) -> - res = - p1: c.params.param1 - p2: c.params.param2 - p3: c.params.param3 - d1: input.data1 - d2: input.data2 - out.send res - do callback - err.on 'data', (data) -> - done data - out.once 'data', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.p1).to.equal 'req' - chai.expect(data.p2).to.be.undefined - chai.expect(data.p3).to.equal 0 - chai.expect(data.d1).to.equal 'foo' - chai.expect(data.d2).to.equal 123 - # And later when second param arrives - out.once 'data', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.p1).to.equal 'req' - chai.expect(data.p2).to.equal 568 - chai.expect(data.p3).to.equal 800 - chai.expect(data.d1).to.equal 'bar' - chai.expect(data.d2).to.equal 456 - done() - - d1.send 'foo' - d1.disconnect() - d2.send 123 - d2.disconnect() - c.sendDefaults() - p1.send 'req' - p1.disconnect() - # the handler should be triggered here - - setTimeout -> - p2.send 568 - p2.disconnect() - p3.send 800 - p3.disconnect() - - d1.send 'bar' - d1.disconnect() - d2.send 456 - d2.disconnect() - , 10 - - it 'should work for async procs too', (done) -> - noflo.helpers.WirePattern c, - in: ['data1', 'data2'] - out: 'out' - params: ['param1', 'param2', 'param3'] - async: true - , (input, groups, out, callback) -> - delay = if c.params.param2 then c.params.param2 else 10 - setTimeout -> - res = - p1: c.params.param1 - p2: c.params.param2 - p3: c.params.param3 - d1: input.data1 - d2: input.data2 - out.send res - do callback - , delay - - err.on 'data', (data) -> - done data - out.once 'data', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.p1).to.equal 'req' - chai.expect(data.p2).to.equal 56 - chai.expect(data.p3).to.equal 0 - chai.expect(data.d1).to.equal 'foo' - chai.expect(data.d2).to.equal 123 - done() - - p2.send 56 - p2.disconnect() - d1.send 'foo' - d1.disconnect() - d2.send 123 - d2.disconnect() - c.sendDefaults() - p1.send 'req' - p1.disconnect() - # the handler should be triggered here - - it 'should reset state if shutdown() is called', (done) -> - noflo.helpers.WirePattern c, - in: ['data1', 'data2'] - out: 'out' - params: ['param1', 'param2', 'param3'] - async: true - , (input, groups, out, callback) -> - out.send - p1: c.params.param1 - p2: c.params.param2 - p3: c.params.param3 - d1: input.data1 - d2: input.data2 - do callback - - d1.send 'boo' - d1.disconnect() - p2.send 73 - p2.disconnect() - - chai.expect(c.inPorts.data1.getBuffer().length, 'data1 should have a packet').to.be.above 0 - chai.expect(c.inPorts.param2.getBuffer().length, 'param2 should have a packet').to.be.above 0 - - c.shutdown (err) -> - return done err if err - for portName, port in c.inPorts.ports - chai.expect(port.getBuffer()).to.eql [] - chai.expect(c.load).to.equal 0 - done() - - it 'should drop premature data if configured to do so', (done) -> - noflo.helpers.WirePattern c, - in: ['data1', 'data2'] - out: 'out' - params: ['param1', 'param2', 'param3'] - dropInput: true - async: true - , (input, groups, out, callback) -> - res = - p1: c.params.param1 - p2: c.params.param2 - p3: c.params.param3 - d1: input.data1 - d2: input.data2 - out.send res - do callback - - err.on 'data', (data) -> - done data - out.once 'data', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.p1).to.equal 'req' - chai.expect(data.p2).to.equal 568 - chai.expect(data.p3).to.equal 800 - chai.expect(data.d1).to.equal 'bar' - chai.expect(data.d2).to.equal 456 - done() - - c.sendDefaults() - p2.send 568 - p2.disconnect() - p3.send 800 - p3.disconnect() - d1.send 'foo' - d1.disconnect() - d2.send 123 - d2.disconnect() - # Data is dropped at this point - - setTimeout -> - p1.send 'req' - p1.disconnect() - d1.send 'bar' - d1.disconnect() - d2.send 456 - d2.disconnect() - , 10 - - - describe 'without output ports', -> - foo = null - sig = null - before -> - c = new noflo.Component - c.inPorts.add 'foo' - foo = noflo.internalSocket.createSocket() - sig = noflo.internalSocket.createSocket() - c.inPorts.foo.attach foo - noflo.helpers.WirePattern c, - in: 'foo' - out: [] - async: true - , (foo, grp, out, callback) -> - setTimeout -> - sig.send foo - callback() - , 20 - - it 'should be fine still', (done) -> - sig.on 'data', (data) -> - chai.expect(data).to.equal 'foo' - done() - - foo.send 'foo' - foo.disconnect() - - describe 'with many inputs and groups', -> - ins = noflo.internalSocket.createSocket() - msg = noflo.internalSocket.createSocket() - rep = noflo.internalSocket.createSocket() - pth = noflo.internalSocket.createSocket() - tkn = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - err = noflo.internalSocket.createSocket() - before -> - c = new noflo.Component - c.token = null - c.inPorts.add 'in', datatype: 'string' - .add 'message', datatype: 'string' - .add 'repository', datatype: 'string' - .add 'path', datatype: 'string' - .add 'token', datatype: 'string', (event, payload) -> - c.token = payload if event is 'data' - c.outPorts.add 'out', datatype: 'string' - .add 'error', datatype: 'object' - - noflo.helpers.WirePattern c, - in: ['in', 'message', 'repository', 'path'] - out: 'out' - async: true - forwardGroups: true - , (data, groups, out, callback) -> - setTimeout -> - out.beginGroup data.path - out.send data.message - out.endGroup() - do callback - , 300 - c.inPorts.in.attach ins - c.inPorts.message.attach msg - c.inPorts.repository.attach rep - c.inPorts.path.attach pth - c.inPorts.token.attach tkn - c.outPorts.out.attach out - c.outPorts.error.attach err - - it 'should handle mixed flow well', (done) -> - groups = [] - refGroups = [ - 'foo' - 'http://techcrunch.com/2013/03/26/embedly-now/' - 'path data' - ] - ends = 0 - packets = [] - refData = ['message data'] - out.on 'begingroup', (grp) -> - groups.push grp - out.on 'endgroup', -> - ends++ - out.on 'data', (data) -> - packets.push data - out.on 'disconnect', -> - chai.expect(groups).to.deep.equal refGroups - chai.expect(ends).to.equal 3 - chai.expect(packets).to.deep.equal refData - done() - - err.on 'data', (data) -> - done data - - rep.beginGroup 'foo' - rep.beginGroup 'http://techcrunch.com/2013/03/26/embedly-now/' - rep.send 'repo data' - rep.endGroup() - rep.endGroup() - ins.beginGroup 'foo' - ins.beginGroup 'http://techcrunch.com/2013/03/26/embedly-now/' - ins.send 'ins data' - msg.beginGroup 'foo' - msg.beginGroup 'http://techcrunch.com/2013/03/26/embedly-now/' - msg.send 'message data' - msg.endGroup() - msg.endGroup() - ins.endGroup() - ins.endGroup() - ins.disconnect() - msg.disconnect() - pth.beginGroup 'foo' - pth.beginGroup 'http://techcrunch.com/2013/03/26/embedly-now/' - pth.send 'path data' - pth.endGroup() - pth.endGroup() - pth.disconnect() - rep.disconnect() - - describe 'for batch processing', -> - # Component constructors - newGenerator = (name) -> - generator = new noflo.Component - generator.inPorts.add 'count', datatype: 'int' - generator.outPorts.add 'seq', datatype: 'int' - noflo.helpers.WirePattern generator, - in: 'count' - out: 'seq' - async: true - forwardGroups: true - ordered: true - , (count, groups, seq, callback) -> - sentCount = 0 - for i in [1..count] - do (i) -> - delay = if i > 10 then i % 10 else i - setTimeout -> - seq.send i - sentCount++ - if sentCount is count - callback() - , delay - newDoubler = (name) -> - doubler = new noflo.Component - doubler.inPorts.add 'num', datatype: 'int' - doubler.outPorts.add 'out', datatype: 'int' - noflo.helpers.WirePattern doubler, - in: 'num' - out: 'out' - forwardGroups: true - async: true - , (num, groups, out, callback) -> - dbl = 2*num - out.send dbl - do callback - newAdder = -> - adder = new noflo.Component - adder.inPorts.add 'num1', datatype: 'int' - adder.inPorts.add 'num2', datatype: 'int' - adder.outPorts.add 'sum', datatype: 'int' - noflo.helpers.WirePattern adder, - in: ['num1', 'num2'] - out: 'sum' - forwardGroups: true - async: true - ordered: true - , (args, groups, out, callback) -> - sum = args.num1 + args.num2 - # out.send sum - setTimeout -> - out.send sum - callback() - , sum % 10 - newSeqsum = -> - seqsum = new noflo.Component - seqsum.sum = 0 - seqsum.inPorts.add 'seq', datatype: 'int' - seqsum.outPorts.add 'sum', datatype: 'int' - seqsum.process (input, output) -> - return unless input.hasData 'seq' - seqsum.sum += input.getData 'seq' - return seqsum - - cntA = noflo.internalSocket.createSocket() - cntB = noflo.internalSocket.createSocket() - gen2dblA = noflo.internalSocket.createSocket() - gen2dblB = noflo.internalSocket.createSocket() - dblA2add = noflo.internalSocket.createSocket() - dblB2add = noflo.internalSocket.createSocket() - addr2sum = noflo.internalSocket.createSocket() - sum = noflo.internalSocket.createSocket() - before -> - # Wires - genA = newGenerator 'A' - genB = newGenerator 'B' - dblA = newDoubler 'A' - dblB = newDoubler 'B' - addr = newAdder() - sumr = newSeqsum() - - genA.inPorts.count.attach cntA - genB.inPorts.count.attach cntB - genA.outPorts.seq.attach gen2dblA - genB.outPorts.seq.attach gen2dblB - dblA.inPorts.num.attach gen2dblA - dblB.inPorts.num.attach gen2dblB - dblA.outPorts.out.attach dblA2add - dblB.outPorts.out.attach dblB2add - addr.inPorts.num1.attach dblA2add - addr.inPorts.num2.attach dblB2add - addr.outPorts.sum.attach addr2sum - sumr.inPorts.seq.attach addr2sum - sumr.outPorts.sum.attach sum - - it 'should process sequences of packets separated by disconnects', (done) -> - return @skip 'WirePattern doesn\'t see disconnects because of IP objects' - expected = [ 24, 40 ] - actual = [] - sum.on 'data', (data) -> - actual.push data - sum.on 'disconnect', -> - chai.expect(actual).to.have.length.above 0 - chai.expect(expected).to.have.length.above 0 - act = actual.shift() - exp = expected.shift() - chai.expect(act).to.equal exp - done() if expected.length is 0 - - cntA.send 3 - cntA.disconnect() - cntB.send 3 - cntB.disconnect() - - cntA.send 4 - cntB.send 4 - cntA.disconnect() - cntB.disconnect() - - describe 'for batch processing with groups', -> - c1 = new noflo.Component - c1.inPorts.add 'count', datatype: 'int' - c1.outPorts.add 'seq', datatype: 'int' - c2 = new noflo.Component - c2.inPorts.add 'num', datatype: 'int' - c2.outPorts.add 'out', datatype: 'int' - cnt = noflo.internalSocket.createSocket() - c1c2 = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - - c1.inPorts.count.attach cnt - c1.outPorts.seq.attach c1c2 - c2.inPorts.num.attach c1c2 - c2.outPorts.out.attach out - - it 'should wrap entire sequence with groups', (done) -> - noflo.helpers.WirePattern c1, - in: 'count' - out: 'seq' - async: true - forwardGroups: true - , (count, groups, out, callback) -> - for i in [0...count] - do (i) -> - setTimeout -> - out.send i - , 0 - setTimeout -> - callback() - , 3 - - noflo.helpers.WirePattern c2, - in: 'num' - out: 'out' - forwardGroups: true - async: true - , (num, groups, out, callback) -> - chai.expect(groups).to.deep.equal ['foo', 'bar'] - out.send num - do callback - - expected = ['', '', 0, 1, 2, '', ''] - actual = [] - out.on 'begingroup', (grp) -> - actual.push "<#{grp}>" - out.on 'endgroup', (grp) -> - actual.push "" - out.on 'data', (data) -> - actual.push data - out.on 'disconnect', -> - chai.expect(actual).to.deep.equal expected - done() - - cnt.beginGroup 'foo' - cnt.beginGroup 'bar' - cnt.send 3 - cnt.endGroup() - cnt.endGroup() - cnt.disconnect() - - describe 'with addressable ports', -> - c = null - p11 = null - p12 = null - p13 = null - d11 = null - d12 = null - d13 = null - d2 = null - out = null - err = null - beforeEach -> - c = new noflo.Component - c.inPorts.add 'p1', - datatype: 'int' - addressable: true - required: true - .add 'd1', - datatype: 'int' - addressable: true - .add 'd2', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'object' - .add 'error', - datatype: 'object' - p11 = noflo.internalSocket.createSocket() - p12 = noflo.internalSocket.createSocket() - p13 = noflo.internalSocket.createSocket() - d11 = noflo.internalSocket.createSocket() - d12 = noflo.internalSocket.createSocket() - d13 = noflo.internalSocket.createSocket() - d2 = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - err = noflo.internalSocket.createSocket() - c.inPorts.p1.attach p11 - c.inPorts.p1.attach p12 - c.inPorts.p1.attach p13 - c.inPorts.d1.attach d11 - c.inPorts.d1.attach d12 - c.inPorts.d1.attach d13 - c.inPorts.d2.attach d2 - c.outPorts.out.attach out - c.outPorts.error.attach err - - it 'should wait for all param and any data port values (default)', (done) -> - noflo.helpers.WirePattern c, - in: ['d1', 'd2'] - params: 'p1' - out: 'out' - arrayPolicy: # default values - in: 'any' - params: 'all' - async: true - , (input, groups, out, callback) -> - chai.expect(c.params.p1).to.deep.equal { 0: 1, 1: 2, 2: 3 } - chai.expect(input.d1).to.deep.equal {0: 1} - chai.expect(input.d2).to.equal 'foo' - do callback - done() - - d2.send 'foo' - d2.disconnect() - d11.send 1 - d11.disconnect() - p11.send 1 - p11.disconnect() - p12.send 2 - p12.disconnect() - p13.send 3 - p13.disconnect() - - it 'should wait for any param and all data values', (done) -> - noflo.helpers.WirePattern c, - in: ['d1', 'd2'] - params: 'p1' - out: 'out' - arrayPolicy: # inversed - in: 'all' - params: 'any' - async: true - , (input, groups, out, callback) -> - chai.expect(c.params.p1).to.deep.equal {0: 1} - chai.expect(input.d1).to.deep.equal { 0: 1, 1: 2, 2: 3 } - chai.expect(input.d2).to.equal 'foo' - do callback - done() - - out.on 'disconnect', -> - console.log 'disc' - - d2.send 'foo' - d2.disconnect() - p11.send 1 - p11.disconnect() - d11.send 1 - d11.disconnect() - d12.send 2 - d12.disconnect() - d13.send 3 - d13.disconnect() - p12.send 2 - p12.disconnect() - p13.send 3 - p13.disconnect() - - it 'should wait for all indexes of a single input', (done) -> - noflo.helpers.WirePattern c, - in: 'd1' - out: 'out' - arrayPolicy: - in: 'all' - async: true - , (input, groups, out, callback) -> - chai.expect(input).to.deep.equal { 0: 1, 1: 2, 2: 3 } - do callback - done() - - d11.send 1 - d11.disconnect() - d12.send 2 - d12.disconnect() - d13.send 3 - d13.disconnect() - - it 'should behave normally with string output from another component', (done) -> - c = new noflo.Component - c.inPorts.add 'd1', - datatype: 'string' - addressable: true - c.outPorts.add 'out', - datatype: 'object' - d11 = noflo.internalSocket.createSocket() - d12 = noflo.internalSocket.createSocket() - d13 = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - c.inPorts.d1.attach d11 - c.inPorts.d1.attach d12 - c.inPorts.d1.attach d13 - c.outPorts.out.attach out - c2 = new noflo.Component - c2.inPorts.add 'in', datatype: 'string' - c2.outPorts.add 'out', datatype: 'string' - noflo.helpers.WirePattern c2, - in: 'in' - out: 'out' - forwardGroups: true - async: true - , (input, groups, out, callback) -> - out.send input - do callback - d3 = noflo.internalSocket.createSocket() - c2.inPorts.in.attach d3 - c2.outPorts.out.attach d11 - - noflo.helpers.WirePattern c, - in: 'd1' - out: 'out' - async: true - , (input, groups, out, callback) -> - chai.expect(input).to.deep.equal {0: 'My string'} - do callback - done() - - d3.send 'My string' - d3.disconnect() - - describe 'when grouping requests', -> - c = new noflo.Component - c.inPorts.add 'x', datatype: 'int' - .add 'y', datatype: 'int' - c.outPorts.add 'out', datatype: 'object' - x = noflo.internalSocket.createSocket() - y = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - c.inPorts.x.attach x - c.inPorts.y.attach y - c.outPorts.out.attach out - - getUuid = -> - 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace /[xy]/g, (c) -> - r = Math.random()*16|0 - v = if c is 'x' then r else r&0x3|0x8 - v.toString 16 - isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - - generateRequests = (num) -> - reqs = {} - for i in [1..num] - req = - id: getUuid() - num: i - if i % 3 is 0 - req.x = i - else if i % 7 is 0 - req.y = i - else - req.x = i - req.y = 2*i - reqs[req.id] = req - reqs - - sendRequests = (reqs, delay) -> - for id, req of reqs - do (req) -> - setTimeout -> - if 'x' of req - x.beginGroup req.id - x.beginGroup 'x' - x.beginGroup req.num - x.send req.x - x.endGroup() - x.endGroup() - x.endGroup() - x.disconnect() - if 'y' of req - y.beginGroup req.id - y.beginGroup 'y' - y.beginGroup req.num - y.send req.y - y.endGroup() - y.endGroup() - y.endGroup() - y.disconnect() - , delay*req.num - - before -> - noflo.helpers.WirePattern c, - in: ['x', 'y'] - out: 'out' - async: true - forwardGroups: true - group: isUuid - gcFrequency: 2 # every 2 requests - gcTimeout: 0.02 # older than 20ms - , (input, groups, out, done) -> - setTimeout -> - out.send - id: groups[0] - x: input.x - y: input.y - done() - , 3 - - it 'should group requests by outer UUID group', (done) -> - reqs = generateRequests 10 - count = 0 - - out.on 'data', (data) -> - count++ - chai.expect(data.x).to.equal reqs[data.id].x - chai.expect(data.y).to.equal reqs[data.id].y - done() if count is 6 # 6 complete requests processed - - sendRequests reqs, 10 - - describe 'when using scopes', -> - c = new noflo.Component - inPorts: - x: datatype: 'int' - y: datatype: 'int' - outPorts: - out: datatype: 'object' - x = noflo.internalSocket.createSocket() - y = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - c.inPorts.x.attach x - c.inPorts.y.attach y - c.outPorts.out.attach out - - getUuid = -> - 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace /[xy]/g, (c) -> - r = Math.random()*16|0 - v = if c is 'x' then r else r&0x3|0x8 - v.toString 16 - isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - - generateRequests = (num) -> - reqs = {} - for i in [1..num] - req = - id: getUuid() - num: i - if i % 3 is 0 - req.x = i - else if i % 7 is 0 - req.y = i - else - req.x = i - req.y = 2*i - reqs[req.id] = req - reqs - - sendRequests = (reqs, delay) -> - for id, req of reqs - do (req) -> - setTimeout -> - if 'x' of req - x.post new noflo.IP 'openBracket', 'x', scope: req.id - x.post new noflo.IP 'data', req.x, scope: req.id - x.post new noflo.IP 'closeBracket', null, scope: req.id - x.disconnect() - if 'y' of req - y.post new noflo.IP 'openBracket', 'y', scope: req.id - y.post new noflo.IP 'data', req.y, scope: req.id - y.post new noflo.IP 'closeBracket', null, scope: req.id - y.disconnect() - , delay*req.num - - before -> - noflo.helpers.WirePattern c, - in: ['x', 'y'] - out: 'out' - async: true - forwardGroups: true - , (input, groups, out, done, postpone, resume, scope) -> - setTimeout -> - out.send - id: scope - x: input.x - y: input.y - done() - , 3 - - it 'should scope requests by proper UUID', (done) -> - reqs = generateRequests 10 - count = 0 - - out.on 'data', (data) -> - count++ - chai.expect(data.x).to.equal reqs[data.id].x - chai.expect(data.y).to.equal reqs[data.id].y - done() if count is 6 # 6 complete requests processed - - sendRequests reqs, 10 diff --git a/spec/IP.coffee b/spec/IP.coffee deleted file mode 100644 index 54b4d8b48..000000000 --- a/spec/IP.coffee +++ /dev/null @@ -1,47 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' -else - noflo = require 'noflo' - -describe 'IP object', -> - it 'should create IPs of different types', -> - open = new noflo.IP 'openBracket' - data = new noflo.IP 'data', "Payload" - close = new noflo.IP 'closeBracket' - chai.expect(open.type).to.equal 'openBracket' - chai.expect(close.type).to.equal 'closeBracket' - chai.expect(data.type).to.equal 'data' - - it 'should be moved to an owner', -> - p = new noflo.IP 'data', "Token" - p.move 'SomeProc' - chai.expect(p.owner).to.equal 'SomeProc' - - it 'should support sync context scoping', -> - p = new noflo.IP 'data', "Request-specific" - p.scope = 'request-12345' - chai.expect(p.scope).to.equal 'request-12345' - - it 'should be able to clone itself', -> - d1 = new noflo.IP 'data', "Trooper", - groups: ['foo', 'bar'] - owner: 'SomeProc' - scope: 'request-12345' - clonable: true - datatype: 'string' - schema: 'text/plain' - d2 = d1.clone() - chai.expect(d2).not.to.equal d1 - chai.expect(d2.type).to.equal d1.type - chai.expect(d2.schema).to.equal d1.schema - chai.expect(d2.data).to.eql d1.data - chai.expect(d2.groups).to.eql d2.groups - chai.expect(d2.owner).not.to.equal d1.owner - chai.expect(d2.scope).to.equal d1.scope - - it 'should dispose its contents when dropped', -> - p = new noflo.IP 'data', "Garbage" - p.groups = ['foo', 'bar'] - p.drop() - chai.expect(Object.keys(p)).to.have.lengthOf 0 diff --git a/spec/IP.js b/spec/IP.js new file mode 100644 index 000000000..7b17732f5 --- /dev/null +++ b/spec/IP.js @@ -0,0 +1,52 @@ +let chai; let noflo; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); +} else { + noflo = require('noflo'); +} + +describe('IP object', () => { + it('should create IPs of different types', () => { + const open = new noflo.IP('openBracket'); + const data = new noflo.IP('data', 'Payload'); + const close = new noflo.IP('closeBracket'); + chai.expect(open.type).to.equal('openBracket'); + chai.expect(close.type).to.equal('closeBracket'); + chai.expect(data.type).to.equal('data'); + }); + it('should be moved to an owner', () => { + const p = new noflo.IP('data', 'Token'); + p.move('SomeProc'); + chai.expect(p.owner).to.equal('SomeProc'); + }); + it('should support sync context scoping', () => { + const p = new noflo.IP('data', 'Request-specific'); + p.scope = 'request-12345'; + chai.expect(p.scope).to.equal('request-12345'); + }); + it('should be able to clone itself', () => { + const d1 = new noflo.IP('data', 'Trooper', { + groups: ['foo', 'bar'], + owner: 'SomeProc', + scope: 'request-12345', + clonable: true, + datatype: 'string', + schema: 'text/plain', + }); + const d2 = d1.clone(); + chai.expect(d2).not.to.equal(d1); + chai.expect(d2.type).to.equal(d1.type); + chai.expect(d2.schema).to.equal(d1.schema); + chai.expect(d2.data).to.eql(d1.data); + chai.expect(d2.groups).to.eql(d2.groups); + chai.expect(d2.owner).not.to.equal(d1.owner); + chai.expect(d2.scope).to.equal(d1.scope); + }); + it('should dispose its contents when dropped', () => { + const p = new noflo.IP('data', 'Garbage'); + p.groups = ['foo', 'bar']; + p.drop(); + chai.expect(Object.keys(p)).to.have.lengthOf(0); + }); +}); diff --git a/spec/InPort.coffee b/spec/InPort.coffee deleted file mode 100644 index fac3632d3..000000000 --- a/spec/InPort.coffee +++ /dev/null @@ -1,225 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' -else - noflo = require 'noflo' - -describe 'Inport Port', -> - describe 'with default options', -> - p = new noflo.InPort - it 'should be of datatype "all"', -> - chai.expect(p.getDataType()).to.equal 'all' - it 'should not be required', -> - chai.expect(p.isRequired()).to.equal false - it 'should not be addressable', -> - chai.expect(p.isAddressable()).to.equal false - it 'should not be buffered', -> - chai.expect(p.isBuffered()).to.equal false - describe 'with custom type', -> - p = new noflo.InPort - datatype: 'string' - schema: 'text/url' - it 'should retain the type', -> - chai.expect(p.getDataType()).to.equal 'string' - chai.expect(p.getSchema()).to.equal 'text/url' - - describe 'without attached sockets', -> - p = new noflo.InPort - it 'should not be attached', -> - chai.expect(p.isAttached()).to.equal false - chai.expect(p.listAttached()).to.eql [] - it 'should allow attaching', -> - chai.expect(p.canAttach()).to.equal true - it 'should not be connected initially', -> - chai.expect(p.isConnected()).to.equal false - it 'should not contain a socket initially', -> - chai.expect(p.sockets.length).to.equal 0 - - describe 'with processing function called with port as context', -> - it 'should set context to port itself', (done) -> - s = new noflo.internalSocket.InternalSocket - p = new noflo.InPort - p.on 'data', (packet, component) -> - chai.expect(@).to.equal p - chai.expect(packet).to.equal 'some-data' - done() - p.attach s - s.send 'some-data' - - describe 'with default value', -> - p = s = null - beforeEach -> - p = new noflo.InPort - default: 'default-value' - s = new noflo.internalSocket.InternalSocket - p.attach s - it 'should send the default value as a packet, though on next tick after initialization', (done) -> - p.on 'data', (data) -> - chai.expect(data).to.equal 'default-value' - done() - s.send() - it 'should send the default value before IIP', (done) -> - received = ['default-value', 'some-iip'] - p.on 'data', (data) -> - chai.expect(data).to.equal received.shift() - done() if received.length is 0 - setTimeout -> - s.send() - s.send 'some-iip' - , 0 - - describe 'with options stored in port', -> - it 'should store all provided options in port, whether we expect it or not', -> - options = - datatype: 'string' - type: 'http://schema.org/Person' - description: 'Person' - required: true - weNeverExpectThis: 'butWeStoreItAnyway' - p = new noflo.InPort options - for name, option of options - chai.expect(p.options[name]).to.equal option - - describe 'with data type information', -> - right = 'all string number int object array'.split ' ' - wrong = 'not valie data types'.split ' ' - f = (datatype) -> - new noflo.InPort - datatype: datatype - right.forEach (r) -> - it "should accept a '#{r}' data type", => - chai.expect(-> f r).to.not.throw() - wrong.forEach (w) -> - it "should NOT accept a '#{w}' data type", => - chai.expect(-> f w).to.throw() - - describe 'with TYPE (i.e. ontology) information', -> - f = (type) -> - new noflo.InPort - type: type - it 'should be a URL or MIME', -> - chai.expect(-> f 'http://schema.org/Person').to.not.throw() - chai.expect(-> f 'text/javascript').to.not.throw() - chai.expect(-> f 'neither-a-url-nor-mime').to.throw() - - describe 'with accepted enumerated values', -> - it 'should accept certain values', (done) -> - p = new noflo.InPort - values: 'noflo is awesome'.split ' ' - s = new noflo.internalSocket.InternalSocket - p.attach s - p.on 'data', (data) -> - chai.expect(data).to.equal 'awesome' - done() - s.send 'awesome' - - it 'should throw an error if value is not accepted', -> - p = new noflo.InPort - values: 'noflo is awesome'.split ' ' - s = new noflo.internalSocket.InternalSocket - p.attach s - p.on 'data', -> - # Fail the test, we shouldn't have received anything - chai.expect(true).to.be.equal false - - chai.expect(-> s.send('terrific')).to.throw - - describe 'with processing shorthand', -> - it 'should also accept metadata (i.e. options) when provided', (done) -> - s = new noflo.internalSocket.InternalSocket - expectedEvents = [ - 'connect' - 'data' - 'disconnect' - ] - ps = - outPorts: new noflo.OutPorts - out: new noflo.OutPort - inPorts: new noflo.InPorts - ps.inPorts.add 'in', - datatype: 'string' - required: true - ps.inPorts.in.on 'ip', (ip) -> - return unless ip.type is 'data' - chai.expect(ip.data).to.equal 'some-data' - done() - ps.inPorts.in.attach s - chai.expect(ps.inPorts.in.listAttached()).to.eql [0] - s.send 'some-data' - s.disconnect() - - it 'should translate IP objects to legacy events', (done) -> - s = new noflo.internalSocket.InternalSocket - expectedEvents = [ - 'connect' - 'data' - 'disconnect' - ] - receivedEvents = [] - ps = - outPorts: new noflo.OutPorts - out: new noflo.OutPort - inPorts: new noflo.InPorts - ps.inPorts.add 'in', - datatype: 'string' - required: true - ps.inPorts.in.on 'connect', -> - receivedEvents.push 'connect' - ps.inPorts.in.on 'data', -> - receivedEvents.push 'data' - ps.inPorts.in.on 'disconnect', -> - receivedEvents.push 'disconnect' - chai.expect(receivedEvents).to.eql expectedEvents - done() - ps.inPorts.in.attach s - chai.expect(ps.inPorts.in.listAttached()).to.eql [0] - s.post new noflo.IP 'data', 'some-data' - - it 'should stamp an IP object with the port\'s datatype', (done) -> - p = new noflo.InPort - datatype: 'string' - p.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 'Hello' - chai.expect(data.datatype).to.equal 'string' - done() - p.handleIP new noflo.IP 'data', 'Hello' - it 'should keep an IP object\'s datatype as-is if already set', (done) -> - p = new noflo.InPort - datatype: 'string' - p.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 123 - chai.expect(data.datatype).to.equal 'integer' - done() - p.handleIP new noflo.IP 'data', 123, - datatype: 'integer' - - it 'should stamp an IP object with the port\'s schema', (done) -> - p = new noflo.InPort - datatype: 'string' - schema: 'text/markdown' - p.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 'Hello' - chai.expect(data.datatype).to.equal 'string' - chai.expect(data.schema).to.equal 'text/markdown' - done() - p.handleIP new noflo.IP 'data', 'Hello' - it 'should keep an IP object\'s schema as-is if already set', (done) -> - p = new noflo.InPort - datatype: 'string' - schema: 'text/markdown' - p.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 'Hello' - chai.expect(data.datatype).to.equal 'string' - chai.expect(data.schema).to.equal 'text/plain' - done() - p.handleIP new noflo.IP 'data', 'Hello', - datatype: 'string' - schema: 'text/plain' diff --git a/spec/InPort.js b/spec/InPort.js new file mode 100644 index 000000000..d4a1c43af --- /dev/null +++ b/spec/InPort.js @@ -0,0 +1,262 @@ +let chai; let noflo; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); +} else { + noflo = require('noflo'); +} + +describe('Inport Port', () => { + describe('with default options', () => { + const p = new noflo.InPort(); + it('should be of datatype "all"', () => { + chai.expect(p.getDataType()).to.equal('all'); + }); + it('should not be required', () => { + chai.expect(p.isRequired()).to.equal(false); + }); + it('should not be addressable', () => { + chai.expect(p.isAddressable()).to.equal(false); + }); + it('should not be buffered', () => chai.expect(p.isBuffered()).to.equal(false)); + }); + describe('with custom type', () => { + const p = new noflo.InPort({ + datatype: 'string', + schema: 'text/url', + }); + it('should retain the type', () => { + chai.expect(p.getDataType()).to.equal('string'); + chai.expect(p.getSchema()).to.equal('text/url'); + }); + }); + describe('without attached sockets', () => { + const p = new noflo.InPort(); + it('should not be attached', () => { + chai.expect(p.isAttached()).to.equal(false); + chai.expect(p.listAttached()).to.eql([]); + }); + it('should allow attaching', () => { + chai.expect(p.canAttach()).to.equal(true); + }); + it('should not be connected initially', () => { + chai.expect(p.isConnected()).to.equal(false); + }); + it('should not contain a socket initially', () => { + chai.expect(p.sockets.length).to.equal(0); + }); + }); + describe('with processing function called with port as context', () => { + it('should set context to port itself', (done) => { + const s = new noflo.internalSocket.InternalSocket(); + const p = new noflo.InPort(); + p.on('data', function (packet) { + chai.expect(this).to.equal(p); + chai.expect(packet).to.equal('some-data'); + done(); + }); + p.attach(s); + s.send('some-data'); + }); + }); + describe('with default value', () => { + let p = null; + let s = null; + beforeEach(() => { + p = new noflo.InPort({ default: 'default-value' }); + s = new noflo.internalSocket.InternalSocket(); + p.attach(s); + }); + it('should send the default value as a packet, though on next tick after initialization', (done) => { + p.on('data', (data) => { + chai.expect(data).to.equal('default-value'); + done(); + }); + s.send(); + }); + it('should send the default value before IIP', (done) => { + const received = ['default-value', 'some-iip']; + p.on('data', (data) => { + chai.expect(data).to.equal(received.shift()); + if (received.length === 0) { done(); } + }); + setTimeout(() => { + s.send(); + s.send('some-iip'); + }, + 0); + }); + }); + describe('with options stored in port', () => { + it('should store all provided options in port, whether we expect it or not', () => { + const options = { + datatype: 'string', + type: 'http://schema.org/Person', + description: 'Person', + required: true, + weNeverExpectThis: 'butWeStoreItAnyway', + }; + const p = new noflo.InPort(options); + for (const name in options) { + if (Object.prototype.hasOwnProperty.call(options, name)) { + const option = options[name]; + chai.expect(p.options[name]).to.equal(option); + } + } + }); + }); + describe('with data type information', () => { + const right = 'all string number int object array'.split(' '); + const wrong = 'not valie data types'.split(' '); + const f = (datatype) => new noflo.InPort({ datatype }); + right.forEach((r) => { + it(`should accept a '${r}' data type`, () => { + chai.expect(() => f(r)).to.not.throw(); + }); + }); + wrong.forEach((w) => { + it(`should NOT accept a '${w}' data type`, () => { + chai.expect(() => f(w)).to.throw(); + }); + }); + }); + describe('with TYPE (i.e. ontology) information', () => { + const f = (type) => new noflo.InPort({ type }); + it('should be a URL or MIME', () => { + chai.expect(() => f('http://schema.org/Person')).to.not.throw(); + chai.expect(() => f('text/javascript')).to.not.throw(); + chai.expect(() => f('neither-a-url-nor-mime')).to.throw(); + }); + }); + describe('with accepted enumerated values', () => { + it('should accept certain values', (done) => { + const p = new noflo.InPort({ values: 'noflo is awesome'.split(' ') }); + const s = new noflo.internalSocket.InternalSocket(); + p.attach(s); + p.on('data', (data) => { + chai.expect(data).to.equal('awesome'); + done(); + }); + s.send('awesome'); + }); + it('should throw an error if value is not accepted', () => { + const p = new noflo.InPort({ values: 'noflo is awesome'.split(' ') }); + const s = new noflo.internalSocket.InternalSocket(); + p.attach(s); + p.on('data', () => { + // Fail the test, we shouldn't have received anything + chai.expect(true).to.be.equal(false); + }); + chai.expect(() => s.send('terrific')).to.throw; + }); + }); + describe('with processing shorthand', () => { + it('should also accept metadata (i.e. options) when provided', (done) => { + const s = new noflo.internalSocket.InternalSocket(); + const ps = { + outPorts: new noflo.OutPorts({ out: new noflo.OutPort() }), + inPorts: new noflo.InPorts(), + }; + ps.inPorts.add('in', { + datatype: 'string', + required: true, + }); + ps.inPorts.in.on('ip', (ip) => { + if (ip.type !== 'data') { return; } + chai.expect(ip.data).to.equal('some-data'); + done(); + }); + ps.inPorts.in.attach(s); + chai.expect(ps.inPorts.in.listAttached()).to.eql([0]); + s.send('some-data'); + s.disconnect(); + }); + it('should translate IP objects to legacy events', (done) => { + const s = new noflo.internalSocket.InternalSocket(); + const expectedEvents = [ + 'connect', + 'data', + 'disconnect', + ]; + const receivedEvents = []; + const ps = { + outPorts: new noflo.OutPorts({ out: new noflo.OutPort() }), + inPorts: new noflo.InPorts(), + }; + ps.inPorts.add('in', { + datatype: 'string', + required: true, + }); + ps.inPorts.in.on('connect', () => { + receivedEvents.push('connect'); + }); + ps.inPorts.in.on('data', () => { + receivedEvents.push('data'); + }); + ps.inPorts.in.on('disconnect', () => { + receivedEvents.push('disconnect'); + chai.expect(receivedEvents).to.eql(expectedEvents); + done(); + }); + ps.inPorts.in.attach(s); + chai.expect(ps.inPorts.in.listAttached()).to.eql([0]); + s.post(new noflo.IP('data', 'some-data')); + }); + it('should stamp an IP object with the port\'s datatype', (done) => { + const p = new noflo.InPort({ datatype: 'string' }); + p.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal('Hello'); + chai.expect(data.datatype).to.equal('string'); + done(); + }); + p.handleIP(new noflo.IP('data', 'Hello')); + }); + it('should keep an IP object\'s datatype as-is if already set', (done) => { + const p = new noflo.InPort({ datatype: 'string' }); + p.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal(123); + chai.expect(data.datatype).to.equal('integer'); + done(); + }); + p.handleIP(new noflo.IP('data', 123, + { datatype: 'integer' })); + }); + it('should stamp an IP object with the port\'s schema', (done) => { + const p = new noflo.InPort({ + datatype: 'string', + schema: 'text/markdown', + }); + p.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal('Hello'); + chai.expect(data.datatype).to.equal('string'); + chai.expect(data.schema).to.equal('text/markdown'); + done(); + }); + p.handleIP(new noflo.IP('data', 'Hello')); + }); + it('should keep an IP object\'s schema as-is if already set', (done) => { + const p = new noflo.InPort({ + datatype: 'string', + schema: 'text/markdown', + }); + p.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal('Hello'); + chai.expect(data.datatype).to.equal('string'); + chai.expect(data.schema).to.equal('text/plain'); + done(); + }); + p.handleIP(new noflo.IP('data', 'Hello', { + datatype: 'string', + schema: 'text/plain', + })); + }); + }); +}); diff --git a/spec/LegacyNetwork.coffee b/spec/LegacyNetwork.coffee deleted file mode 100644 index 5ede5b179..000000000 --- a/spec/LegacyNetwork.coffee +++ /dev/null @@ -1,599 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - path = require 'path' - root = path.resolve __dirname, '../' -else - noflo = require 'noflo' - root = 'noflo' - -describe 'NoFlo Legacy Network', -> - Split = -> - new noflo.Component - inPorts: - in: datatype: 'all' - outPorts: - out: datatype: 'all' - process: (input, output) -> - output.sendDone - out: input.get 'in' - Merge = -> - new noflo.Component - inPorts: - in: datatype: 'all' - outPorts: - out: datatype: 'all' - process: (input, output) -> - output.sendDone - out: input.get 'in' - Callback = -> - new noflo.Component - inPorts: - in: datatype: 'all' - callback: - datatype: 'all' - control: true - process: (input, output) -> - # Drop brackets - return unless input.hasData 'callback', 'in' - cb = input.getData 'callback' - data = input.getData 'in' - cb data - output.done() - - describe 'with an empty graph', -> - g = null - n = null - before (done) -> - g = new noflo.Graph - g.baseDir = root - n = new noflo.Network g - n.connect done - it 'should initially be marked as stopped', -> - chai.expect(n.isStarted()).to.equal false - it 'should initially have no processes', -> - chai.expect(n.processes).to.be.empty - it 'should initially have no active processes', -> - chai.expect(n.getActiveProcesses()).to.eql [] - it 'should initially have to connections', -> - chai.expect(n.connections).to.be.empty - it 'should initially have no IIPs', -> - chai.expect(n.initials).to.be.empty - it 'should have reference to the graph', -> - chai.expect(n.graph).to.equal g - it 'should know its baseDir', -> - chai.expect(n.baseDir).to.equal g.baseDir - it 'should have a ComponentLoader', -> - chai.expect(n.loader).to.be.an 'object' - it 'should have transmitted the baseDir to the Component Loader', -> - chai.expect(n.loader.baseDir).to.equal g.baseDir - it 'should be able to list components', (done) -> - @timeout 60 * 1000 - n.loader.listComponents (err, components) -> - return done err if err - chai.expect(components).to.be.an 'object' - done() - return - it 'should have an uptime', -> - chai.expect(n.uptime()).to.be.at.least 0 - - describe 'with new node', -> - it 'should contain the node', (done) -> - g.once 'addNode', -> - setTimeout -> - chai.expect(n.processes).not.to.be.empty - chai.expect(n.processes.Graph).to.exist - done() - , 10 - g.addNode 'Graph', 'Graph', - foo: 'Bar' - it 'should have transmitted the node metadata to the process', -> - chai.expect(n.processes.Graph.component.metadata).to.exist - chai.expect(n.processes.Graph.component.metadata).to.be.an 'object' - chai.expect(n.processes.Graph.component.metadata).to.eql g.getNode('Graph').metadata - it 'adding the same node again should be a no-op', (done) -> - originalProcess = n.getNode 'Graph' - graphNode = g.getNode 'Graph' - n.addNode graphNode, (err, newProcess) -> - return done err if err - chai.expect(newProcess).to.equal originalProcess - done() - it 'should not contain the node after removal', (done) -> - g.once 'removeNode', -> - setTimeout -> - chai.expect(n.processes).to.be.empty - done() - , 10 - g.removeNode 'Graph' - it 'should fail when removing the removed node again', (done) -> - n.removeNode - id: 'Graph' - , (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'not found' - done() - describe 'with new edge', -> - before -> - n.loader.components.Split = Split - g.addNode 'A', 'Split' - g.addNode 'B', 'Split' - after -> - g.removeNode 'A' - g.removeNode 'B' - it 'should contain the edge', (done) -> - g.once 'addEdge', -> - setTimeout -> - chai.expect(n.connections).not.to.be.empty - chai.expect(n.connections[0].from).to.eql - process: n.getNode 'A' - port: 'out' - index: undefined - chai.expect(n.connections[0].to).to.eql - process: n.getNode 'B' - port: 'in' - index: undefined - done() - , 10 - g.addEdge 'A', 'out', 'B', 'in' - it 'should not contain the edge after removal', (done) -> - g.once 'removeEdge', -> - setTimeout -> - chai.expect(n.connections).to.be.empty - done() - , 10 - g.removeEdge 'A', 'out', 'B', 'in' - - describe 'with a simple graph', -> - g = null - n = null - cb = null - before (done) -> - @timeout 60 * 1000 - g = new noflo.Graph - g.baseDir = root - g.addNode 'Merge', 'Merge' - g.addNode 'Callback', 'Callback' - g.addEdge 'Merge', 'out', 'Callback', 'in' - g.addInitial (data) -> - chai.expect(data).to.equal 'Foo' - cb() - , 'Callback', 'callback' - g.addInitial 'Foo', 'Merge', 'in' - noflo.createNetwork g, (err, nw) -> - return done err if err - nw.loader.components.Split = Split - nw.loader.components.Merge = Merge - nw.loader.components.Callback = Callback - n = nw - nw.connect (err) -> - return done err if err - done() - , true - - it 'should send some initials when started', (done) -> - chai.expect(n.initials).not.to.be.empty - cb = done - n.start (err) -> - return done err if err - - it 'should contain two processes', -> - chai.expect(n.processes).to.not.be.empty - chai.expect(n.processes.Merge).to.exist - chai.expect(n.processes.Merge).to.be.an 'Object' - chai.expect(n.processes.Callback).to.exist - chai.expect(n.processes.Callback).to.be.an 'Object' - it 'the ports of the processes should know the node names', -> - for name, port of n.processes.Callback.component.inPorts.ports - chai.expect(port.name).to.equal name - chai.expect(port.node).to.equal 'Callback' - chai.expect(port.getId()).to.equal "Callback #{name.toUpperCase()}" - for name, port of n.processes.Callback.component.outPorts.ports - chai.expect(port.name).to.equal name - chai.expect(port.node).to.equal 'Callback' - chai.expect(port.getId()).to.equal "Callback #{name.toUpperCase()}" - - it 'should contain 1 connection between processes and 2 for IIPs', -> - chai.expect(n.connections).to.not.be.empty - chai.expect(n.connections.length).to.equal 3 - - it 'should have started in debug mode', -> - chai.expect(n.debug).to.equal true - chai.expect(n.getDebug()).to.equal true - - it 'should emit a process-error when a component throws', (done) -> - g.removeInitial 'Callback', 'callback' - g.removeInitial 'Merge', 'in' - g.addInitial (data) -> - throw new Error 'got Foo' - , 'Callback', 'callback' - g.addInitial 'Foo', 'Merge', 'in' - n.once 'process-error', (err) -> - chai.expect(err).to.be.an 'object' - chai.expect(err.id).to.equal 'Callback' - chai.expect(err.metadata).to.be.an 'object' - chai.expect(err.error).to.be.an 'error' - chai.expect(err.error.message).to.equal 'got Foo' - done() - n.sendInitials() - - describe 'with a renamed node', -> - it 'should have the process in a new location', (done) -> - g.once 'renameNode', -> - chai.expect(n.processes.Func).to.be.an 'object' - done() - g.renameNode 'Callback', 'Func' - it 'shouldn\'t have the process in the old location', -> - chai.expect(n.processes.Callback).to.be.undefined - it 'should fail to rename with the old name', (done) -> - n.renameNode 'Callback', 'Func', (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'not found' - done() - it 'should have informed the ports of their new node name', -> - for name, port of n.processes.Func.component.inPorts.ports - chai.expect(port.name).to.equal name - chai.expect(port.node).to.equal 'Func' - chai.expect(port.getId()).to.equal "Func #{name.toUpperCase()}" - for name, port of n.processes.Func.component.outPorts.ports - chai.expect(port.name).to.equal name - chai.expect(port.node).to.equal 'Func' - chai.expect(port.getId()).to.equal "Func #{name.toUpperCase()}" - - describe 'with process icon change', -> - it 'should emit an icon event', (done) -> - n.once 'icon', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.id).to.equal 'Func' - chai.expect(data.icon).to.equal 'flask' - done() - n.processes.Func.component.setIcon 'flask' - - describe 'once stopped', -> - it 'should be marked as stopped', (done) -> - n.stop -> - chai.expect(n.isStarted()).to.equal false - done() - - describe 'without the delay option', -> - it 'should auto-start', (done) -> - g.removeInitial 'Func', 'callback' - newGraph = noflo.graph.loadJSON g.toJSON(), (err, graph) -> - return done err if err - cb = done - # Pass the already-initialized component loader - graph.componentLoader = n.loader - graph.addInitial (data) -> - chai.expect(data).to.equal 'Foo' - cb() - , 'Func', 'callback' - noflo.createNetwork graph, (err, nw) -> - return done err if err - return - - describe 'with nodes containing default ports', -> - g = null - testCallback = null - c = null - cb = null - - beforeEach -> - testCallback = null - c = null - cb = null - - c = new noflo.Component - c.inPorts.add 'in', - required: true - datatype: 'string' - default: 'default-value', - c.outPorts.add 'out' - c.process (input, output) -> - output.sendDone input.get 'in' - - cb = new noflo.Component - cb.inPorts.add 'in', - required: true - datatype: 'all' - cb.process (input, output) -> - return unless input.hasData 'in' - testCallback input.getData 'in' - - g = new noflo.Graph - g.baseDir = root - g.addNode 'Def', 'Def' - g.addNode 'Cb', 'Cb' - g.addEdge 'Def', 'out', 'Cb', 'in' - - it 'should send default values to nodes without an edge', (done) -> - @timeout 60 * 1000 - testCallback = (data) -> - chai.expect(data).to.equal 'default-value' - done() - noflo.createNetwork g, (err, nw) -> - return done err if err - nw.loader.components.Def = -> c - nw.loader.components.Cb = -> cb - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - , true - - it 'should not send default values to nodes with an edge', (done) -> - @timeout 60 * 1000 - testCallback = (data) -> - chai.expect(data).to.equal 'from-edge' - done() - g.addNode 'Merge', 'Merge' - g.addEdge 'Merge', 'out', 'Def', 'in' - g.addInitial 'from-edge', 'Merge', 'in' - noflo.createNetwork g, (err, nw) -> - return done err if err - nw.loader.components.Def = -> c - nw.loader.components.Cb = -> cb - nw.loader.components.Merge = Merge - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - , true - - it 'should not send default values to nodes with IIP', (done) -> - @timeout 60 * 1000 - testCallback = (data) -> - chai.expect(data).to.equal 'from-IIP' - done() - g.addInitial 'from-IIP', 'Def', 'in' - noflo.createNetwork g, (err, nw) -> - return done err if err - nw.loader.components.Def = -> c - nw.loader.components.Cb = -> cb - nw.loader.components.Merge = Merge - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - , true - - describe 'with an existing IIP', -> - g = null - n = null - before -> - g = new noflo.Graph - g.baseDir = root - g.addNode 'Callback', 'Callback' - g.addNode 'Repeat', 'Split' - g.addEdge 'Repeat', 'out', 'Callback', 'in' - it 'should call the Callback with the original IIP value', (done) -> - @timeout 6000 - cb = (packet) -> - chai.expect(packet).to.equal 'Foo' - done() - g.addInitial cb, 'Callback', 'callback' - g.addInitial 'Foo', 'Repeat', 'in' - setTimeout -> - noflo.createNetwork g, (err, nw) -> - return done err if err - nw.loader.components.Split = Split - nw.loader.components.Merge = Merge - nw.loader.components.Callback = Callback - n = nw - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - , true - , 10 - it 'should allow removing the IIPs', (done) -> - @timeout 6000 - removed = 0 - onRemove = -> - removed++ - return if removed < 2 - chai.expect(n.initials.length).to.equal 0, 'No IIPs left' - chai.expect(n.connections.length).to.equal 1, 'Only one connection' - g.removeListener 'removeInitial', onRemove - done() - g.on 'removeInitial', onRemove - g.removeInitial 'Callback', 'callback' - g.removeInitial 'Repeat', 'in' - it 'new IIPs to replace original ones should work correctly', (done) -> - cb = (packet) -> - chai.expect(packet).to.equal 'Baz' - done() - g.addInitial cb, 'Callback', 'callback' - g.addInitial 'Baz', 'Repeat', 'in' - n.start (err) -> - return done err if err - - describe 'on stopping', -> - it 'processes should be running before the stop call', -> - chai.expect(n.started).to.be.true - chai.expect(n.processes.Repeat.component.started).to.equal true - it 'should emit the end event', (done) -> - @timeout 5000 - # Ensure we have a connection open - n.once 'end', (endTimes) -> - chai.expect(endTimes).to.be.an 'object' - done() - n.stop (err) -> - return done err if err - it 'should have called the shutdown method of each process', -> - chai.expect(n.processes.Repeat.component.started).to.equal false - - describe 'with a very large network', -> - it 'should be able to connect without errors', (done) -> - @timeout 100000 - g = new noflo.Graph - g.baseDir = root - called = 0 - for n in [0..10000] - g.addNode "Repeat#{n}", 'Split' - g.addNode 'Callback', 'Callback' - for n in [0..10000] - g.addEdge "Repeat#{n}", 'out', 'Callback', 'in' - g.addInitial -> - called++ - , 'Callback', 'callback' - for n in [0..10000] - g.addInitial n, "Repeat#{n}", 'in' - - nw = new noflo.Network g - nw.loader.listComponents (err) -> - return done err if err - nw.loader.components.Split = Split - nw.loader.components.Callback = Callback - nw.once 'end', -> - chai.expect(called).to.equal 10001 - done() - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - return - - describe 'with a faulty graph', -> - loader = null - before (done) -> - loader = new noflo.ComponentLoader root - loader.listComponents (err) -> - return done err if err - loader.components.Split = Split - done() - it 'should fail on connect with non-existing component', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Baz' - g.addNode 'Repeat2', 'Split' - g.addEdge 'Repeat1', 'out', 'Repeat2', 'in' - nw = new noflo.Network g - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'not available' - done() - it 'should fail on connect with missing target port', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - g.addNode 'Repeat2', 'Split' - g.addEdge 'Repeat1', 'out', 'Repeat2', 'foo' - nw = new noflo.Network g - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No inport' - done() - it 'should fail on connect with missing source port', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - g.addNode 'Repeat2', 'Split' - g.addEdge 'Repeat1', 'foo', 'Repeat2', 'in' - nw = new noflo.Network g - nw = new noflo.Network g - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No outport' - done() - it 'should fail on connect with missing IIP target port', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - g.addNode 'Repeat2', 'Split' - g.addEdge 'Repeat1', 'out', 'Repeat2', 'in' - g.addInitial 'hello', 'Repeat1', 'baz' - nw = new noflo.Network g - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No inport' - done() - it 'should fail on connect with node without component', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - g.addNode 'Repeat2' - g.addEdge 'Repeat1', 'out', 'Repeat2', 'in' - g.addInitial 'hello', 'Repeat1', 'in' - nw = new noflo.Network g - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No component defined' - done() - it 'should fail to add an edge to a missing outbound node', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - nw = new noflo.Network g - nw.loader = loader - nw.connect (err) -> - return done err if err - nw.addEdge { - from: - node: 'Repeat2' - port: 'out' - to: - node: 'Repeat1' - port: 'in' - }, (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No process defined for outbound node' - done() - it 'should fail to add an edge to a missing inbound node', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - nw = new noflo.Network g - nw.loader = loader - nw.connect (err) -> - return done err if err - nw.addEdge { - from: - node: 'Repeat1' - port: 'out' - to: - node: 'Repeat2' - port: 'in' - }, (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No process defined for inbound node' - done() - describe 'baseDir setting', -> - it 'should set baseDir based on given graph', -> - g = new noflo.Graph - g.baseDir = root - n = new noflo.Network g - chai.expect(n.baseDir).to.equal root - it 'should fall back to CWD if graph has no baseDir', -> - return @skip() if noflo.isBrowser() - g = new noflo.Graph - n = new noflo.Network g - chai.expect(n.baseDir).to.equal process.cwd() - it 'should set the baseDir for the component loader', -> - g = new noflo.Graph - g.baseDir = root - n = new noflo.Network g - chai.expect(n.baseDir).to.equal root - chai.expect(n.loader.baseDir).to.equal root - describe 'debug setting', -> - n = null - g = null - before (done) -> - g = new noflo.Graph - g.baseDir = root - n = new noflo.Network g - n.loader.listComponents (err, components) -> - return done err if err - n.loader.components.Split = Split - g.addNode 'A', 'Split' - g.addNode 'B', 'Split' - g.addEdge 'A', 'out', 'B', 'in' - n.connect done - it 'should initially have debug enabled', -> - chai.expect(n.getDebug()).to.equal true - it 'should have propagated debug setting to connections', -> - chai.expect(n.connections[0].debug).to.equal n.getDebug() - it 'calling setDebug with same value should be no-op', -> - n.setDebug true - chai.expect(n.getDebug()).to.equal true - chai.expect(n.connections[0].debug).to.equal n.getDebug() - it 'disabling debug should get propagated to connections', -> - n.setDebug false - chai.expect(n.getDebug()).to.equal false - chai.expect(n.connections[0].debug).to.equal n.getDebug() diff --git a/spec/LegacyNetwork.js b/spec/LegacyNetwork.js new file mode 100644 index 000000000..2f60b8e8a --- /dev/null +++ b/spec/LegacyNetwork.js @@ -0,0 +1,826 @@ +let chai; let noflo; let root; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + const path = require('path'); + root = path.resolve(__dirname, '../'); +} else { + noflo = require('noflo'); + root = 'noflo'; +} + +describe('NoFlo Legacy Network', () => { + const Split = () => new noflo.Component({ + inPorts: { + in: { datatype: 'all' }, + }, + outPorts: { + out: { datatype: 'all' }, + }, + process(input, output) { + output.sendDone({ out: input.get('in') }); + }, + }); + const Merge = () => new noflo.Component({ + inPorts: { + in: { datatype: 'all' }, + }, + outPorts: { + out: { datatype: 'all' }, + }, + process(input, output) { + output.sendDone({ out: input.get('in') }); + }, + }); + const Callback = () => new noflo.Component({ + inPorts: { + in: { datatype: 'all' }, + callback: { + datatype: 'all', + control: true, + }, + }, + process(input, output) { + // Drop brackets + if (!input.hasData('callback', 'in')) { return; } + const cb = input.getData('callback'); + const data = input.getData('in'); + cb(data); + output.done(); + }, + }); + describe('with an empty graph', () => { + let g = null; + let n = null; + before((done) => { + g = new noflo.Graph(); + g.baseDir = root; + n = new noflo.Network(g); + n.connect(done); + }); + it('should initially be marked as stopped', () => { + chai.expect(n.isStarted()).to.equal(false); + }); + it('should initially have no processes', () => { + chai.expect(n.processes).to.be.empty; + }); + it('should initially have no active processes', () => { + chai.expect(n.getActiveProcesses()).to.eql([]); + }); + it('should initially have to connections', () => { + chai.expect(n.connections).to.be.empty; + }); + it('should initially have no IIPs', () => { + chai.expect(n.initials).to.be.empty; + }); + it('should have reference to the graph', () => { + chai.expect(n.graph).to.equal(g); + }); + it('should know its baseDir', () => { + chai.expect(n.baseDir).to.equal(g.baseDir); + }); + it('should have a ComponentLoader', () => { + chai.expect(n.loader).to.be.an('object'); + }); + it('should have transmitted the baseDir to the Component Loader', () => { + chai.expect(n.loader.baseDir).to.equal(g.baseDir); + }); + it('should be able to list components', function (done) { + this.timeout(60 * 1000); + n.loader.listComponents((err, components) => { + if (err) { + done(err); + return; + } + chai.expect(components).to.be.an('object'); + done(); + }); + }); + it('should have an uptime', () => { + chai.expect(n.uptime()).to.be.at.least(0); + }); + describe('with new node', () => { + it('should contain the node', (done) => { + g.once('addNode', () => { + setTimeout(() => { + chai.expect(n.processes).not.to.be.empty; + chai.expect(n.processes.Graph).to.exist; + done(); + }, + 10); + }); + g.addNode('Graph', 'Graph', + { foo: 'Bar' }); + }); + it('should have transmitted the node metadata to the process', () => { + chai.expect(n.processes.Graph.component.metadata).to.exist; + chai.expect(n.processes.Graph.component.metadata).to.be.an('object'); + chai.expect(n.processes.Graph.component.metadata).to.eql(g.getNode('Graph').metadata); + }); + it('adding the same node again should be a no-op', (done) => { + const originalProcess = n.getNode('Graph'); + const graphNode = g.getNode('Graph'); + n.addNode(graphNode, (err, newProcess) => { + if (err) { + done(err); + return; + } + chai.expect(newProcess).to.equal(originalProcess); + done(); + }); + }); + it('should not contain the node after removal', (done) => { + g.once('removeNode', () => { + setTimeout(() => { + chai.expect(n.processes).to.be.empty; + done(); + }, + 10); + }); + g.removeNode('Graph'); + }); + it('should fail when removing the removed node again', (done) => { + n.removeNode( + { id: 'Graph' }, + (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('not found'); + done(); + }, + ); + }); + }); + describe('with new edge', () => { + before(() => { + n.loader.components.Split = Split; + g.addNode('A', 'Split'); + g.addNode('B', 'Split'); + }); + after(() => { + g.removeNode('A'); + g.removeNode('B'); + }); + it('should contain the edge', (done) => { + g.once('addEdge', () => { + setTimeout(() => { + chai.expect(n.connections).not.to.be.empty; + chai.expect(n.connections[0].from).to.eql({ + process: n.getNode('A'), + port: 'out', + index: undefined, + }); + chai.expect(n.connections[0].to).to.eql({ + process: n.getNode('B'), + port: 'in', + index: undefined, + }); + done(); + }, + 10); + }); + g.addEdge('A', 'out', 'B', 'in'); + }); + it('should not contain the edge after removal', (done) => { + g.once('removeEdge', () => { + setTimeout(() => { + chai.expect(n.connections).to.be.empty; + done(); + }, + 10); + }); + g.removeEdge('A', 'out', 'B', 'in'); + }); + }); + }); + describe('with a simple graph', () => { + let g = null; + let n = null; + let cb = null; + before(function (done) { + this.timeout(60 * 1000); + g = new noflo.Graph(); + g.baseDir = root; + g.addNode('Merge', 'Merge'); + g.addNode('Callback', 'Callback'); + g.addEdge('Merge', 'out', 'Callback', 'in'); + g.addInitial((data) => { + chai.expect(data).to.equal('Foo'); + cb(); + }, + 'Callback', 'callback'); + g.addInitial('Foo', 'Merge', 'in'); + noflo.createNetwork(g, (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Split = Split; + nw.loader.components.Merge = Merge; + nw.loader.components.Callback = Callback; + n = nw; + nw.connect((err) => { + if (err) { + done(err); + return; + } + done(); + }); + }, + true); + }); + it('should send some initials when started', (done) => { + chai.expect(n.initials).not.to.be.empty; + cb = done; + n.start((err) => { + if (err) { + done(err); + } + }); + }); + it('should contain two processes', () => { + chai.expect(n.processes).to.not.be.empty; + chai.expect(n.processes.Merge).to.exist; + chai.expect(n.processes.Merge).to.be.an('Object'); + chai.expect(n.processes.Callback).to.exist; + chai.expect(n.processes.Callback).to.be.an('Object'); + }); + it('the ports of the processes should know the node names', () => { + Object.keys(n.processes.Callback.component.inPorts.ports).forEach((name) => { + const port = n.processes.Callback.component.inPorts.ports[name]; + chai.expect(port.name).to.equal(name); + chai.expect(port.node).to.equal('Callback'); + chai.expect(port.getId()).to.equal(`Callback ${name.toUpperCase()}`); + }); + Object.keys(n.processes.Callback.component.outPorts.ports).forEach((name) => { + const port = n.processes.Callback.component.outPorts.ports[name]; + chai.expect(port.name).to.equal(name); + chai.expect(port.node).to.equal('Callback'); + chai.expect(port.getId()).to.equal(`Callback ${name.toUpperCase()}`); + }); + }); + it('should contain 1 connection between processes and 2 for IIPs', () => { + chai.expect(n.connections).to.not.be.empty; + chai.expect(n.connections.length).to.equal(3); + }); + it('should have started in debug mode', () => { + chai.expect(n.debug).to.equal(true); + chai.expect(n.getDebug()).to.equal(true); + }); + it('should emit a process-error when a component throws', (done) => { + g.removeInitial('Callback', 'callback'); + g.removeInitial('Merge', 'in'); + g.addInitial(() => { + throw new Error('got Foo'); + }, + 'Callback', 'callback'); + g.addInitial('Foo', 'Merge', 'in'); + n.once('process-error', (err) => { + chai.expect(err).to.be.an('object'); + chai.expect(err.id).to.equal('Callback'); + chai.expect(err.metadata).to.be.an('object'); + chai.expect(err.error).to.be.an('error'); + chai.expect(err.error.message).to.equal('got Foo'); + done(); + }); + n.sendInitials(); + }); + describe('with a renamed node', () => { + it('should have the process in a new location', (done) => { + g.once('renameNode', () => { + chai.expect(n.processes.Func).to.be.an('object'); + done(); + }); + g.renameNode('Callback', 'Func'); + }); + it('shouldn\'t have the process in the old location', () => { + chai.expect(n.processes.Callback).to.be.undefined; + }); + it('should fail to rename with the old name', (done) => { + n.renameNode('Callback', 'Func', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('not found'); + done(); + }); + }); + it('should have informed the ports of their new node name', () => { + Object.keys(n.processes.Func.component.inPorts.ports).forEach((name) => { + const port = n.processes.Func.component.inPorts.ports[name]; + chai.expect(port.name).to.equal(name); + chai.expect(port.node).to.equal('Func'); + chai.expect(port.getId()).to.equal(`Func ${name.toUpperCase()}`); + }); + Object.keys(n.processes.Func.component.outPorts.ports).forEach((name) => { + const port = n.processes.Func.component.outPorts.ports[name]; + chai.expect(port.name).to.equal(name); + chai.expect(port.node).to.equal('Func'); + chai.expect(port.getId()).to.equal(`Func ${name.toUpperCase()}`); + }); + }); + }); + describe('with process icon change', () => { + it('should emit an icon event', (done) => { + n.once('icon', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.id).to.equal('Func'); + chai.expect(data.icon).to.equal('flask'); + done(); + }); + n.processes.Func.component.setIcon('flask'); + }); + }); + describe('once stopped', () => { + it('should be marked as stopped', (done) => { + n.stop(() => { + chai.expect(n.isStarted()).to.equal(false); + done(); + }); + }); + }); + describe('without the delay option', () => { + it('should auto-start', (done) => { + g.removeInitial('Func', 'callback'); + noflo.graph.loadJSON(g.toJSON(), (err, graph) => { + if (err) { + done(err); + return; + } + cb = done; + // Pass the already-initialized component loader + graph.componentLoader = n.loader; + graph.addInitial((data) => { + chai.expect(data).to.equal('Foo'); + cb(); + }, + 'Func', 'callback'); + noflo.createNetwork(graph, (err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + }); + describe('with nodes containing default ports', () => { + let g = null; + let testCallback = null; + let c = null; + let cb = null; + + beforeEach(() => { + testCallback = null; + c = null; + cb = null; + + c = new noflo.Component(); + c.inPorts.add('in', { + required: true, + datatype: 'string', + default: 'default-value', + }); + c.outPorts.add('out'); + c.process((input, output) => { + output.sendDone(input.get('in')); + }); + cb = new noflo.Component(); + cb.inPorts.add('in', { + required: true, + datatype: 'all', + }); + cb.process((input) => { + if (!input.hasData('in')) { return; } + testCallback(input.getData('in')); + }); + g = new noflo.Graph(); + g.baseDir = root; + g.addNode('Def', 'Def'); + g.addNode('Cb', 'Cb'); + g.addEdge('Def', 'out', 'Cb', 'in'); + }); + it('should send default values to nodes without an edge', function (done) { + this.timeout(60 * 1000); + testCallback = function (data) { + chai.expect(data).to.equal('default-value'); + done(); + }; + noflo.createNetwork(g, (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Def = () => c; + nw.loader.components.Cb = () => cb; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }, + true); + }); + it('should not send default values to nodes with an edge', function (done) { + this.timeout(60 * 1000); + testCallback = function (data) { + chai.expect(data).to.equal('from-edge'); + done(); + }; + g.addNode('Merge', 'Merge'); + g.addEdge('Merge', 'out', 'Def', 'in'); + g.addInitial('from-edge', 'Merge', 'in'); + noflo.createNetwork(g, (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Def = () => c; + nw.loader.components.Cb = () => cb; + nw.loader.components.Merge = Merge; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }, + true); + }); + it('should not send default values to nodes with IIP', function (done) { + this.timeout(60 * 1000); + testCallback = function (data) { + chai.expect(data).to.equal('from-IIP'); + done(); + }; + g.addInitial('from-IIP', 'Def', 'in'); + noflo.createNetwork(g, (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Def = () => c; + nw.loader.components.Cb = () => cb; + nw.loader.components.Merge = Merge; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }, + true); + }); + }); + describe('with an existing IIP', () => { + let g = null; + let n = null; + before(() => { + g = new noflo.Graph(); + g.baseDir = root; + g.addNode('Callback', 'Callback'); + g.addNode('Repeat', 'Split'); + g.addEdge('Repeat', 'out', 'Callback', 'in'); + }); + it('should call the Callback with the original IIP value', function (done) { + this.timeout(6000); + const cb = function (packet) { + chai.expect(packet).to.equal('Foo'); + done(); + }; + g.addInitial(cb, 'Callback', 'callback'); + g.addInitial('Foo', 'Repeat', 'in'); + setTimeout(() => { + noflo.createNetwork(g, (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Split = Split; + nw.loader.components.Merge = Merge; + nw.loader.components.Callback = Callback; + n = nw; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }, + true); + }, + 10); + }); + it('should allow removing the IIPs', function (done) { + this.timeout(6000); + let removed = 0; + const onRemove = function () { + removed++; + if (removed < 2) { return; } + chai.expect(n.initials.length).to.equal(0, 'No IIPs left'); + chai.expect(n.connections.length).to.equal(1, 'Only one connection'); + g.removeListener('removeInitial', onRemove); + done(); + }; + g.on('removeInitial', onRemove); + g.removeInitial('Callback', 'callback'); + g.removeInitial('Repeat', 'in'); + }); + it('new IIPs to replace original ones should work correctly', (done) => { + const cb = function (packet) { + chai.expect(packet).to.equal('Baz'); + done(); + }; + g.addInitial(cb, 'Callback', 'callback'); + g.addInitial('Baz', 'Repeat', 'in'); + n.start((err) => { + if (err) { + done(err); + } + }); + }); + describe('on stopping', () => { + it('processes should be running before the stop call', () => { + chai.expect(n.started).to.be.true; + chai.expect(n.processes.Repeat.component.started).to.equal(true); + }); + it('should emit the end event', function (done) { + this.timeout(5000); + // Ensure we have a connection open + n.once('end', (endTimes) => { + chai.expect(endTimes).to.be.an('object'); + done(); + }); + n.stop((err) => { + if (err) { + done(err); + } + }); + }); + it('should have called the shutdown method of each process', () => { + chai.expect(n.processes.Repeat.component.started).to.equal(false); + }); + }); + }); + describe('with a very large network', () => { + it('should be able to connect without errors', function (done) { + let n; + this.timeout(100000); + const g = new noflo.Graph(); + g.baseDir = root; + let called = 0; + for (n = 0; n <= 10000; n++) { + g.addNode(`Repeat${n}`, 'Split'); + } + g.addNode('Callback', 'Callback'); + for (n = 0; n <= 10000; n++) { + g.addEdge(`Repeat${n}`, 'out', 'Callback', 'in'); + } + g.addInitial(() => { + called++; + }, + 'Callback', 'callback'); + for (n = 0; n <= 10000; n++) { + g.addInitial(n, `Repeat${n}`, 'in'); + } + + const nw = new noflo.Network(g); + nw.loader.listComponents((err) => { + if (err) { + done(err); + return; + } + nw.loader.components.Split = Split; + nw.loader.components.Callback = Callback; + nw.once('end', () => { + chai.expect(called).to.equal(10001); + done(); + }); + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + }); + + describe('with a faulty graph', () => { + let loader = null; + before((done) => { + loader = new noflo.ComponentLoader(root); + loader.listComponents((err) => { + if (err) { + done(err); + return; + } + loader.components.Split = Split; + done(); + }); + }); + it('should fail on connect with non-existing component', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Baz'); + g.addNode('Repeat2', 'Split'); + g.addEdge('Repeat1', 'out', 'Repeat2', 'in'); + const nw = new noflo.Network(g); + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('not available'); + done(); + }); + }); + it('should fail on connect with missing target port', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + g.addNode('Repeat2', 'Split'); + g.addEdge('Repeat1', 'out', 'Repeat2', 'foo'); + const nw = new noflo.Network(g); + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No inport'); + done(); + }); + }); + it('should fail on connect with missing source port', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + g.addNode('Repeat2', 'Split'); + g.addEdge('Repeat1', 'foo', 'Repeat2', 'in'); + let nw = new noflo.Network(g); + nw = new noflo.Network(g); + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No outport'); + done(); + }); + }); + it('should fail on connect with missing IIP target port', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + g.addNode('Repeat2', 'Split'); + g.addEdge('Repeat1', 'out', 'Repeat2', 'in'); + g.addInitial('hello', 'Repeat1', 'baz'); + const nw = new noflo.Network(g); + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No inport'); + done(); + }); + }); + it('should fail on connect with node without component', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + g.addNode('Repeat2'); + g.addEdge('Repeat1', 'out', 'Repeat2', 'in'); + g.addInitial('hello', 'Repeat1', 'in'); + const nw = new noflo.Network(g); + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No component defined'); + done(); + }); + }); + it('should fail to add an edge to a missing outbound node', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + const nw = new noflo.Network(g); + nw.loader = loader; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.addEdge({ + from: { + node: 'Repeat2', + port: 'out', + }, + to: { + node: 'Repeat1', + port: 'in', + }, + }, (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No process defined for outbound node'); + done(); + }); + }); + }); + it('should fail to add an edge to a missing inbound node', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + const nw = new noflo.Network(g); + nw.loader = loader; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.addEdge({ + from: { + node: 'Repeat1', + port: 'out', + }, + to: { + node: 'Repeat2', + port: 'in', + }, + }, (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No process defined for inbound node'); + done(); + }); + }); + }); + }); + describe('baseDir setting', () => { + it('should set baseDir based on given graph', () => { + const g = new noflo.Graph(); + g.baseDir = root; + const n = new noflo.Network(g); + chai.expect(n.baseDir).to.equal(root); + }); + it('should fall back to CWD if graph has no baseDir', function () { + if (noflo.isBrowser()) { + this.skip(); + return; + } + const g = new noflo.Graph(); + const n = new noflo.Network(g); + chai.expect(n.baseDir).to.equal(process.cwd()); + }); + it('should set the baseDir for the component loader', () => { + const g = new noflo.Graph(); + g.baseDir = root; + const n = new noflo.Network(g); + chai.expect(n.baseDir).to.equal(root); + chai.expect(n.loader.baseDir).to.equal(root); + }); + }); + describe('debug setting', () => { + let n = null; + let g = null; + before((done) => { + g = new noflo.Graph(); + g.baseDir = root; + n = new noflo.Network(g); + n.loader.listComponents((err) => { + if (err) { + done(err); + return; + } + n.loader.components.Split = Split; + g.addNode('A', 'Split'); + g.addNode('B', 'Split'); + g.addEdge('A', 'out', 'B', 'in'); + n.connect(done); + }); + }); + it('should initially have debug enabled', () => { + chai.expect(n.getDebug()).to.equal(true); + }); + it('should have propagated debug setting to connections', () => { + chai.expect(n.connections[0].debug).to.equal(n.getDebug()); + }); + it('calling setDebug with same value should be no-op', () => { + n.setDebug(true); + chai.expect(n.getDebug()).to.equal(true); + chai.expect(n.connections[0].debug).to.equal(n.getDebug()); + }); + it('disabling debug should get propagated to connections', () => { + n.setDebug(false); + chai.expect(n.getDebug()).to.equal(false); + chai.expect(n.connections[0].debug).to.equal(n.getDebug()); + }); + }); +}); diff --git a/spec/Network.coffee b/spec/Network.coffee deleted file mode 100644 index 0bc77386b..000000000 --- a/spec/Network.coffee +++ /dev/null @@ -1,753 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - path = require 'path' - root = path.resolve __dirname, '../' -else - noflo = require 'noflo' - root = 'noflo' - -describe 'NoFlo Network', -> - Split = -> - new noflo.Component - inPorts: - in: datatype: 'all' - outPorts: - out: datatype: 'all' - process: (input, output) -> - output.sendDone - out: input.get 'in' - Merge = -> - new noflo.Component - inPorts: - in: datatype: 'all' - outPorts: - out: datatype: 'all' - process: (input, output) -> - output.sendDone - out: input.get 'in' - Callback = -> - new noflo.Component - inPorts: - in: datatype: 'all' - callback: - datatype: 'all' - control: true - process: (input, output) -> - # Drop brackets - return unless input.hasData 'callback', 'in' - cb = input.getData 'callback' - data = input.getData 'in' - cb data - output.done() - - describe 'with an empty graph', -> - g = null - n = null - before (done) -> - g = new noflo.Graph - g.baseDir = root - noflo.createNetwork g, - subscribeGraph: false - delay: true - , (err, network) -> - return done err if err - n = network - n.connect done - it 'should initially be marked as stopped', -> - chai.expect(n.isStarted()).to.equal false - it 'should initially have no processes', -> - chai.expect(n.processes).to.be.empty - it 'should initially have no active processes', -> - chai.expect(n.getActiveProcesses()).to.eql [] - it 'should initially have to connections', -> - chai.expect(n.connections).to.be.empty - it 'should initially have no IIPs', -> - chai.expect(n.initials).to.be.empty - it 'should have reference to the graph', -> - chai.expect(n.graph).to.equal g - it 'should know its baseDir', -> - chai.expect(n.baseDir).to.equal g.baseDir - it 'should have a ComponentLoader', -> - chai.expect(n.loader).to.be.an 'object' - it 'should have transmitted the baseDir to the Component Loader', -> - chai.expect(n.loader.baseDir).to.equal g.baseDir - it 'should be able to list components', (done) -> - @timeout 60 * 1000 - n.loader.listComponents (err, components) -> - return done err if err - chai.expect(components).to.be.an 'object' - done() - return - it 'should have an uptime', -> - chai.expect(n.uptime()).to.be.at.least 0 - - describe 'with new node', -> - it 'should contain the node', (done) -> - n.addNode - id: 'Graph' - component: 'Graph' - metadata: - foo: 'Bar' - , done - it 'should have registered the node with the graph', -> - node = g.getNode 'Graph' - chai.expect(node).to.be.an 'object' - chai.expect(node.component).to.equal 'Graph' - it 'should have transmitted the node metadata to the process', -> - chai.expect(n.processes.Graph.component.metadata).to.exist - chai.expect(n.processes.Graph.component.metadata).to.be.an 'object' - chai.expect(n.processes.Graph.component.metadata).to.eql g.getNode('Graph').metadata - it 'adding the same node again should be a no-op', (done) -> - originalProcess = n.getNode 'Graph' - graphNode = g.getNode 'Graph' - n.addNode graphNode, (err, newProcess) -> - return done err if err - chai.expect(newProcess).to.equal originalProcess - done() - it 'should not contain the node after removal', (done) -> - n.removeNode - id: 'Graph' - , (err) -> - return done err if err - chai.expect(n.processes).to.be.empty - done() - it 'should have removed the node from the graph', -> - node = g.getNode 'graph' - chai.expect(node).to.be.a 'null' - it 'should fail when removing the removed node again', (done) -> - n.removeNode - id: 'Graph' - , (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'not found' - done() - describe 'with new edge', -> - before (done) -> - n.loader.components.Split = Split - n.addNode - id: 'A' - component: 'Split' - , (err) -> - return done err if err - n.addNode - id: 'B' - component: 'Split' - , done - after (done) -> - n.removeNode - id: 'A' - , (err) -> - return done err if err - n.removeNode - id: 'B' - , done - it 'should contain the edge', (done) -> - n.addEdge - from: - node: 'A' - port: 'out' - to: - node: 'B' - port: 'in' - , (err) -> - return done err if err - chai.expect(n.connections).not.to.be.empty - chai.expect(n.connections[0].from).to.eql - process: n.getNode 'A' - port: 'out' - index: undefined - chai.expect(n.connections[0].to).to.eql - process: n.getNode 'B' - port: 'in' - index: undefined - done() - it 'should have registered the edge with the graph', -> - edge = g.getEdge 'A', 'out', 'B', 'in' - chai.expect(edge).to.not.be.a 'null' - it 'should not contain the edge after removal', (done) -> - n.removeEdge - from: - node: 'A' - port: 'out' - to: - node: 'B' - port: 'in' - , (err) -> - return done err if err - chai.expect(n.connections).to.be.empty - done() - it 'should have removed the edge from the graph', -> - edge = g.getEdge 'A', 'out', 'B', 'in' - chai.expect(edge).to.be.a 'null' - - describe 'with a simple graph', -> - g = null - n = null - cb = null - before (done) -> - @timeout 60 * 1000 - g = new noflo.Graph - g.baseDir = root - g.addNode 'Merge', 'Merge' - g.addNode 'Callback', 'Callback' - g.addEdge 'Merge', 'out', 'Callback', 'in' - g.addInitial (data) -> - chai.expect(data).to.equal 'Foo' - cb() - , 'Callback', 'callback' - g.addInitial 'Foo', 'Merge', 'in' - noflo.createNetwork g, - subscribeGraph: false - delay: true - , (err, nw) -> - return done err if err - nw.loader.components.Split = Split - nw.loader.components.Merge = Merge - nw.loader.components.Callback = Callback - n = nw - nw.connect done - - it 'should send some initials when started', (done) -> - chai.expect(n.initials).not.to.be.empty - cb = done - n.start (err) -> - return done err if err - - it 'should contain two processes', -> - chai.expect(n.processes).to.not.be.empty - chai.expect(n.processes.Merge).to.exist - chai.expect(n.processes.Merge).to.be.an 'Object' - chai.expect(n.processes.Callback).to.exist - chai.expect(n.processes.Callback).to.be.an 'Object' - it 'the ports of the processes should know the node names', -> - for name, port of n.processes.Callback.component.inPorts.ports - chai.expect(port.name).to.equal name - chai.expect(port.node).to.equal 'Callback' - chai.expect(port.getId()).to.equal "Callback #{name.toUpperCase()}" - for name, port of n.processes.Callback.component.outPorts.ports - chai.expect(port.name).to.equal name - chai.expect(port.node).to.equal 'Callback' - chai.expect(port.getId()).to.equal "Callback #{name.toUpperCase()}" - - it 'should contain 1 connection between processes and 2 for IIPs', -> - chai.expect(n.connections).to.not.be.empty - chai.expect(n.connections.length).to.equal 3 - - it 'should have started in debug mode', -> - chai.expect(n.debug).to.equal true - chai.expect(n.getDebug()).to.equal true - - it 'should emit a process-error when a component throws', (done) -> - n.removeInitial - to: - node: 'Callback' - port: 'callback' - , (err) -> - return done err if err - n.removeInitial - to: - node: 'Merge' - port: 'in' - , (err) -> - return done err if err - n.addInitial - from: - data: (data) -> throw new Error 'got Foo' - to: - node: 'Callback' - port: 'callback' - , (err) -> - return done err if err - n.addInitial - from: - data: 'Foo' - to: - node: 'Merge' - port: 'in' - , (err) -> - return done err if err - n.once 'process-error', (err) -> - chai.expect(err).to.be.an 'object' - chai.expect(err.id).to.equal 'Callback' - chai.expect(err.metadata).to.be.an 'object' - chai.expect(err.error).to.be.an 'error' - chai.expect(err.error.message).to.equal 'got Foo' - done() - n.sendInitials (err) -> - return done err if err - - describe 'with a renamed node', -> - it 'should have the process in a new location', (done) -> - n.renameNode 'Callback', 'Func', (err) -> - return done err if err - chai.expect(n.processes.Func).to.be.an 'object' - done() - it 'shouldn\'t have the process in the old location', -> - chai.expect(n.processes.Callback).to.be.undefined - it 'should have updated the name in the graph', -> - chai.expect(n.getNode('Callback')).to.not.exist - chai.expect(n.getNode('Func')).to.exist - it 'should fail to rename with the old name', (done) -> - n.renameNode 'Callback', 'Func', (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'not found' - done() - it 'should have informed the ports of their new node name', -> - for name, port of n.processes.Func.component.inPorts.ports - chai.expect(port.name).to.equal name - chai.expect(port.node).to.equal 'Func' - chai.expect(port.getId()).to.equal "Func #{name.toUpperCase()}" - for name, port of n.processes.Func.component.outPorts.ports - chai.expect(port.name).to.equal name - chai.expect(port.node).to.equal 'Func' - chai.expect(port.getId()).to.equal "Func #{name.toUpperCase()}" - - describe 'with process icon change', -> - it 'should emit an icon event', (done) -> - n.once 'icon', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.id).to.equal 'Func' - chai.expect(data.icon).to.equal 'flask' - done() - n.processes.Func.component.setIcon 'flask' - - describe 'once stopped', -> - it 'should be marked as stopped', (done) -> - n.stop -> - chai.expect(n.isStarted()).to.equal false - done() - - describe 'without the delay option', -> - it 'should auto-start', (done) -> - g.removeInitial 'Func', 'callback' - newGraph = noflo.graph.loadJSON g.toJSON(), (err, graph) -> - return done err if err - # Pass the already-initialized component loader - graph.componentLoader = n.loader - graph.addInitial (data) -> - chai.expect(data).to.equal 'Foo' - done() - , 'Func', 'callback' - noflo.createNetwork graph, - subscribeGraph: false - delay: false - , (err, nw) -> - return done err if err - return - - describe 'with nodes containing default ports', -> - g = null - testCallback = null - c = null - cb = null - - beforeEach -> - testCallback = null - c = null - cb = null - - c = new noflo.Component - c.inPorts.add 'in', - required: true - datatype: 'string' - default: 'default-value', - c.outPorts.add 'out' - c.process (input, output) -> - output.sendDone input.get 'in' - - cb = new noflo.Component - cb.inPorts.add 'in', - required: true - datatype: 'all' - cb.process (input, output) -> - return unless input.hasData 'in' - testCallback input.getData 'in' - - g = new noflo.Graph - g.baseDir = root - g.addNode 'Def', 'Def' - g.addNode 'Cb', 'Cb' - g.addEdge 'Def', 'out', 'Cb', 'in' - - it 'should send default values to nodes without an edge', (done) -> - @timeout 60 * 1000 - testCallback = (data) -> - chai.expect(data).to.equal 'default-value' - done() - noflo.createNetwork g, - subscribeGraph: false - delay: true - , (err, nw) -> - return done err if err - nw.loader.components.Def = -> c - nw.loader.components.Cb = -> cb - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - - it 'should not send default values to nodes with an edge', (done) -> - @timeout 60 * 1000 - testCallback = (data) -> - chai.expect(data).to.equal 'from-edge' - done() - g.addNode 'Merge', 'Merge' - g.addEdge 'Merge', 'out', 'Def', 'in' - g.addInitial 'from-edge', 'Merge', 'in' - noflo.createNetwork g, - subscribeGraph: false - delay: true - , (err, nw) -> - return done err if err - nw.loader.components.Def = -> c - nw.loader.components.Cb = -> cb - nw.loader.components.Merge = Merge - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - - it 'should not send default values to nodes with IIP', (done) -> - @timeout 60 * 1000 - testCallback = (data) -> - chai.expect(data).to.equal 'from-IIP' - done() - g.addInitial 'from-IIP', 'Def', 'in' - noflo.createNetwork g, - subscribeGraph: false - delay: true - , (err, nw) -> - return done err if err - nw.loader.components.Def = -> c - nw.loader.components.Cb = -> cb - nw.loader.components.Merge = Merge - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - - describe 'with an existing IIP', -> - g = null - n = null - before -> - g = new noflo.Graph - g.baseDir = root - g.addNode 'Callback', 'Callback' - g.addNode 'Repeat', 'Split' - g.addEdge 'Repeat', 'out', 'Callback', 'in' - it 'should call the Callback with the original IIP value', (done) -> - @timeout 6000 - cb = (packet) -> - chai.expect(packet).to.equal 'Foo' - done() - g.addInitial cb, 'Callback', 'callback' - g.addInitial 'Foo', 'Repeat', 'in' - setTimeout -> - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader.components.Split = Split - nw.loader.components.Merge = Merge - nw.loader.components.Callback = Callback - n = nw - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - , 10 - it 'should allow removing the IIPs', (done) -> - n.removeInitial - to: - node: 'Callback' - port: 'callback' - , (err) -> - return done err if err - n.removeInitial - to: - node: 'Repeat' - port: 'in' - , (err) -> - return done err if err - chai.expect(n.initials.length).to.equal 0, 'No IIPs left' - chai.expect(n.connections.length).to.equal 1, 'Only one connection' - done() - it 'new IIPs to replace original ones should work correctly', (done) -> - cb = (packet) -> - chai.expect(packet).to.equal 'Baz' - done() - n.addInitial - from: - data: cb - to: - node: 'Callback' - port: 'callback' - , (err) -> - return done err if err - n.addInitial - from: - data: 'Baz' - to: - node: 'Repeat' - port: 'in' - , (err) -> - return done err if err - n.start (err) -> - return done err if err - - describe 'on stopping', -> - it 'processes should be running before the stop call', -> - chai.expect(n.started).to.be.true - chai.expect(n.processes.Repeat.component.started).to.equal true - it 'should emit the end event', (done) -> - @timeout 5000 - # Ensure we have a connection open - n.once 'end', (endTimes) -> - chai.expect(endTimes).to.be.an 'object' - done() - n.stop (err) -> - return done err if err - it 'should have called the shutdown method of each process', -> - chai.expect(n.processes.Repeat.component.started).to.equal false - - describe 'with a very large network', -> - it 'should be able to connect without errors', (done) -> - @timeout 100000 - g = new noflo.Graph - g.baseDir = root - called = 0 - for n in [0..10000] - g.addNode "Repeat#{n}", 'Split' - g.addNode 'Callback', 'Callback' - for n in [0..10000] - g.addEdge "Repeat#{n}", 'out', 'Callback', 'in' - g.addInitial -> - called++ - , 'Callback', 'callback' - for n in [0..10000] - g.addInitial n, "Repeat#{n}", 'in' - - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader.components.Split = Split - nw.loader.components.Callback = Callback - nw.once 'end', -> - chai.expect(called).to.equal 10001 - done() - nw.connect (err) -> - return done err if err - nw.start (err) -> - return done err if err - return - - describe 'with a faulty graph', -> - loader = null - before (done) -> - loader = new noflo.ComponentLoader root - loader.listComponents (err) -> - return done err if err - loader.components.Split = Split - done() - it 'should fail on connect with non-existing component', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Baz' - g.addNode 'Repeat2', 'Split' - g.addEdge 'Repeat1', 'out', 'Repeat2', 'in' - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'not available' - done() - it 'should fail on connect with missing target port', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - g.addNode 'Repeat2', 'Split' - g.addEdge 'Repeat1', 'out', 'Repeat2', 'foo' - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No inport' - done() - it 'should fail on connect with missing source port', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - g.addNode 'Repeat2', 'Split' - g.addEdge 'Repeat1', 'foo', 'Repeat2', 'in' - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No outport' - done() - it 'should fail on connect with missing IIP target port', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - g.addNode 'Repeat2', 'Split' - g.addEdge 'Repeat1', 'out', 'Repeat2', 'in' - g.addInitial 'hello', 'Repeat1', 'baz' - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No inport' - done() - it 'should fail on connect with node without component', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - g.addNode 'Repeat2' - g.addEdge 'Repeat1', 'out', 'Repeat2', 'in' - g.addInitial 'hello', 'Repeat1', 'in' - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader = loader - nw.connect (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No component defined' - done() - it 'should fail to add an edge to a missing outbound node', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader = loader - nw.connect (err) -> - return done err if err - nw.addEdge { - from: - node: 'Repeat2' - port: 'out' - to: - node: 'Repeat1' - port: 'in' - }, (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No process defined for outbound node' - done() - it 'should fail to add an edge to a missing inbound node', (done) -> - g = new noflo.Graph - g.addNode 'Repeat1', 'Split' - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - nw.loader = loader - nw.connect (err) -> - return done err if err - nw.addEdge { - from: - node: 'Repeat1' - port: 'out' - to: - node: 'Repeat2' - port: 'in' - }, (err) -> - chai.expect(err).to.be.an 'error' - chai.expect(err.message).to.contain 'No process defined for inbound node' - done() - describe 'baseDir setting', -> - it 'should set baseDir based on given graph', (done) -> - g = new noflo.Graph - g.baseDir = root - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - chai.expect(nw.baseDir).to.equal root - done() - it 'should fall back to CWD if graph has no baseDir', (done) -> - return @skip() if noflo.isBrowser() - g = new noflo.Graph - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - chai.expect(nw.baseDir).to.equal process.cwd() - done() - it 'should set the baseDir for the component loader', (done) -> - g = new noflo.Graph - g.baseDir = root - noflo.createNetwork g, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - chai.expect(nw.baseDir).to.equal root - chai.expect(nw.loader.baseDir).to.equal root - done() - describe 'debug setting', -> - n = null - g = null - before (done) -> - g = new noflo.Graph - g.baseDir = root - noflo.createNetwork g, - subscribeGraph: false - default: true - , (err, network) -> - return done err if err - n = network - n.loader.components.Split = Split - n.addNode - id: 'A' - component: 'Split' - , (err) -> - return done err if err - n.addNode - id: 'B' - component: 'Split' - , (err) -> - return done err if err - n.addEdge - from: - node: 'A' - port: 'out' - to: - node: 'B' - port: 'in' - , (err) -> - return done err if err - n.connect done - it 'should initially have debug enabled', -> - chai.expect(n.getDebug()).to.equal true - it 'should have propagated debug setting to connections', -> - chai.expect(n.connections[0].debug).to.equal n.getDebug() - it 'calling setDebug with same value should be no-op', -> - n.setDebug true - chai.expect(n.getDebug()).to.equal true - chai.expect(n.connections[0].debug).to.equal n.getDebug() - it 'disabling debug should get propagated to connections', -> - n.setDebug false - chai.expect(n.getDebug()).to.equal false - chai.expect(n.connections[0].debug).to.equal n.getDebug() diff --git a/spec/Network.js b/spec/Network.js new file mode 100644 index 000000000..a27d68a6e --- /dev/null +++ b/spec/Network.js @@ -0,0 +1,1141 @@ +let chai; let noflo; let root; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + const path = require('path'); + root = path.resolve(__dirname, '../'); +} else { + noflo = require('noflo'); + root = 'noflo'; +} + +describe('NoFlo Network', () => { + const Split = () => new noflo.Component({ + inPorts: { + in: { datatype: 'all' }, + }, + outPorts: { + out: { datatype: 'all' }, + }, + process(input, output) { + output.sendDone({ out: input.get('in') }); + }, + }); + const Merge = () => new noflo.Component({ + inPorts: { + in: { datatype: 'all' }, + }, + outPorts: { + out: { datatype: 'all' }, + }, + process(input, output) { + output.sendDone({ out: input.get('in') }); + }, + }); + const Callback = () => new noflo.Component({ + inPorts: { + in: { datatype: 'all' }, + callback: { + datatype: 'all', + control: true, + }, + }, + process(input, output) { + // Drop brackets + if (!input.hasData('callback', 'in')) { return; } + const cb = input.getData('callback'); + const data = input.getData('in'); + cb(data); + output.done(); + }, + }); + describe('with an empty graph', () => { + let g = null; + let n = null; + before((done) => { + g = new noflo.Graph(); + g.baseDir = root; + noflo.createNetwork(g, { + subscribeGraph: false, + delay: true, + }, + (err, network) => { + if (err) { + done(err); + return; + } + n = network; + n.connect(done); + }); + }); + it('should initially be marked as stopped', () => { + chai.expect(n.isStarted()).to.equal(false); + }); + it('should initially have no processes', () => { + chai.expect(n.processes).to.be.empty; + }); + it('should initially have no active processes', () => { + chai.expect(n.getActiveProcesses()).to.eql([]); + }); + it('should initially have to connections', () => { + chai.expect(n.connections).to.be.empty; + }); + it('should initially have no IIPs', () => { + chai.expect(n.initials).to.be.empty; + }); + it('should have reference to the graph', () => { + chai.expect(n.graph).to.equal(g); + }); + it('should know its baseDir', () => { + chai.expect(n.baseDir).to.equal(g.baseDir); + }); + it('should have a ComponentLoader', () => { + chai.expect(n.loader).to.be.an('object'); + }); + it('should have transmitted the baseDir to the Component Loader', () => { + chai.expect(n.loader.baseDir).to.equal(g.baseDir); + }); + it('should be able to list components', function (done) { + this.timeout(60 * 1000); + n.loader.listComponents((err, components) => { + if (err) { + done(err); + return; + } + chai.expect(components).to.be.an('object'); + done(); + }); + }); + it('should have an uptime', () => { + chai.expect(n.uptime()).to.be.at.least(0); + }); + describe('with new node', () => { + it('should contain the node', (done) => { + n.addNode({ + id: 'Graph', + component: 'Graph', + metadata: { + foo: 'Bar', + }, + }, + done); + }); + it('should have registered the node with the graph', () => { + const node = g.getNode('Graph'); + chai.expect(node).to.be.an('object'); + chai.expect(node.component).to.equal('Graph'); + }); + it('should have transmitted the node metadata to the process', () => { + chai.expect(n.processes.Graph.component.metadata).to.exist; + chai.expect(n.processes.Graph.component.metadata).to.be.an('object'); + chai.expect(n.processes.Graph.component.metadata).to.eql(g.getNode('Graph').metadata); + }); + it('adding the same node again should be a no-op', (done) => { + const originalProcess = n.getNode('Graph'); + const graphNode = g.getNode('Graph'); + n.addNode(graphNode, (err, newProcess) => { + if (err) { + done(err); + return; + } + chai.expect(newProcess).to.equal(originalProcess); + done(); + }); + }); + it('should not contain the node after removal', (done) => { + n.removeNode( + { id: 'Graph' }, + (err) => { + if (err) { + done(err); + return; + } + chai.expect(n.processes).to.be.empty; + done(); + }, + ); + }); + it('should have removed the node from the graph', () => { + const node = g.getNode('graph'); + chai.expect(node).to.be.a('null'); + }); + it('should fail when removing the removed node again', (done) => { + n.removeNode( + { id: 'Graph' }, + (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('not found'); + done(); + }, + ); + }); + }); + describe('with new edge', () => { + before((done) => { + n.loader.components.Split = Split; + n.addNode({ + id: 'A', + component: 'Split', + }, + (err) => { + if (err) { + done(err); + return; + } + n.addNode({ + id: 'B', + component: 'Split', + }, + done); + }); + }); + after((done) => { + n.removeNode( + { id: 'A' }, + (err) => { + if (err) { + done(err); + return; + } + n.removeNode( + { id: 'B' }, + done, + ); + }, + ); + }); + it('should contain the edge', (done) => { + n.addEdge({ + from: { + node: 'A', + port: 'out', + }, + to: { + node: 'B', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + chai.expect(n.connections).not.to.be.empty; + chai.expect(n.connections[0].from).to.eql({ + process: n.getNode('A'), + port: 'out', + index: undefined, + }); + chai.expect(n.connections[0].to).to.eql({ + process: n.getNode('B'), + port: 'in', + index: undefined, + }); + done(); + }); + }); + it('should have registered the edge with the graph', () => { + const edge = g.getEdge('A', 'out', 'B', 'in'); + chai.expect(edge).to.not.be.a('null'); + }); + it('should not contain the edge after removal', (done) => { + n.removeEdge({ + from: { + node: 'A', + port: 'out', + }, + to: { + node: 'B', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + chai.expect(n.connections).to.be.empty; + done(); + }); + }); + it('should have removed the edge from the graph', () => { + const edge = g.getEdge('A', 'out', 'B', 'in'); + chai.expect(edge).to.be.a('null'); + }); + }); + }); + describe('with a simple graph', () => { + let g = null; + let n = null; + let cb = null; + before(function (done) { + this.timeout(60 * 1000); + g = new noflo.Graph(); + g.baseDir = root; + g.addNode('Merge', 'Merge'); + g.addNode('Callback', 'Callback'); + g.addEdge('Merge', 'out', 'Callback', 'in'); + g.addInitial((data) => { + chai.expect(data).to.equal('Foo'); + cb(); + }, + 'Callback', 'callback'); + g.addInitial('Foo', 'Merge', 'in'); + noflo.createNetwork(g, { + subscribeGraph: false, + delay: true, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Split = Split; + nw.loader.components.Merge = Merge; + nw.loader.components.Callback = Callback; + n = nw; + nw.connect(done); + }); + }); + it('should send some initials when started', (done) => { + chai.expect(n.initials).not.to.be.empty; + cb = done; + n.start((err) => { + if (err) { + done(err); + } + }); + }); + it('should contain two processes', () => { + chai.expect(n.processes).to.not.be.empty; + chai.expect(n.processes.Merge).to.exist; + chai.expect(n.processes.Merge).to.be.an('Object'); + chai.expect(n.processes.Callback).to.exist; + chai.expect(n.processes.Callback).to.be.an('Object'); + }); + it('the ports of the processes should know the node names', () => { + Object.keys(n.processes.Callback.component.inPorts.ports).forEach((name) => { + const port = n.processes.Callback.component.inPorts.ports[name]; + chai.expect(port.name).to.equal(name); + chai.expect(port.node).to.equal('Callback'); + chai.expect(port.getId()).to.equal(`Callback ${name.toUpperCase()}`); + }); + Object.keys(n.processes.Callback.component.outPorts.ports).forEach((name) => { + const port = n.processes.Callback.component.outPorts.ports[name]; + chai.expect(port.name).to.equal(name); + chai.expect(port.node).to.equal('Callback'); + chai.expect(port.getId()).to.equal(`Callback ${name.toUpperCase()}`); + }); + }); + it('should contain 1 connection between processes and 2 for IIPs', () => { + chai.expect(n.connections).to.not.be.empty; + chai.expect(n.connections.length).to.equal(3); + }); + it('should have started in debug mode', () => { + chai.expect(n.debug).to.equal(true); + chai.expect(n.getDebug()).to.equal(true); + }); + it('should emit a process-error when a component throws', (done) => { + n.removeInitial({ + to: { + node: 'Callback', + port: 'callback', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + n.removeInitial({ + to: { + node: 'Merge', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + n.addInitial({ + from: { + data() { throw new Error('got Foo'); }, + }, + to: { + node: 'Callback', + port: 'callback', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + n.addInitial({ + from: { + data: 'Foo', + }, + to: { + node: 'Merge', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + n.once('process-error', (err) => { + chai.expect(err).to.be.an('object'); + chai.expect(err.id).to.equal('Callback'); + chai.expect(err.metadata).to.be.an('object'); + chai.expect(err.error).to.be.an('error'); + chai.expect(err.error.message).to.equal('got Foo'); + done(); + }); + n.sendInitials((err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + }); + }); + describe('with a renamed node', () => { + it('should have the process in a new location', (done) => { + n.renameNode('Callback', 'Func', (err) => { + if (err) { + done(err); + return; + } + chai.expect(n.processes.Func).to.be.an('object'); + done(); + }); + }); + it('shouldn\'t have the process in the old location', () => { + chai.expect(n.processes.Callback).to.be.undefined; + }); + it('should have updated the name in the graph', () => { + chai.expect(n.getNode('Callback')).to.not.exist; + chai.expect(n.getNode('Func')).to.exist; + }); + it('should fail to rename with the old name', (done) => { + n.renameNode('Callback', 'Func', (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('not found'); + done(); + }); + }); + it('should have informed the ports of their new node name', () => { + Object.keys(n.processes.Func.component.inPorts.ports).forEach((name) => { + const port = n.processes.Func.component.inPorts.ports[name]; + chai.expect(port.name).to.equal(name); + chai.expect(port.node).to.equal('Func'); + chai.expect(port.getId()).to.equal(`Func ${name.toUpperCase()}`); + }); + Object.keys(n.processes.Func.component.outPorts.ports).forEach((name) => { + const port = n.processes.Func.component.outPorts.ports[name]; + chai.expect(port.name).to.equal(name); + chai.expect(port.node).to.equal('Func'); + chai.expect(port.getId()).to.equal(`Func ${name.toUpperCase()}`); + }); + }); + }); + describe('with process icon change', () => { + it('should emit an icon event', (done) => { + n.once('icon', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.id).to.equal('Func'); + chai.expect(data.icon).to.equal('flask'); + done(); + }); + n.processes.Func.component.setIcon('flask'); + }); + }); + describe('once stopped', () => { + it('should be marked as stopped', (done) => { + n.stop(() => { + chai.expect(n.isStarted()).to.equal(false); + done(); + }); + }); + }); + describe('without the delay option', () => { + it('should auto-start', (done) => { + g.removeInitial('Func', 'callback'); + noflo.graph.loadJSON(g.toJSON(), (err, graph) => { + if (err) { + done(err); + return; + } + // Pass the already-initialized component loader + graph.componentLoader = n.loader; + graph.addInitial((data) => { + chai.expect(data).to.equal('Foo'); + done(); + }, + 'Func', 'callback'); + noflo.createNetwork(graph, { + subscribeGraph: false, + delay: false, + }, + (err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + }); + describe('with nodes containing default ports', () => { + let g = null; + let testCallback = null; + let c = null; + let cb = null; + + beforeEach(() => { + testCallback = null; + c = null; + cb = null; + + c = new noflo.Component(); + c.inPorts.add('in', { + required: true, + datatype: 'string', + default: 'default-value', + }); + c.outPorts.add('out'); + c.process((input, output) => { + output.sendDone(input.get('in')); + }); + cb = new noflo.Component(); + cb.inPorts.add('in', { + required: true, + datatype: 'all', + }); + cb.process((input) => { + if (!input.hasData('in')) { return; } + testCallback(input.getData('in')); + }); + g = new noflo.Graph(); + g.baseDir = root; + g.addNode('Def', 'Def'); + g.addNode('Cb', 'Cb'); + g.addEdge('Def', 'out', 'Cb', 'in'); + }); + it('should send default values to nodes without an edge', function (done) { + this.timeout(60 * 1000); + testCallback = function (data) { + chai.expect(data).to.equal('default-value'); + done(); + }; + noflo.createNetwork(g, { + subscribeGraph: false, + delay: true, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Def = () => c; + nw.loader.components.Cb = () => cb; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + it('should not send default values to nodes with an edge', function (done) { + this.timeout(60 * 1000); + testCallback = function (data) { + chai.expect(data).to.equal('from-edge'); + done(); + }; + g.addNode('Merge', 'Merge'); + g.addEdge('Merge', 'out', 'Def', 'in'); + g.addInitial('from-edge', 'Merge', 'in'); + noflo.createNetwork(g, { + subscribeGraph: false, + delay: true, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Def = () => c; + nw.loader.components.Cb = () => cb; + nw.loader.components.Merge = Merge; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + it('should not send default values to nodes with IIP', function (done) { + this.timeout(60 * 1000); + testCallback = function (data) { + chai.expect(data).to.equal('from-IIP'); + done(); + }; + g.addInitial('from-IIP', 'Def', 'in'); + noflo.createNetwork(g, { + subscribeGraph: false, + delay: true, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Def = () => c; + nw.loader.components.Cb = () => cb; + nw.loader.components.Merge = Merge; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + }); + describe('with an existing IIP', () => { + let g = null; + let n = null; + before(() => { + g = new noflo.Graph(); + g.baseDir = root; + g.addNode('Callback', 'Callback'); + g.addNode('Repeat', 'Split'); + g.addEdge('Repeat', 'out', 'Callback', 'in'); + }); + it('should call the Callback with the original IIP value', function (done) { + this.timeout(6000); + const cb = function (packet) { + chai.expect(packet).to.equal('Foo'); + done(); + }; + g.addInitial(cb, 'Callback', 'callback'); + g.addInitial('Foo', 'Repeat', 'in'); + setTimeout(() => { + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Split = Split; + nw.loader.components.Merge = Merge; + nw.loader.components.Callback = Callback; + n = nw; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + }, + 10); + }); + it('should allow removing the IIPs', (done) => { + n.removeInitial({ + to: { + node: 'Callback', + port: 'callback', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + n.removeInitial({ + to: { + node: 'Repeat', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + chai.expect(n.initials.length).to.equal(0, 'No IIPs left'); + chai.expect(n.connections.length).to.equal(1, 'Only one connection'); + done(); + }); + }); + }); + it('new IIPs to replace original ones should work correctly', (done) => { + const cb = function (packet) { + chai.expect(packet).to.equal('Baz'); + done(); + }; + n.addInitial({ + from: { + data: cb, + }, + to: { + node: 'Callback', + port: 'callback', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + n.addInitial({ + from: { + data: 'Baz', + }, + to: { + node: 'Repeat', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + n.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + describe('on stopping', () => { + it('processes should be running before the stop call', () => { + chai.expect(n.started).to.be.true; + chai.expect(n.processes.Repeat.component.started).to.equal(true); + }); + it('should emit the end event', function (done) { + this.timeout(5000); + // Ensure we have a connection open + n.once('end', (endTimes) => { + chai.expect(endTimes).to.be.an('object'); + done(); + }); + n.stop((err) => { + if (err) { + done(err); + } + }); + }); + it('should have called the shutdown method of each process', () => { + chai.expect(n.processes.Repeat.component.started).to.equal(false); + }); + }); + }); + describe('with a very large network', () => { + it('should be able to connect without errors', function (done) { + let n; + this.timeout(100000); + const g = new noflo.Graph(); + g.baseDir = root; + let called = 0; + for (n = 0; n <= 10000; n++) { + g.addNode(`Repeat${n}`, 'Split'); + } + g.addNode('Callback', 'Callback'); + for (n = 0; n <= 10000; n++) { + g.addEdge(`Repeat${n}`, 'out', 'Callback', 'in'); + } + g.addInitial(() => { + called++; + }, + 'Callback', 'callback'); + for (n = 0; n <= 10000; n++) { + g.addInitial(n, `Repeat${n}`, 'in'); + } + + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader.components.Split = Split; + nw.loader.components.Callback = Callback; + nw.once('end', () => { + chai.expect(called).to.equal(10001); + done(); + }); + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + }); + }); + describe('with a faulty graph', () => { + let loader = null; + before((done) => { + loader = new noflo.ComponentLoader(root); + loader.listComponents((err) => { + if (err) { + done(err); + return; + } + loader.components.Split = Split; + done(); + }); + }); + it('should fail on connect with non-existing component', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Baz'); + g.addNode('Repeat2', 'Split'); + g.addEdge('Repeat1', 'out', 'Repeat2', 'in'); + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('not available'); + done(); + }); + }); + }); + it('should fail on connect with missing target port', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + g.addNode('Repeat2', 'Split'); + g.addEdge('Repeat1', 'out', 'Repeat2', 'foo'); + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No inport'); + done(); + }); + }); + }); + it('should fail on connect with missing source port', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + g.addNode('Repeat2', 'Split'); + g.addEdge('Repeat1', 'foo', 'Repeat2', 'in'); + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No outport'); + done(); + }); + }); + }); + it('should fail on connect with missing IIP target port', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + g.addNode('Repeat2', 'Split'); + g.addEdge('Repeat1', 'out', 'Repeat2', 'in'); + g.addInitial('hello', 'Repeat1', 'baz'); + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No inport'); + done(); + }); + }); + }); + it('should fail on connect with node without component', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + g.addNode('Repeat2'); + g.addEdge('Repeat1', 'out', 'Repeat2', 'in'); + g.addInitial('hello', 'Repeat1', 'in'); + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader = loader; + nw.connect((err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No component defined'); + done(); + }); + }); + }); + it('should fail to add an edge to a missing outbound node', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader = loader; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.addEdge({ + from: { + node: 'Repeat2', + port: 'out', + }, + to: { + node: 'Repeat1', + port: 'in', + }, + }, (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No process defined for outbound node'); + done(); + }); + }); + }); + }); + it('should fail to add an edge to a missing inbound node', (done) => { + const g = new noflo.Graph(); + g.addNode('Repeat1', 'Split'); + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + nw.loader = loader; + nw.connect((err) => { + if (err) { + done(err); + return; + } + nw.addEdge({ + from: { + node: 'Repeat1', + port: 'out', + }, + to: { + node: 'Repeat2', + port: 'in', + }, + }, (err) => { + chai.expect(err).to.be.an('error'); + chai.expect(err.message).to.contain('No process defined for inbound node'); + done(); + }); + }); + }); + }); + }); + describe('baseDir setting', () => { + it('should set baseDir based on given graph', (done) => { + const g = new noflo.Graph(); + g.baseDir = root; + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + chai.expect(nw.baseDir).to.equal(root); + done(); + }); + }); + it('should fall back to CWD if graph has no baseDir', function (done) { + if (noflo.isBrowser()) { + this.skip(); + return; + } + const g = new noflo.Graph(); + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + chai.expect(nw.baseDir).to.equal(process.cwd()); + done(); + }); + }); + it('should set the baseDir for the component loader', (done) => { + const g = new noflo.Graph(); + g.baseDir = root; + noflo.createNetwork(g, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + chai.expect(nw.baseDir).to.equal(root); + chai.expect(nw.loader.baseDir).to.equal(root); + done(); + }); + }); + }); + describe('debug setting', () => { + let n = null; + let g = null; + before((done) => { + g = new noflo.Graph(); + g.baseDir = root; + noflo.createNetwork(g, { + subscribeGraph: false, + default: true, + }, + (err, network) => { + if (err) { + done(err); + return; + } + n = network; + n.loader.components.Split = Split; + n.addNode({ + id: 'A', + component: 'Split', + }, + (err) => { + if (err) { + done(err); + return; + } + n.addNode({ + id: 'B', + component: 'Split', + }, + (err) => { + if (err) { + done(err); + return; + } + n.addEdge({ + from: { + node: 'A', + port: 'out', + }, + to: { + node: 'B', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + n.connect(done); + }); + }); + }); + }); + }); + it('should initially have debug enabled', () => { + chai.expect(n.getDebug()).to.equal(true); + }); + it('should have propagated debug setting to connections', () => { + chai.expect(n.connections[0].debug).to.equal(n.getDebug()); + }); + it('calling setDebug with same value should be no-op', () => { + n.setDebug(true); + chai.expect(n.getDebug()).to.equal(true); + chai.expect(n.connections[0].debug).to.equal(n.getDebug()); + }); + it('disabling debug should get propagated to connections', () => { + n.setDebug(false); + chai.expect(n.getDebug()).to.equal(false); + chai.expect(n.connections[0].debug).to.equal(n.getDebug()); + }); + }); +}); diff --git a/spec/NetworkLifecycle.coffee b/spec/NetworkLifecycle.coffee deleted file mode 100644 index 73370e7cf..000000000 --- a/spec/NetworkLifecycle.coffee +++ /dev/null @@ -1,1197 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - path = require 'path' - root = path.resolve __dirname, '../' - urlPrefix = './' -else - noflo = require 'noflo' - root = 'noflo' - urlPrefix = '/' - -legacyBasic = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - c.inPorts.in.on 'connect', -> - c.outPorts.out.connect() - c.inPorts.in.on 'begingroup', (group) -> - c.outPorts.out.beginGroup group - c.inPorts.in.on 'data', (data) -> - c.outPorts.out.data data + c.nodeId - c.inPorts.in.on 'endgroup', (group) -> - c.outPorts.out.endGroup() - c.inPorts.in.on 'disconnect', -> - c.outPorts.out.disconnect() - c - -wirePatternAsync = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - noflo.helpers.WirePattern c, - in: 'in' - out: 'out' - async: true - forwardGroups: true - , (data, groups, out, callback) -> - setTimeout -> - out.send data + c.nodeId - callback() - , 1 - -wirePatternMerge = -> - c = new noflo.Component - c.inPorts.add 'in1', - datatype: 'string' - c.inPorts.add 'in2', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - noflo.helpers.WirePattern c, - in: ['in1', 'in2'] - out: 'out' - async: true - forwardGroups: true - , (data, groups, out, callback) -> - out.send "1#{data['in1']}#{c.nodeId}2#{data['in2']}#{c.nodeId}" - callback() - -processAsync = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - c.process (input, output) -> - data = input.getData 'in' - setTimeout -> - output.sendDone data + c.nodeId - , 1 - -processMerge = -> - c = new noflo.Component - c.inPorts.add 'in1', - datatype: 'string' - c.inPorts.add 'in2', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - c.forwardBrackets = - 'in1': ['out'] - - c.process (input, output) -> - return unless input.has 'in1', 'in2', (ip) -> ip.type is 'data' - first = input.getData 'in1' - second = input.getData 'in2' - - output.sendDone - out: "1#{first}:2#{second}:#{c.nodeId}" - -processSync = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - c.process (input, output) -> - data = input.getData 'in' - output.send - out: data + c.nodeId - output.done() - -processBracketize = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - c.counter = 0 - c.tearDown = (callback) -> - c.counter = 0 - do callback - c.process (input, output) -> - data = input.getData 'in' - output.send - out: new noflo.IP 'openBracket', c.counter - output.send - out: data - output.send - out: new noflo.IP 'closeBracket', c.counter - c.counter++ - output.done() - -processNonSending = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.inPorts.add 'in2', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - c.forwardBrackets = {} - c.process (input, output) -> - if input.hasData 'in2' - input.getData 'in2' - output.done() - return - return unless input.hasData 'in' - data = input.getData 'in' - output.send data + c.nodeId - output.done() - -processGenerator = -> - c = new noflo.Component - c.inPorts.add 'start', - datatype: 'bang' - c.inPorts.add 'stop', - datatype: 'bang' - c.outPorts.add 'out', - datatype: 'bang' - c.autoOrdering = false - - cleanUp = -> - return unless c.timer - clearInterval c.timer.interval - c.timer.deactivate() - c.timer = null - c.tearDown = (callback) -> - cleanUp() - callback() - - c.process (input, output, context) -> - if input.hasData 'start' - cleanUp() if c.timer - input.getData 'start' - c.timer = context - c.timer.interval = setInterval -> - output.send out: true - , 100 - if input.hasData 'stop' - input.getData 'stop' - return output.done() unless c.timer - cleanUp() - output.done() - -describe 'Network Lifecycle', -> - loader = null - before (done) -> - loader = new noflo.ComponentLoader root - loader.listComponents (err) -> - return done err if err - loader.registerComponent 'wirepattern', 'Async', wirePatternAsync - loader.registerComponent 'wirepattern', 'Merge', wirePatternMerge - loader.registerComponent 'process', 'Async', processAsync - loader.registerComponent 'process', 'Sync', processSync - loader.registerComponent 'process', 'Merge', processMerge - loader.registerComponent 'process', 'Bracketize', processBracketize - loader.registerComponent 'process', 'NonSending', processNonSending - loader.registerComponent 'process', 'Generator', processGenerator - loader.registerComponent 'legacy', 'Sync', legacyBasic - done() - - describe 'recognizing API level', -> - it 'should recognize legacy component as such', (done) -> - loader.load 'legacy/Sync', (err, inst) -> - return done err if err - chai.expect(inst.isLegacy()).to.equal true - done() - return - it 'should recognize WirePattern component as non-legacy', (done) -> - loader.load 'wirepattern/Async', (err, inst) -> - return done err if err - chai.expect(inst.isLegacy()).to.equal false - done() - return - it 'should recognize Process API component as non-legacy', (done) -> - loader.load 'process/Async', (err, inst) -> - return done err if err - chai.expect(inst.isLegacy()).to.equal false - done() - return - it 'should recognize Graph component as non-legacy', (done) -> - loader.load 'Graph', (err, inst) -> - return done err if err - chai.expect(inst.isLegacy()).to.equal false - done() - return - - describe 'with single Process API component receiving IIP', -> - c = null - g = null - out = null - beforeEach (done) -> - fbpData = " - OUTPORT=Pc.OUT:OUT - 'hello' -> IN Pc(process/Async) - " - noflo.graph.loadFBP fbpData, (err, graph) -> - return done err if err - g = graph - loader.registerComponent 'scope', 'Connected', graph - loader.load 'scope/Connected', (err, instance) -> - return done err if err - c = instance - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - done() - afterEach (done) -> - c.outPorts.out.detach out - out = null - c.shutdown done - it 'should execute and finish', (done) -> - expected = [ - 'DATA helloPc' - ] - received = [] - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "< #{ip.data}" - when 'data' - received.push "DATA #{ip.data}" - when 'closeBracket' - received.push '>' - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - c.start (err) -> - return done err if err - it 'should execute twice if IIP changes', (done) -> - expected = [ - 'DATA helloPc' - 'DATA worldPc' - ] - received = [] - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "< #{ip.data}" - when 'data' - received.push "DATA #{ip.data}" - when 'closeBracket' - received.push '>' - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(wasStarted).to.equal true - if received.length < expected.length - wasStarted = false - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - c.network.addInitial - from: - data: 'world' - to: - node: 'Pc' - port: 'in' - , (err) -> - return done err if err - return - chai.expect(received).to.eql expected - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - c.start (err) -> - return done err if err - it 'should not send new IIP if network was stopped', (done) -> - expected = [ - 'DATA helloPc' - ] - received = [] - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "< #{ip.data}" - when 'data' - received.push "DATA #{ip.data}" - when 'closeBracket' - received.push '>' - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(wasStarted).to.equal true - c.network.stop (err) -> - return done err if err - chai.expect(c.network.isStopped()).to.equal true - c.network.once 'start', -> - throw new Error 'Unexpected network start' - c.network.once 'end', -> - throw new Error 'Unexpected network end' - c.network.addInitial - from: - data: 'world' - to: - node: 'Pc' - port: 'in' - , (err) -> - return done err if err - setTimeout -> - chai.expect(received).to.eql expected - done() - , 1000 - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - c.start (err) -> - return done err if err - - describe 'with synchronous Process API', -> - c = null - g = null - out = null - beforeEach (done) -> - fbpData = " - OUTPORT=Sync.OUT:OUT - 'foo' -> IN2 NonSending(process/NonSending) - 'hello' -> IN Bracketize(process/Bracketize) - Bracketize OUT -> IN NonSending(process/NonSending) - NonSending OUT -> IN Sync(process/Sync) - Sync OUT -> IN2 NonSending - " - noflo.graph.loadFBP fbpData, (err, graph) -> - return done err if err - g = graph - loader.registerComponent 'scope', 'Connected', graph - loader.load 'scope/Connected', (err, instance) -> - return done err if err - c = instance - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - done() - afterEach (done) -> - c.outPorts.out.detach out - out = null - c.shutdown done - it 'should execute and finish', (done) -> - expected = [ - 'DATA helloNonSendingSync' - ] - received = [] - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "< #{ip.data}" - when 'data' - received.push "DATA #{ip.data}" - when 'closeBracket' - received.push '>' - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - setTimeout -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - , 100 - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - c.start (err) -> - return done err if err - - describe 'with WirePattern sending to Process API', -> - c = null - ins = null - out = null - before (done) -> - fbpData = " - INPORT=Wp.IN:IN - OUTPORT=Pc.OUT:OUT - Wp(wirepattern/Async) OUT -> IN Pc(process/Async) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'Connected', g - loader.load 'scope/Connected', (err, instance) -> - return done err if err - c = instance - ins = noflo.internalSocket.createSocket() - c.inPorts.in.attach ins - done() - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach (done) -> - c.outPorts.out.detach out - out = null - c.shutdown done - - it 'should forward old-style groups as expected', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA bazWpPc' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - ins.connect() - ins.beginGroup 1 - ins.beginGroup 'a' - ins.send 'baz' - ins.endGroup() - ins.endGroup() - ins.disconnect() - it 'should forward new-style brackets as expected', (done) -> - expected = [ - '< 1' - '< a' - 'DATA fooWpPc' - '>' - '>' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "< #{ip.data}" - brackets.push ip.data - when 'data' - received.push "DATA #{ip.data}" - when 'closeBracket' - received.push '>' - brackets.pop() - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - ins.post new noflo.IP 'openBracket', 1 - ins.post new noflo.IP 'openBracket', 'a' - ins.post new noflo.IP 'data', 'foo' - ins.post new noflo.IP 'closeBracket', 'a' - ins.post new noflo.IP 'closeBracket', 1 - it 'should forward scopes as expected', (done) -> - expected = [ - 'x < 1' - 'x < a' - 'x DATA barWpPc' - 'x >' - 'x >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - ins.post new noflo.IP 'openBracket', 1, - scope: 'x' - ins.post new noflo.IP 'openBracket', 'a', - scope: 'x' - ins.post new noflo.IP 'data', 'bar', - scope: 'x' - ins.post new noflo.IP 'closeBracket', 'a', - scope: 'x' - ins.post new noflo.IP 'closeBracket', 1, - scope: 'x' - - describe 'pure Process API merging two inputs', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Pc1.IN:IN1 - INPORT=Pc2.IN:IN2 - OUTPORT=PcMerge.OUT:OUT - Pc1(process/Async) OUT -> IN1 PcMerge(process/Merge) - Pc2(process/Async) OUT -> IN2 PcMerge(process/Merge) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'Merge', g - loader.load 'scope/Merge', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - in2 = noflo.internalSocket.createSocket() - c.inPorts.in2.attach in2 - done() - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach (done) -> - c.outPorts.out.detach out - out = null - c.shutdown done - - it 'should forward new-style brackets as expected', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazPc1:2fooPc2:PcMerge' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - in2.connect() - in2.send 'foo' - in2.disconnect() - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - it 'should forward new-style brackets as expected regardless of sending order', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazPc1:2fooPc2:PcMerge' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - in2.connect() - in2.send 'foo' - in2.disconnect() - it 'should forward scopes as expected', (done) -> - expected = [ - 'x < 1' - 'x DATA 1onePc1:2twoPc2:PcMerge' - 'x >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - in2.post new noflo.IP 'data', 'two', - scope: 'x' - in1.post new noflo.IP 'openBracket', 1, - scope: 'x' - in1.post new noflo.IP 'data', 'one', - scope: 'x' - in1.post new noflo.IP 'closeBracket', 1, - scope: 'x' - - describe 'Process API mixed with legacy merging two inputs', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Leg1.IN:IN1 - INPORT=Leg2.IN:IN2 - OUTPORT=Leg3.OUT:OUT - Leg1(legacy/Sync) OUT -> IN1 PcMerge(process/Merge) - Leg2(legacy/Sync) OUT -> IN2 PcMerge(process/Merge) - PcMerge OUT -> IN Leg3(legacy/Sync) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'Merge', g - loader.load 'scope/Merge', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - in2 = noflo.internalSocket.createSocket() - c.inPorts.in2.attach in2 - done() - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach (done) -> - c.outPorts.out.detach out - out = null - c.shutdown done - - it 'should forward new-style brackets as expected', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazLeg1:2fooLeg2:PcMergeLeg3' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - in2.connect() - in2.send 'foo' - in2.disconnect() - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - it 'should forward new-style brackets as expected regardless of sending order', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazLeg1:2fooLeg2:PcMergeLeg3' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - in2.connect() - in2.send 'foo' - in2.disconnect() - - describe 'Process API mixed with Legacy and WirePattern merging two inputs', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Leg1.IN:IN1 - INPORT=Leg2.IN:IN2 - OUTPORT=Wp.OUT:OUT - Leg1(legacy/Sync) OUT -> IN1 PcMerge(process/Merge) - Leg2(legacy/Sync) OUT -> IN2 PcMerge(process/Merge) - PcMerge OUT -> IN Wp(wirepattern/Async) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'Merge', g - loader.load 'scope/Merge', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - in2 = noflo.internalSocket.createSocket() - c.inPorts.in2.attach in2 - done() - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach (done) -> - c.outPorts.out.detach out - out = null - c.shutdown done - - it 'should forward new-style brackets as expected', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazLeg1:2fooLeg2:PcMergeWp' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - in2.connect() - in2.send 'foo' - in2.disconnect() - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - it 'should forward new-style brackets as expected regardless of sending order', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazLeg1:2fooLeg2:PcMergeWp' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - in2.connect() - in2.send 'foo' - in2.disconnect() - - describe 'Process API mixed with WirePattern and legacy merging two inputs', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Leg1.IN:IN1 - INPORT=Leg2.IN:IN2 - OUTPORT=Leg3.OUT:OUT - Leg1(legacy/Sync) OUT -> IN1 PcMerge(process/Merge) - Leg2(legacy/Sync) OUT -> IN2 PcMerge(process/Merge) - PcMerge OUT -> IN Wp(wirepattern/Async) - Wp OUT -> IN Leg3(legacy/Sync) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'Merge', g - loader.load 'scope/Merge', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - in2 = noflo.internalSocket.createSocket() - c.inPorts.in2.attach in2 - done() - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach (done) -> - c.outPorts.out.detach out - out = null - c.shutdown done - - it 'should forward new-style brackets as expected', (done) -> - expected = [ - 'START' - 'DATA -> IN Leg2() DATA foo' - 'Leg2() OUT -> IN2 PcMerge() DATA fooLeg2' - 'Leg1() OUT -> IN1 PcMerge() < 1' - 'Leg1() OUT -> IN1 PcMerge() < a' - 'Leg1() OUT -> IN1 PcMerge() DATA bazLeg1' - 'PcMerge() OUT -> IN Wp() < 1' - 'PcMerge() OUT -> IN Wp() < a' - 'PcMerge() OUT -> IN Wp() DATA 1bazLeg1:2fooLeg2:PcMerge' - 'Leg1() OUT -> IN1 PcMerge() > a' - 'PcMerge() OUT -> IN Wp() > a' - 'Leg1() OUT -> IN1 PcMerge() > 1' - 'PcMerge() OUT -> IN Wp() > 1' - 'Wp() OUT -> IN Leg3() < 1' - 'Wp() OUT -> IN Leg3() < a' - 'Wp() OUT -> IN Leg3() DATA 1bazLeg1:2fooLeg2:PcMergeWp' - 'Wp() OUT -> IN Leg3() > a' - 'Wp() OUT -> IN Leg3() > 1' - 'END' - ] - received = [] - - wasStarted = false - checkStart = -> - received.push 'START' - receiveConnect = (event) -> - received.push "#{event.id} CONN" - receiveEvent = (event) -> - prefix = '' - switch event.type - when 'openBracket' - prefix = '<' - data = "#{prefix} #{event.data}" - when 'data' - prefix = 'DATA' - data = "#{prefix} #{event.data}" - when 'closeBracket' - prefix = '>' - data = "#{prefix} #{event.data}" - received.push "#{event.id} #{data}" - receiveDisconnect = (event) -> - received.push "#{event.id} DISC" - checkEnd = -> - received.push 'END' - c.network.graph.removeInitial 'foo', 'Leg2', 'in' - c.network.removeListener 'connect', receiveConnect - c.network.removeListener 'ip', receiveEvent - c.network.removeListener 'disconnect', receiveDisconnect - chai.expect(received).to.eql expected - done() - c.network.once 'start', checkStart - c.network.on 'connect', receiveConnect - c.network.on 'ip', receiveEvent - c.network.on 'disconnect', receiveDisconnect - c.network.once 'end', checkEnd - - c.network.addInitial - from: - data: 'foo' - to: - node: 'Leg2' - port: 'in' - , (err) -> - return done err if err - c.start (err) -> - return done err if err - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - it 'should forward new-style brackets as expected regardless of sending order', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazLeg1:2fooLeg2:PcMergeWpLeg3' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - - wasStarted = false - checkStart = -> - chai.expect(wasStarted).to.equal false - wasStarted = true - checkEnd = -> - chai.expect(received).to.eql expected - chai.expect(wasStarted).to.equal true - done() - c.network.once 'start', checkStart - c.network.once 'end', checkEnd - - c.start (err) -> - return done err if err - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - in2.connect() - in2.send 'foo' - in2.disconnect() - - describe 'with a Process API Generator component', -> - c = null - start = null - stop = null - out = null - before (done) -> - fbpData = " - INPORT=PcGen.START:START - INPORT=PcGen.STOP:STOP - OUTPORT=Pc.OUT:OUT - PcGen(process/Generator) OUT -> IN Pc(process/Async) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'Connected', g - loader.load 'scope/Connected', (err, instance) -> - return done err if err - instance.once 'ready', -> - c = instance - start = noflo.internalSocket.createSocket() - c.inPorts.start.attach start - stop = noflo.internalSocket.createSocket() - c.inPorts.stop.attach stop - done() - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach (done) -> - c.outPorts.out.detach out - out = null - c.shutdown done - it 'should not be running initially', -> - chai.expect(c.network.isRunning()).to.equal false - it 'should not be running even when network starts', (done) -> - c.start (err) -> - return done err if err - chai.expect(c.network.isRunning()).to.equal false - done() - it 'should start generating when receiving a start packet', (done) -> - c.start (err) -> - return done err if err - out.once 'data', -> - chai.expect(c.network.isRunning()).to.equal true - done() - start.send true - it 'should stop generating when receiving a stop packet', (done) -> - c.start (err) -> - return done err if err - out.once 'data', -> - chai.expect(c.network.isRunning()).to.equal true - stop.send true - setTimeout -> - chai.expect(c.network.isRunning()).to.equal false - done() - , 10 - start.send true diff --git a/spec/NetworkLifecycle.js b/spec/NetworkLifecycle.js new file mode 100644 index 000000000..f5316a7a0 --- /dev/null +++ b/spec/NetworkLifecycle.js @@ -0,0 +1,943 @@ +let chai; let noflo; let root; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + const path = require('path'); + root = path.resolve(__dirname, '../'); +} else { + noflo = require('noflo'); + root = 'noflo'; +} + +const legacyBasic = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + c.inPorts.in.on('connect', () => { + c.outPorts.out.connect(); + }); + c.inPorts.in.on('begingroup', (group) => { + c.outPorts.out.beginGroup(group); + }); + c.inPorts.in.on('data', (data) => { + c.outPorts.out.data(data + c.nodeId); + }); + c.inPorts.in.on('endgroup', () => { + c.outPorts.out.endGroup(); + }); + c.inPorts.in.on('disconnect', () => { + c.outPorts.out.disconnect(); + }); + return c; +}; + +const processAsync = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + + c.process((input, output) => { + const data = input.getData('in'); + setTimeout(() => { + output.sendDone(data + c.nodeId); + }, + 1); + }); + return c; +}; + +const processMerge = function () { + const c = new noflo.Component(); + c.inPorts.add('in1', + { datatype: 'string' }); + c.inPorts.add('in2', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + + c.forwardBrackets = { in1: ['out'] }; + + c.process((input, output) => { + if (!input.has('in1', 'in2', (ip) => ip.type === 'data')) { return; } + const first = input.getData('in1'); + const second = input.getData('in2'); + + output.sendDone({ out: `1${first}:2${second}:${c.nodeId}` }); + }); + return c; +}; + +const processSync = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + c.process((input, output) => { + const data = input.getData('in'); + output.send({ out: data + c.nodeId }); + output.done(); + }); + return c; +}; + +const processBracketize = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + c.counter = 0; + c.tearDown = function (callback) { + c.counter = 0; + callback(); + }; + c.process((input, output) => { + const data = input.getData('in'); + output.send({ out: new noflo.IP('openBracket', c.counter) }); + output.send({ out: data }); + output.send({ out: new noflo.IP('closeBracket', c.counter) }); + c.counter++; + output.done(); + }); + return c; +}; + +const processNonSending = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.inPorts.add('in2', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + c.forwardBrackets = {}; + c.process((input, output) => { + if (input.hasData('in2')) { + input.getData('in2'); + output.done(); + return; + } + if (!input.hasData('in')) { return; } + const data = input.getData('in'); + output.send(data + c.nodeId); + output.done(); + }); + return c; +}; + +const processGenerator = function () { + const c = new noflo.Component(); + c.inPorts.add('start', + { datatype: 'bang' }); + c.inPorts.add('stop', + { datatype: 'bang' }); + c.outPorts.add('out', + { datatype: 'bang' }); + c.autoOrdering = false; + + const cleanUp = function () { + if (!c.timer) { return; } + clearInterval(c.timer.interval); + c.timer.deactivate(); + c.timer = null; + }; + c.tearDown = function (callback) { + cleanUp(); + callback(); + }; + + c.process((input, output, context) => { + if (input.hasData('start')) { + if (c.timer) { cleanUp(); } + input.getData('start'); + c.timer = context; + c.timer.interval = setInterval(() => { + output.send({ out: true }); + }, + 100); + } + if (input.hasData('stop')) { + input.getData('stop'); + if (!c.timer) { + output.done(); + return; + } + cleanUp(); + output.done(); + } + }); + return c; +}; + +describe('Network Lifecycle', () => { + let loader = null; + before((done) => { + loader = new noflo.ComponentLoader(root); + loader.listComponents((err) => { + if (err) { + done(err); + return; + } + loader.registerComponent('process', 'Async', processAsync); + loader.registerComponent('process', 'Sync', processSync); + loader.registerComponent('process', 'Merge', processMerge); + loader.registerComponent('process', 'Bracketize', processBracketize); + loader.registerComponent('process', 'NonSending', processNonSending); + loader.registerComponent('process', 'Generator', processGenerator); + loader.registerComponent('legacy', 'Sync', legacyBasic); + done(); + }); + }); + describe('recognizing API level', () => { + it('should recognize legacy component as such', (done) => { + loader.load('legacy/Sync', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst.isLegacy()).to.equal(true); + done(); + }); + }); + it('should recognize Process API component as non-legacy', (done) => { + loader.load('process/Async', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst.isLegacy()).to.equal(false); + done(); + }); + }); + it('should recognize Graph component as non-legacy', (done) => { + loader.load('Graph', (err, inst) => { + if (err) { + done(err); + return; + } + chai.expect(inst.isLegacy()).to.equal(false); + done(); + }); + }); + }); + describe('with single Process API component receiving IIP', () => { + let c = null; + let out = null; + beforeEach((done) => { + const fbpData = 'OUTPORT=Pc.OUT:OUT\n' + + '\'hello\' -> IN Pc(process/Async)\n'; + noflo.graph.loadFBP(fbpData, (err, graph) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'Connected', graph); + loader.load('scope/Connected', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + done(); + }); + }); + }); + afterEach((done) => { + c.outPorts.out.detach(out); + out = null; + c.shutdown(done); + }); + it('should execute and finish', (done) => { + const expected = [ + 'DATA helloPc', + ]; + const received = []; + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`< ${ip.data}`); + break; + case 'data': + received.push(`DATA ${ip.data}`); + break; + case 'closeBracket': + received.push('>'); + break; + } + }); + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + chai.expect(received).to.eql(expected); + chai.expect(wasStarted).to.equal(true); + done(); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + c.start((err) => { + if (err) { + done(err); + } + }); + }); + it('should execute twice if IIP changes', (done) => { + const expected = [ + 'DATA helloPc', + 'DATA worldPc', + ]; + const received = []; + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`< ${ip.data}`); + break; + case 'data': + received.push(`DATA ${ip.data}`); + break; + case 'closeBracket': + received.push('>'); + break; + } + }); + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + chai.expect(wasStarted).to.equal(true); + if (received.length < expected.length) { + wasStarted = false; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + c.network.addInitial({ + from: { + data: 'world', + }, + to: { + node: 'Pc', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + } + }); + return; + } + chai.expect(received).to.eql(expected); + done(); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + c.start((err) => { + if (err) { + done(err); + } + }); + }); + it('should not send new IIP if network was stopped', (done) => { + const expected = [ + 'DATA helloPc', + ]; + const received = []; + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`< ${ip.data}`); + break; + case 'data': + received.push(`DATA ${ip.data}`); + break; + case 'closeBracket': + received.push('>'); + break; + } + }); + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + chai.expect(wasStarted).to.equal(true); + return c.network.stop((err) => { + if (err) { + done(err); + return; + } + chai.expect(c.network.isStopped()).to.equal(true); + c.network.once('start', () => { + throw new Error('Unexpected network start'); + }); + c.network.once('end', () => { + throw new Error('Unexpected network end'); + }); + c.network.addInitial({ + from: { + data: 'world', + }, + to: { + node: 'Pc', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + } + }); + setTimeout(() => { + chai.expect(received).to.eql(expected); + done(); + }, + 1000); + }); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + c.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + describe('with synchronous Process API', () => { + let c = null; + let out = null; + beforeEach((done) => { + const fbpData = 'OUTPORT=Sync.OUT:OUT\n' + + '\'foo\' -> IN2 NonSending(process/NonSending)\n' + + '\'hello\' -> IN Bracketize(process/Bracketize)\n' + + 'Bracketize OUT -> IN NonSending(process/NonSending)\n' + + 'NonSending OUT -> IN Sync(process/Sync)\n' + + 'Sync OUT -> IN2 NonSending\n'; + noflo.graph.loadFBP(fbpData, (err, graph) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'Connected', graph); + loader.load('scope/Connected', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + done(); + }); + }); + }); + afterEach((done) => { + c.outPorts.out.detach(out); + out = null; + c.shutdown(done); + }); + it('should execute and finish', (done) => { + const expected = [ + 'DATA helloNonSendingSync', + ]; + const received = []; + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`< ${ip.data}`); + break; + case 'data': + received.push(`DATA ${ip.data}`); + break; + case 'closeBracket': + received.push('>'); + break; + } + }); + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + setTimeout(() => { + chai.expect(received).to.eql(expected); + chai.expect(wasStarted).to.equal(true); + done(); + }, + 100); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + c.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + describe('pure Process API merging two inputs', () => { + let c = null; + let in1 = null; + let in2 = null; + let out = null; + before((done) => { + const fbpData = 'INPORT=Pc1.IN:IN1\n' + + 'INPORT=Pc2.IN:IN2\n' + + 'OUTPORT=PcMerge.OUT:OUT\n' + + 'Pc1(process/Async) OUT -> IN1 PcMerge(process/Merge)\n' + + 'Pc2(process/Async) OUT -> IN2 PcMerge(process/Merge)\n'; + noflo.graph.loadFBP(fbpData, (err, g) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'Merge', g); + loader.load('scope/Merge', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + in1 = noflo.internalSocket.createSocket(); + c.inPorts.in1.attach(in1); + in2 = noflo.internalSocket.createSocket(); + c.inPorts.in2.attach(in2); + done(); + }); + }); + }); + beforeEach(() => { + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + }); + afterEach((done) => { + c.outPorts.out.detach(out); + out = null; + c.shutdown(done); + }); + it('should forward new-style brackets as expected', (done) => { + const expected = [ + 'CONN', + '< 1', + '< a', + 'DATA 1bazPc1:2fooPc2:PcMerge', + '>', + '>', + 'DISC', + ]; + const received = []; + + out.on('connect', () => { + received.push('CONN'); + }); + out.on('begingroup', (group) => { + received.push(`< ${group}`); + }); + out.on('data', (data) => { + received.push(`DATA ${data}`); + }); + out.on('endgroup', () => { + received.push('>'); + }); + out.on('disconnect', () => { + received.push('DISC'); + }); + + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + chai.expect(received).to.eql(expected); + chai.expect(wasStarted).to.equal(true); + done(); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + + c.start((err) => { + if (err) { + done(err); + return; + } + in2.connect(); + in2.send('foo'); + in2.disconnect(); + in1.connect(); + in1.beginGroup(1); + in1.beginGroup('a'); + in1.send('baz'); + in1.endGroup(); + in1.endGroup(); + in1.disconnect(); + }); + }); + it('should forward new-style brackets as expected regardless of sending order', (done) => { + const expected = [ + 'CONN', + '< 1', + '< a', + 'DATA 1bazPc1:2fooPc2:PcMerge', + '>', + '>', + 'DISC', + ]; + const received = []; + + out.on('connect', () => { + received.push('CONN'); + }); + out.on('begingroup', (group) => { + received.push(`< ${group}`); + }); + out.on('data', (data) => { + received.push(`DATA ${data}`); + }); + out.on('endgroup', () => { + received.push('>'); + }); + out.on('disconnect', () => { + received.push('DISC'); + }); + + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + chai.expect(received).to.eql(expected); + chai.expect(wasStarted).to.equal(true); + done(); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + + c.start((err) => { + if (err) { + done(err); + return; + } + in1.connect(); + in1.beginGroup(1); + in1.beginGroup('a'); + in1.send('baz'); + in1.endGroup(); + in1.endGroup(); + in1.disconnect(); + in2.connect(); + in2.send('foo'); + in2.disconnect(); + }); + }); + it('should forward scopes as expected', (done) => { + const expected = [ + 'x < 1', + 'x DATA 1onePc1:2twoPc2:PcMerge', + 'x >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + break; + } + }); + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + chai.expect(received).to.eql(expected); + chai.expect(wasStarted).to.equal(true); + done(); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + + c.start((err) => { + if (err) { + done(err); + return; + } + in2.post(new noflo.IP('data', 'two', + { scope: 'x' })); + in1.post(new noflo.IP('openBracket', 1, + { scope: 'x' })); + in1.post(new noflo.IP('data', 'one', + { scope: 'x' })); + in1.post(new noflo.IP('closeBracket', 1, + { scope: 'x' })); + }); + }); + }); + describe('Process API mixed with legacy merging two inputs', () => { + let c = null; + let in1 = null; + let in2 = null; + let out = null; + before((done) => { + const fbpData = 'INPORT=Leg1.IN:IN1\n' + + 'INPORT=Leg2.IN:IN2\n' + + 'OUTPORT=Leg3.OUT:OUT\n' + + 'Leg1(legacy/Sync) OUT -> IN1 PcMerge(process/Merge)\n' + + 'Leg2(legacy/Sync) OUT -> IN2 PcMerge(process/Merge)\n' + + 'PcMerge OUT -> IN Leg3(legacy/Sync)\n'; + noflo.graph.loadFBP(fbpData, (err, g) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'Merge', g); + loader.load('scope/Merge', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + in1 = noflo.internalSocket.createSocket(); + c.inPorts.in1.attach(in1); + in2 = noflo.internalSocket.createSocket(); + c.inPorts.in2.attach(in2); + done(); + }); + }); + }); + beforeEach(() => { + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + }); + afterEach((done) => { + c.outPorts.out.detach(out); + out = null; + c.shutdown(done); + }); + it('should forward new-style brackets as expected', (done) => { + const expected = [ + 'CONN', + '< 1', + '< a', + 'DATA 1bazLeg1:2fooLeg2:PcMergeLeg3', + '>', + '>', + 'DISC', + ]; + const received = []; + + out.on('connect', () => { + received.push('CONN'); + }); + out.on('begingroup', (group) => { + received.push(`< ${group}`); + }); + out.on('data', (data) => { + received.push(`DATA ${data}`); + }); + out.on('endgroup', () => { + received.push('>'); + }); + out.on('disconnect', () => { + received.push('DISC'); + }); + + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + chai.expect(received).to.eql(expected); + chai.expect(wasStarted).to.equal(true); + done(); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + + c.start((err) => { + if (err) { + done(err); + return; + } + in2.connect(); + in2.send('foo'); + in2.disconnect(); + in1.connect(); + in1.beginGroup(1); + in1.beginGroup('a'); + in1.send('baz'); + in1.endGroup(); + in1.endGroup(); + in1.disconnect(); + }); + }); + it('should forward new-style brackets as expected regardless of sending order', (done) => { + const expected = [ + 'CONN', + '< 1', + '< a', + 'DATA 1bazLeg1:2fooLeg2:PcMergeLeg3', + '>', + '>', + 'DISC', + ]; + const received = []; + + out.on('connect', () => { + received.push('CONN'); + }); + out.on('begingroup', (group) => { + received.push(`< ${group}`); + }); + out.on('data', (data) => { + received.push(`DATA ${data}`); + }); + out.on('endgroup', () => { + received.push('>'); + }); + out.on('disconnect', () => { + received.push('DISC'); + }); + + let wasStarted = false; + const checkStart = function () { + chai.expect(wasStarted).to.equal(false); + wasStarted = true; + }; + const checkEnd = function () { + chai.expect(received).to.eql(expected); + chai.expect(wasStarted).to.equal(true); + done(); + }; + c.network.once('start', checkStart); + c.network.once('end', checkEnd); + + c.start((err) => { + if (err) { + done(err); + return; + } + in1.connect(); + in1.beginGroup(1); + in1.beginGroup('a'); + in1.send('baz'); + in1.endGroup(); + in1.endGroup(); + in1.disconnect(); + in2.connect(); + in2.send('foo'); + in2.disconnect(); + }); + }); + }); + describe('with a Process API Generator component', () => { + let c = null; + let start = null; + let stop = null; + let out = null; + before((done) => { + const fbpData = 'INPORT=PcGen.START:START\n' + + 'INPORT=PcGen.STOP:STOP\n' + + 'OUTPORT=Pc.OUT:OUT\n' + + 'PcGen(process/Generator) OUT -> IN Pc(process/Async)\n'; + noflo.graph.loadFBP(fbpData, (err, g) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'Connected', g); + loader.load('scope/Connected', (err, instance) => { + if (err) { + done(err); + return; + } + instance.once('ready', () => { + c = instance; + start = noflo.internalSocket.createSocket(); + c.inPorts.start.attach(start); + stop = noflo.internalSocket.createSocket(); + c.inPorts.stop.attach(stop); + done(); + }); + }); + }); + }); + beforeEach(() => { + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + }); + afterEach((done) => { + c.outPorts.out.detach(out); + out = null; + c.shutdown(done); + }); + it('should not be running initially', () => { + chai.expect(c.network.isRunning()).to.equal(false); + }); + it('should not be running even when network starts', (done) => { + c.start((err) => { + if (err) { + done(err); + return; + } + chai.expect(c.network.isRunning()).to.equal(false); + done(); + }); + }); + it('should start generating when receiving a start packet', (done) => { + c.start((err) => { + if (err) { + done(err); + return; + } + out.once('data', () => { + chai.expect(c.network.isRunning()).to.equal(true); + done(); + }); + start.send(true); + }); + }); + it('should stop generating when receiving a stop packet', (done) => { + c.start((err) => { + if (err) { + done(err); + return; + } + out.once('data', () => { + chai.expect(c.network.isRunning()).to.equal(true); + stop.send(true); + setTimeout(() => { + chai.expect(c.network.isRunning()).to.equal(false); + done(); + }, + 10); + }); + start.send(true); + }); + }); + }); +}); diff --git a/spec/NoFlo.coffee b/spec/NoFlo.coffee deleted file mode 100644 index 4c8744435..000000000 --- a/spec/NoFlo.coffee +++ /dev/null @@ -1,34 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - path = require('path') - browser = false -else - noflo = require 'noflo' - browser = true - -describe 'NoFlo interface', -> - it 'should be able to tell whether it is running on browser', -> - chai.expect(noflo.isBrowser()).to.equal browser - describe 'working with graph files', -> - targetPath = null - before -> - # These features only work on Node.js - return @skip() if noflo.isBrowser() - targetPath = path.resolve __dirname, 'tmp.json' - after (done) -> - return done() if noflo.isBrowser() - fs = require 'fs' - fs.unlink targetPath, done - it 'should be able to save a graph file', (done) -> - graph = new noflo.Graph - graph.addNode 'G', 'Graph' - noflo.saveFile graph, targetPath, done - it 'should be able to load a graph file', (done) -> - noflo.loadFile targetPath, - baseDir: process.cwd() - delay: true - , (err, network) -> - return done err if err - chai.expect(network.isRunning()).to.equal false - done() diff --git a/spec/NoFlo.js b/spec/NoFlo.js new file mode 100644 index 000000000..cea7bba33 --- /dev/null +++ b/spec/NoFlo.js @@ -0,0 +1,54 @@ +let browser; let chai; let noflo; let path; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + path = require('path'); + browser = false; +} else { + noflo = require('noflo'); + browser = true; +} + +describe('NoFlo interface', () => { + it('should be able to tell whether it is running on browser', () => { + chai.expect(noflo.isBrowser()).to.equal(browser); + }); + describe('working with graph files', () => { + let targetPath = null; + before(function () { + // These features only work on Node.js + if (noflo.isBrowser()) { + this.skip(); + return; + } + targetPath = path.resolve(__dirname, 'tmp.json'); + }); + after((done) => { + if (noflo.isBrowser()) { + done(); + return; + } + const fs = require('fs'); + fs.unlink(targetPath, done); + }); + it('should be able to save a graph file', (done) => { + const graph = new noflo.Graph(); + graph.addNode('G', 'Graph'); + noflo.saveFile(graph, targetPath, done); + }); + it('should be able to load a graph file', (done) => { + noflo.loadFile(targetPath, { + baseDir: process.cwd(), + delay: true, + }, + (err, network) => { + if (err) { + done(err); + return; + } + chai.expect(network.isRunning()).to.equal(false); + done(); + }); + }); + }); +}); diff --git a/spec/OutPort.coffee b/spec/OutPort.coffee deleted file mode 100644 index 0acdc4bcd..000000000 --- a/spec/OutPort.coffee +++ /dev/null @@ -1,267 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' -else - noflo = require 'noflo' - -describe 'Outport Port', -> - describe 'with addressable ports', -> - s1 = s2 = s3 = null - beforeEach -> - s1 = new noflo.internalSocket.InternalSocket - s2 = new noflo.internalSocket.InternalSocket - s3 = new noflo.internalSocket.InternalSocket - - it 'should be able to send to a specific port', -> - p = new noflo.OutPort - addressable: true - p.attach s1 - p.attach s2 - p.attach s3 - chai.expect(p.listAttached()).to.eql [0, 1, 2] - s1.on 'data', -> - chai.expect(true).to.equal false - s2.on 'data', (data) -> - chai.expect(data).to.equal 'some-data' - s3.on 'data', -> - chai.expect(true).to.equal false - p.send 'some-data', 1 - - it 'should be able to send to index 0', (done) -> - p = new noflo.OutPort - addressable: true - p.attach s1 - s1.on 'data', (data) -> - chai.expect(data).to.equal 'my-data' - done() - p.send 'my-data', 0 - - it 'should throw an error when sent data without address', -> - chai.expect(-> p.send('some-data')).to.throw - - it 'should throw an error when a specific port is requested with non-addressable port', -> - p = new noflo.OutPort - p.attach s1 - p.attach s2 - p.attach s3 - chai.expect(-> p.send('some-data', 1)).to.throw - - it 'should give correct port index when detaching a connection', (done) -> - p = new noflo.OutPort - addressable: true - p.attach s1, 3 - p.attach s2, 1 - p.attach s3, 5 - expectedSockets = [s2, s3] - expected = [1, 5] - expectedAttached = [ - [3, 5] - [3] - ] - p.on 'detach', (socket, index) -> - chai.expect(socket).to.equal expectedSockets.shift() - chai.expect(index).to.equal expected.shift() - chai.expect(p.isAttached(index)).to.equal false - atts = expectedAttached.shift() - chai.expect(p.listAttached()).to.eql atts - for att in atts - chai.expect(p.isAttached(att)).to.equal true - done() unless expected.length - p.detach s2 - p.detach s3 - - describe 'with caching ports', -> - s1 = s2 = s3 = null - beforeEach -> - s1 = new noflo.internalSocket.InternalSocket - s2 = new noflo.internalSocket.InternalSocket - s3 = new noflo.internalSocket.InternalSocket - - it 'should repeat the previously sent value on attach event', (done) -> - p = new noflo.OutPort - caching: true - - s1.once 'data', (data) -> - chai.expect(data).to.equal 'foo' - - s2.once 'data', (data) -> - chai.expect(data).to.equal 'foo' - # Next value should be different - s2.once 'data', (data) -> - chai.expect(data).to.equal 'bar' - done() - - p.attach s1 - p.send 'foo' - p.disconnect() - - p.attach s2 - - p.send 'bar' - p.disconnect() - - - it 'should support addressable ports', (done) -> - p = new noflo.OutPort - addressable: true - caching: true - - p.attach s1 - p.attach s2 - - s1.on 'data', -> - chai.expect(true).to.equal false - s2.on 'data', (data) -> - chai.expect(data).to.equal 'some-data' - s3.on 'data', (data) -> - chai.expect(data).to.equal 'some-data' - done() - - p.send 'some-data', 1 - p.disconnect 1 - p.detach s2 - p.attach s3, 1 - - describe 'with IP objects', -> - s1 = s2 = s3 = null - beforeEach -> - s1 = new noflo.internalSocket.InternalSocket - s2 = new noflo.internalSocket.InternalSocket - s3 = new noflo.internalSocket.InternalSocket - - it 'should send data IPs and substreams', (done) -> - p = new noflo.OutPort - p.attach s1 - expectedEvents = [ - 'data' - 'openBracket' - 'data' - 'closeBracket' - ] - count = 0 - s1.on 'ip', (data) -> - count++ - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal expectedEvents.shift() - chai.expect(data.data).to.equal 'my-data' if data.type is 'data' - done() if count is 4 - p.data 'my-data' - p.openBracket() - .data 'my-data' - .closeBracket() - - it 'should send non-clonable objects by reference', (done) -> - p = new noflo.OutPort - p.attach s1 - p.attach s2 - p.attach s3 - - obj = - foo: 123 - bar: - boo: 'baz' - func: -> this.foo = 456 - - s1.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.data).to.equal obj - chai.expect(data.data.func).to.be.a 'function' - s2.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.data).to.equal obj - chai.expect(data.data.func).to.be.a 'function' - s3.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.data).to.equal obj - chai.expect(data.data.func).to.be.a 'function' - done() - - p.data obj, - clonable: false # default - - it 'should clone clonable objects on fan-out', (done) -> - p = new noflo.OutPort - p.attach s1 - p.attach s2 - p.attach s3 - - obj = - foo: 123 - bar: - boo: 'baz' - func: -> this.foo = 456 - - s1.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - # First send is non-cloning - chai.expect(data.data).to.equal obj - chai.expect(data.data.func).to.be.a 'function' - s2.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.data).to.not.equal obj - chai.expect(data.data.foo).to.equal obj.foo - chai.expect(data.data.bar).to.eql obj.bar - chai.expect(data.data.func).to.be.undefined - s3.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.data).to.not.equal obj - chai.expect(data.data.foo).to.equal obj.foo - chai.expect(data.data.bar).to.eql obj.bar - chai.expect(data.data.func).to.be.undefined - done() - - p.data obj, - clonable: true - - it 'should stamp an IP object with the port\'s datatype', (done) -> - p = new noflo.OutPort - datatype: 'string' - p.attach s1 - s1.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 'Hello' - chai.expect(data.datatype).to.equal 'string' - done() - p.data 'Hello' - it 'should keep an IP object\'s datatype as-is if already set', (done) -> - p = new noflo.OutPort - datatype: 'string' - p.attach s1 - s1.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 123 - chai.expect(data.datatype).to.equal 'integer' - done() - p.sendIP new noflo.IP 'data', 123, - datatype: 'integer' - - it 'should stamp an IP object with the port\'s schema', (done) -> - p = new noflo.OutPort - datatype: 'string' - schema: 'text/markdown' - p.attach s1 - s1.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 'Hello' - chai.expect(data.datatype).to.equal 'string' - chai.expect(data.schema).to.equal 'text/markdown' - done() - p.data 'Hello' - it 'should keep an IP object\'s schema as-is if already set', (done) -> - p = new noflo.OutPort - datatype: 'string' - schema: 'text/markdown' - p.attach s1 - s1.on 'ip', (data) -> - chai.expect(data).to.be.an 'object' - chai.expect(data.type).to.equal 'data' - chai.expect(data.data).to.equal 'Hello' - chai.expect(data.datatype).to.equal 'string' - chai.expect(data.schema).to.equal 'text/plain' - done() - p.sendIP new noflo.IP 'data', 'Hello', - datatype: 'string' - schema: 'text/plain' diff --git a/spec/OutPort.js b/spec/OutPort.js new file mode 100644 index 000000000..9efe3249e --- /dev/null +++ b/spec/OutPort.js @@ -0,0 +1,300 @@ +let chai; let noflo; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); +} else { + noflo = require('noflo'); +} + +describe('Outport Port', () => { + describe('with addressable ports', () => { + let s1 = null; let s2 = null; let s3 = null; + beforeEach(() => { + s1 = new noflo.internalSocket.InternalSocket(); + s2 = new noflo.internalSocket.InternalSocket(); + s3 = new noflo.internalSocket.InternalSocket(); + }); + it('should be able to send to a specific port', () => { + const p = new noflo.OutPort({ addressable: true }); + p.attach(s1); + p.attach(s2); + p.attach(s3); + chai.expect(p.listAttached()).to.eql([0, 1, 2]); + s1.on('data', () => { + chai.expect(true).to.equal(false); + }); + s2.on('data', (data) => { + chai.expect(data).to.equal('some-data'); + }); + s3.on('data', () => { + chai.expect(true).to.equal(false); + }); + p.send('some-data', 1); + }); + it('should be able to send to index 0', (done) => { + const p = new noflo.OutPort({ addressable: true }); + p.attach(s1); + s1.on('data', (data) => { + chai.expect(data).to.equal('my-data'); + done(); + }); + p.send('my-data', 0); + }); + it('should throw an error when sent data without address', () => { + chai.expect(() => p.send('some-data')).to.throw; + }); + it('should throw an error when a specific port is requested with non-addressable port', () => { + const p = new noflo.OutPort(); + p.attach(s1); + p.attach(s2); + p.attach(s3); + chai.expect(() => p.send('some-data', 1)).to.throw; + }); + it('should give correct port index when detaching a connection', (done) => { + const p = new noflo.OutPort({ addressable: true }); + p.attach(s1, 3); + p.attach(s2, 1); + p.attach(s3, 5); + const expectedSockets = [s2, s3]; + const expected = [1, 5]; + const expectedAttached = [ + [3, 5], + [3], + ]; + p.on('detach', (socket, index) => { + chai.expect(socket).to.equal(expectedSockets.shift()); + chai.expect(index).to.equal(expected.shift()); + chai.expect(p.isAttached(index)).to.equal(false); + const atts = expectedAttached.shift(); + chai.expect(p.listAttached()).to.eql(atts); + for (const att of atts) { + chai.expect(p.isAttached(att)).to.equal(true); + } + if (!expected.length) { done(); } + }); + p.detach(s2); + p.detach(s3); + }); + }); + describe('with caching ports', () => { + let s1 = null; let s2 = null; let s3 = null; + beforeEach(() => { + s1 = new noflo.internalSocket.InternalSocket(); + s2 = new noflo.internalSocket.InternalSocket(); + s3 = new noflo.internalSocket.InternalSocket(); + }); + it('should repeat the previously sent value on attach event', (done) => { + const p = new noflo.OutPort({ caching: true }); + + s1.once('data', (data) => { + chai.expect(data).to.equal('foo'); + }); + s2.once('data', (data) => { + chai.expect(data).to.equal('foo'); + // Next value should be different + s2.once('data', (data) => { + chai.expect(data).to.equal('bar'); + done(); + }); + }); + p.attach(s1); + p.send('foo'); + p.disconnect(); + + p.attach(s2); + + p.send('bar'); + p.disconnect(); + }); + it('should support addressable ports', (done) => { + const p = new noflo.OutPort({ + addressable: true, + caching: true, + }); + + p.attach(s1); + p.attach(s2); + + s1.on('data', () => { + chai.expect(true).to.equal(false); + }); + s2.on('data', (data) => { + chai.expect(data).to.equal('some-data'); + }); + s3.on('data', (data) => { + chai.expect(data).to.equal('some-data'); + done(); + }); + + p.send('some-data', 1); + p.disconnect(1); + p.detach(s2); + p.attach(s3, 1); + }); + }); + describe('with IP objects', () => { + let s1 = null; let s2 = null; let s3 = null; + beforeEach(() => { + s1 = new noflo.internalSocket.InternalSocket(); + s2 = new noflo.internalSocket.InternalSocket(); + s3 = new noflo.internalSocket.InternalSocket(); + }); + it('should send data IPs and substreams', (done) => { + const p = new noflo.OutPort(); + p.attach(s1); + const expectedEvents = [ + 'data', + 'openBracket', + 'data', + 'closeBracket', + ]; + let count = 0; + s1.on('ip', (data) => { + count++; + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal(expectedEvents.shift()); + if (data.type === 'data') { chai.expect(data.data).to.equal('my-data'); } + if (count === 4) { done(); } + }); + p.data('my-data'); + p.openBracket() + .data('my-data') + .closeBracket(); + }); + it('should send non-clonable objects by reference', (done) => { + const p = new noflo.OutPort(); + p.attach(s1); + p.attach(s2); + p.attach(s3); + + const obj = { + foo: 123, + bar: { + boo: 'baz', + }, + func() { return this.foo = 456; }, + }; + + s1.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.data).to.equal(obj); + chai.expect(data.data.func).to.be.a('function'); + s2.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.data).to.equal(obj); + chai.expect(data.data.func).to.be.a('function'); + s3.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.data).to.equal(obj); + chai.expect(data.data.func).to.be.a('function'); + done(); + }); + }); + }); + + p.data(obj, + { clonable: false }); // default + }); + it('should clone clonable objects on fan-out', (done) => { + const p = new noflo.OutPort(); + p.attach(s1); + p.attach(s2); + p.attach(s3); + + const obj = { + foo: 123, + bar: { + boo: 'baz', + }, + func() { + this.foo = 456; + }, + }; + + s1.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + // First send is non-cloning + chai.expect(data.data).to.equal(obj); + chai.expect(data.data.func).to.be.a('function'); + s2.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.data).to.not.equal(obj); + chai.expect(data.data.foo).to.equal(obj.foo); + chai.expect(data.data.bar).to.eql(obj.bar); + chai.expect(data.data.func).to.be.undefined; + s3.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.data).to.not.equal(obj); + chai.expect(data.data.foo).to.equal(obj.foo); + chai.expect(data.data.bar).to.eql(obj.bar); + chai.expect(data.data.func).to.be.undefined; + done(); + }); + }); + }); + + p.data(obj, + { clonable: true }); + }); + it('should stamp an IP object with the port\'s datatype', (done) => { + const p = new noflo.OutPort({ datatype: 'string' }); + p.attach(s1); + s1.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal('Hello'); + chai.expect(data.datatype).to.equal('string'); + done(); + }); + p.data('Hello'); + }); + it('should keep an IP object\'s datatype as-is if already set', (done) => { + const p = new noflo.OutPort({ datatype: 'string' }); + p.attach(s1); + s1.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal(123); + chai.expect(data.datatype).to.equal('integer'); + done(); + }); + p.sendIP(new noflo.IP('data', 123, + { datatype: 'integer' })); + }); + it('should stamp an IP object with the port\'s schema', (done) => { + const p = new noflo.OutPort({ + datatype: 'string', + schema: 'text/markdown', + }); + p.attach(s1); + s1.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal('Hello'); + chai.expect(data.datatype).to.equal('string'); + chai.expect(data.schema).to.equal('text/markdown'); + done(); + }); + p.data('Hello'); + }); + it('should keep an IP object\'s schema as-is if already set', (done) => { + const p = new noflo.OutPort({ + datatype: 'string', + schema: 'text/markdown', + }); + p.attach(s1); + s1.on('ip', (data) => { + chai.expect(data).to.be.an('object'); + chai.expect(data.type).to.equal('data'); + chai.expect(data.data).to.equal('Hello'); + chai.expect(data.datatype).to.equal('string'); + chai.expect(data.schema).to.equal('text/plain'); + done(); + }); + p.sendIP(new noflo.IP('data', 'Hello', { + datatype: 'string', + schema: 'text/plain', + })); + }); + }); +}); diff --git a/spec/Ports.coffee b/spec/Ports.coffee deleted file mode 100644 index 5758c37b3..000000000 --- a/spec/Ports.coffee +++ /dev/null @@ -1,67 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' -else - noflo = require 'noflo' - -describe 'Ports collection', -> - describe 'InPorts', -> - p = new noflo.InPorts - it 'should initially contain no ports', -> - chai.expect(p.ports).to.eql {} - it 'should allow adding a port', -> - p.add 'foo', - datatype: 'string' - chai.expect(p.ports['foo']).to.be.an 'object' - chai.expect(p.ports['foo'].getDataType()).to.equal 'string' - it 'should allow overriding a port', -> - p.add 'foo', - datatype: 'boolean' - chai.expect(p.ports['foo']).to.be.an 'object' - chai.expect(p.ports['foo'].getDataType()).to.equal 'boolean' - it 'should throw if trying to add an \'add\' port', -> - chai.expect(-> p.add 'add').to.throw() - it 'should throw if trying to add an \'remove\' port', -> - chai.expect(-> p.add 'remove').to.throw() - it 'should throw if trying to add a port with invalid characters', -> - chai.expect(-> p.add 'hello world!').to.throw() - it 'should throw if trying to remove a port that doesn\'t exist', -> - chai.expect(-> p.remove 'bar').to.throw() - it 'should throw if trying to subscribe to a port that doesn\'t exist', -> - chai.expect(-> p.once 'bar', 'ip', ->).to.throw() - chai.expect(-> p.on 'bar', 'ip', ->).to.throw() - it 'should allow subscribing to an existing port', (done) -> - received = 0 - p.once 'foo', 'ip', (packet) -> - received++ - return done() if received is 2 - p.on 'foo', 'ip', (packet) -> - received++ - return done() if received is 2 - p.foo.handleIP new noflo.IP 'data', null - it 'should be able to remove a port', -> - p.remove 'foo' - chai.expect(p.ports).to.eql {} - describe 'OutPorts', -> - p = new noflo.OutPorts - it 'should initially contain no ports', -> - chai.expect(p.ports).to.eql {} - it 'should allow adding a port', -> - p.add 'foo', - datatype: 'string' - chai.expect(p.ports['foo']).to.be.an 'object' - chai.expect(p.ports['foo'].getDataType()).to.equal 'string' - it 'should throw if trying to add an \'add\' port', -> - chai.expect(-> p.add 'add').to.throw() - it 'should throw if trying to add an \'remove\' port', -> - chai.expect(-> p.add 'remove').to.throw() - it 'should throw when calling connect with port that doesn\'t exist', -> - chai.expect(-> p.connect 'bar').to.throw() - it 'should throw when calling beginGroup with port that doesn\'t exist', -> - chai.expect(-> p.beginGroup 'bar').to.throw() - it 'should throw when calling send with port that doesn\'t exist', -> - chai.expect(-> p.send 'bar').to.throw() - it 'should throw when calling endGroup with port that doesn\'t exist', -> - chai.expect(-> p.endGroup 'bar').to.throw() - it 'should throw when calling disconnect with port that doesn\'t exist', -> - chai.expect(-> p.disconnect 'bar').to.throw() diff --git a/spec/Ports.js b/spec/Ports.js new file mode 100644 index 000000000..dfe77f912 --- /dev/null +++ b/spec/Ports.js @@ -0,0 +1,93 @@ +let chai; let noflo; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); +} else { + noflo = require('noflo'); +} + +describe('Ports collection', () => { + describe('InPorts', () => { + const p = new noflo.InPorts(); + it('should initially contain no ports', () => { + chai.expect(p.ports).to.eql({}); + }); + it('should allow adding a port', () => { + p.add('foo', + { datatype: 'string' }); + chai.expect(p.ports.foo).to.be.an('object'); + chai.expect(p.ports.foo.getDataType()).to.equal('string'); + }); + it('should allow overriding a port', () => { + p.add('foo', + { datatype: 'boolean' }); + chai.expect(p.ports.foo).to.be.an('object'); + chai.expect(p.ports.foo.getDataType()).to.equal('boolean'); + }); + it('should throw if trying to add an \'add\' port', () => { + chai.expect(() => p.add('add')).to.throw(); + }); + it('should throw if trying to add an \'remove\' port', () => { + chai.expect(() => p.add('remove')).to.throw(); + }); + it('should throw if trying to add a port with invalid characters', () => { + chai.expect(() => p.add('hello world!')).to.throw(); + }); + it('should throw if trying to remove a port that doesn\'t exist', () => { + chai.expect(() => p.remove('bar')).to.throw(); + }); + it('should throw if trying to subscribe to a port that doesn\'t exist', () => { + chai.expect(() => p.once('bar', 'ip', () => {})).to.throw(); + chai.expect(() => p.on('bar', 'ip', () => {})).to.throw(); + }); + it('should allow subscribing to an existing port', (done) => { + let received = 0; + p.once('foo', 'ip', () => { + received++; + if (received === 2) { done(); } + }); + p.on('foo', 'ip', () => { + received++; + if (received === 2) { done(); } + }); + p.foo.handleIP(new noflo.IP('data', null)); + }); + it('should be able to remove a port', () => { + p.remove('foo'); + chai.expect(p.ports).to.eql({}); + }); + }); + describe('OutPorts', () => { + const p = new noflo.OutPorts(); + it('should initially contain no ports', () => { + chai.expect(p.ports).to.eql({}); + }); + it('should allow adding a port', () => { + p.add('foo', + { datatype: 'string' }); + chai.expect(p.ports.foo).to.be.an('object'); + chai.expect(p.ports.foo.getDataType()).to.equal('string'); + }); + it('should throw if trying to add an \'add\' port', () => { + chai.expect(() => p.add('add')).to.throw(); + }); + it('should throw if trying to add an \'remove\' port', () => { + chai.expect(() => p.add('remove')).to.throw(); + }); + it('should throw when calling connect with port that doesn\'t exist', () => { + chai.expect(() => p.connect('bar')).to.throw(); + }); + it('should throw when calling beginGroup with port that doesn\'t exist', () => { + chai.expect(() => p.beginGroup('bar')).to.throw(); + }); + it('should throw when calling send with port that doesn\'t exist', () => { + chai.expect(() => p.send('bar')).to.throw(); + }); + it('should throw when calling endGroup with port that doesn\'t exist', () => { + chai.expect(() => p.endGroup('bar')).to.throw(); + }); + it('should throw when calling disconnect with port that doesn\'t exist', () => { + chai.expect(() => p.disconnect('bar')).to.throw(); + }); + }); +}); diff --git a/spec/Scoping.coffee b/spec/Scoping.coffee deleted file mode 100644 index f54ba1f6b..000000000 --- a/spec/Scoping.coffee +++ /dev/null @@ -1,761 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - path = require 'path' - root = path.resolve __dirname, '../' - urlPrefix = './' -else - noflo = require 'noflo' - root = 'noflo' - urlPrefix = '/' - -wirePatternAsync = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - noflo.helpers.WirePattern c, - in: 'in' - out: 'out' - async: true - forwardGroups: true - , (data, groups, out, callback) -> - setTimeout -> - out.send data + c.nodeId - callback() - , 1 - -wirePatternMerge = -> - c = new noflo.Component - c.inPorts.add 'in1', - datatype: 'string' - c.inPorts.add 'in2', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - noflo.helpers.WirePattern c, - in: ['in1', 'in2'] - out: 'out' - async: true - forwardGroups: true - , (data, groups, out, callback) -> - out.send "1#{data['in1']}#{c.nodeId}2#{data['in2']}#{c.nodeId}" - callback() - -processAsync = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - c.process (input, output) -> - data = input.getData 'in' - setTimeout -> - output.sendDone data + c.nodeId - , 1 - -processMerge = -> - c = new noflo.Component - c.inPorts.add 'in1', - datatype: 'string' - c.inPorts.add 'in2', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - - c.forwardBrackets = - 'in1': ['out'] - - c.process (input, output) -> - return unless input.has 'in1', 'in2', (ip) -> ip.type is 'data' - first = input.getData 'in1' - second = input.getData 'in2' - - output.sendDone - out: "1#{first}:2#{second}:#{c.nodeId}" - -processMergeUnscoped = -> - c = new noflo.Component - c.inPorts.add 'in1', - datatype: 'string' - c.inPorts.add 'in2', - datatype: 'string' - scoped: false - c.outPorts.add 'out', - datatype: 'string' - - c.forwardBrackets = - 'in1': ['out'] - - c.process (input, output) -> - return unless input.has 'in1', 'in2', (ip) -> ip.type is 'data' - first = input.getData 'in1' - second = input.getData 'in2' - - output.sendDone - out: "1#{first}:2#{second}:#{c.nodeId}" - -processUnscope = -> - c = new noflo.Component - c.inPorts.add 'in', - datatype: 'string' - c.outPorts.add 'out', - datatype: 'string' - scoped: false - - c.process (input, output) -> - data = input.getData 'in' - setTimeout -> - output.sendDone data + c.nodeId - , 1 - -# Merge with an addressable port -processMergeA = -> - c = new noflo.Component - c.inPorts.add 'in1', - datatype: 'string' - c.inPorts.add 'in2', - datatype: 'string' - addressable: true - c.outPorts.add 'out', - datatype: 'string' - - c.forwardBrackets = - 'in1': ['out'] - - c.process (input, output) -> - return unless input.hasData 'in1', ['in2', 0], ['in2', 1] - first = input.getData 'in1' - second0 = input.getData ['in2', 0] - second1 = input.getData ['in2', 1] - - output.sendDone - out: "1#{first}:2#{second0}:2#{second1}:#{c.nodeId}" - -describe 'Scope isolation', -> - loader = null - before (done) -> - loader = new noflo.ComponentLoader root - loader.listComponents (err) -> - return done err if err - loader.registerComponent 'wirepattern', 'Async', wirePatternAsync - loader.registerComponent 'wirepattern', 'Merge', wirePatternMerge - loader.registerComponent 'process', 'Async', processAsync - loader.registerComponent 'process', 'Merge', processMerge - loader.registerComponent 'process', 'MergeA', processMergeA - loader.registerComponent 'process', 'Unscope', processUnscope - loader.registerComponent 'process', 'MergeUnscoped', processMergeUnscoped - done() - - describe 'with WirePattern sending to Process API', -> - c = null - ins = null - out = null - before (done) -> - fbpData = " - INPORT=Wp.IN:IN - OUTPORT=Pc.OUT:OUT - Wp(wirepattern/Async) OUT -> IN Pc(process/Async) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'Connected', g - loader.load 'scope/Connected', (err, instance) -> - return done err if err - c = instance - ins = noflo.internalSocket.createSocket() - c.inPorts.in.attach ins - c.setUp done - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach -> - c.outPorts.out.detach out - out = null - - it 'should forward old-style groups as expected', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA bazWpPc' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - chai.expect(received).to.eql expected - done() - - ins.connect() - ins.beginGroup 1 - ins.beginGroup 'a' - ins.send 'baz' - ins.endGroup() - ins.endGroup() - ins.disconnect() - it 'should forward new-style brackets as expected', (done) -> - expected = [ - '< 1' - '< a' - 'DATA fooWpPc' - '>' - '>' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "< #{ip.data}" - brackets.push ip.data - when 'data' - received.push "DATA #{ip.data}" - when 'closeBracket' - received.push '>' - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - - ins.post new noflo.IP 'openBracket', 1 - ins.post new noflo.IP 'openBracket', 'a' - ins.post new noflo.IP 'data', 'foo' - ins.post new noflo.IP 'closeBracket', 'a' - ins.post new noflo.IP 'closeBracket', 1 - it 'should forward scopes as expected', (done) -> - expected = [ - 'x < 1' - 'x < a' - 'x DATA barWpPc' - 'x >' - 'x >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - - ins.post new noflo.IP 'openBracket', 1, - scope: 'x' - ins.post new noflo.IP 'openBracket', 'a', - scope: 'x' - ins.post new noflo.IP 'data', 'bar', - scope: 'x' - ins.post new noflo.IP 'closeBracket', 'a', - scope: 'x' - ins.post new noflo.IP 'closeBracket', 1, - scope: 'x' - - describe 'pure Process API merging two inputs', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Pc1.IN:IN1 - INPORT=Pc2.IN:IN2 - OUTPORT=PcMerge.OUT:OUT - Pc1(process/Async) OUT -> IN1 PcMerge(process/Merge) - Pc2(process/Async) OUT -> IN2 PcMerge(process/Merge) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'Merge', g - loader.load 'scope/Merge', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - in2 = noflo.internalSocket.createSocket() - c.inPorts.in2.attach in2 - c.setUp done - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach -> - c.outPorts.out.detach out - out = null - - it 'should forward new-style brackets as expected', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazPc1:2fooPc2:PcMerge' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - chai.expect(received).to.eql expected - done() - - in2.connect() - in2.send 'foo' - in2.disconnect() - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - it 'should forward new-style brackets as expected regardless of sending order', (done) -> - expected = [ - 'CONN' - '< 1' - '< a' - 'DATA 1bazPc1:2fooPc2:PcMerge' - '>' - '>' - 'DISC' - ] - received = [] - - out.on 'connect', -> - received.push 'CONN' - out.on 'begingroup', (group) -> - received.push "< #{group}" - out.on 'data', (data) -> - received.push "DATA #{data}" - out.on 'endgroup', -> - received.push '>' - out.on 'disconnect', -> - received.push 'DISC' - chai.expect(received).to.eql expected - done() - - in1.connect() - in1.beginGroup 1 - in1.beginGroup 'a' - in1.send 'baz' - in1.endGroup() - in1.endGroup() - in1.disconnect() - in2.connect() - in2.send 'foo' - in2.disconnect() - it 'should forward scopes as expected', (done) -> - expected = [ - 'x < 1' - 'x DATA 1onePc1:2twoPc2:PcMerge' - 'x >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - - in2.post new noflo.IP 'data', 'two', - scope: 'x' - in1.post new noflo.IP 'openBracket', 1, - scope: 'x' - in1.post new noflo.IP 'data', 'one', - scope: 'x' - in1.post new noflo.IP 'closeBracket', 1, - scope: 'x' - it 'should not forward when scopes don\'t match', (done) -> - out.on 'ip', (ip) -> - throw new Error "Received unexpected #{ip.type} packet" - c.network.once 'end', -> - done() - in2.post new noflo.IP 'data', 'two', scope: 2 - in1.post new noflo.IP 'openBracket', 1, scope: 1 - in1.post new noflo.IP 'data', 'one', scope: 1 - in1.post new noflo.IP 'closeBracket', 1, scope: 1 - - describe 'Process API with IIPs and scopes', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Pc1.IN:IN1 - OUTPORT=PcMerge.OUT:OUT - Pc1(process/Async) -> IN1 PcMerge(process/Merge) - 'twoIIP' -> IN2 PcMerge(process/Merge) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'MergeIIP', g - loader.load 'scope/MergeIIP', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - c.setUp done - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach -> - c.outPorts.out.detach out - out = null - - it 'should forward scopes as expected', (done) -> - expected = [ - 'x < 1' - 'x DATA 1onePc1:2twoIIP:PcMerge' - 'x >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - - in1.post new noflo.IP 'openBracket', 1, scope: 'x' - in1.post new noflo.IP 'data', 'one', scope: 'x' - in1.post new noflo.IP 'closeBracket', 1, scope: 'x' - - describe 'Process API with unscoped inport and scopes', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Pc1.IN:IN1 - INPORT=Pc2.IN:IN2 - OUTPORT=PcMerge.OUT:OUT - Pc1(process/Async) -> IN1 PcMerge(process/MergeUnscoped) - Pc2(process/Async) -> IN2 PcMerge(process/MergeUnscoped) - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'MergeUnscoped', g - loader.load 'scope/MergeUnscoped', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - in2 = noflo.internalSocket.createSocket() - c.inPorts.in2.attach in2 - c.setUp done - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach -> - c.outPorts.out.detach out - out = null - it 'should forward scopes as expected', (done) -> - expected = [ - 'x < 1' - 'x DATA 1onePc1:2twoPc2:PcMerge' - 'x >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - - in1.post new noflo.IP 'openBracket', 1, scope: 'x' - in1.post new noflo.IP 'data', 'one', scope: 'x' - in1.post new noflo.IP 'closeBracket', 1, scope: 'x' - in2.post new noflo.IP 'openBracket', 1, scope: 'x' - in2.post new noflo.IP 'data', 'two', scope: 'x' - in2.post new noflo.IP 'closeBracket', 1, scope: 'x' - it 'should forward packets without scopes', (done) -> - expected = [ - 'null < 1' - 'null DATA 1onePc1:2twoPc2:PcMerge' - 'null >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - in1.post new noflo.IP 'openBracket', 1 - in1.post new noflo.IP 'data', 'one' - in1.post new noflo.IP 'closeBracket' - in2.post new noflo.IP 'openBracket', 1 - in2.post new noflo.IP 'data', 'two' - in2.post new noflo.IP 'closeBracket', 1 - it 'should forward scopes also on unscoped packet', (done) -> - expected = [ - 'x < 1' - 'x DATA 1onePc1:2twoPc2:PcMerge' - 'x >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - in2.post new noflo.IP 'openBracket', 1 - in2.post new noflo.IP 'data', 'two' - in2.post new noflo.IP 'closeBracket', 1 - in1.post new noflo.IP 'openBracket', 1, scope: 'x' - in1.post new noflo.IP 'data', 'one', scope: 'x' - in1.post new noflo.IP 'closeBracket', 1, scope: 'x' - - describe 'Process API with unscoped outport and scopes', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Pc1.IN:IN1 - INPORT=Pc2.IN:IN2 - OUTPORT=PcMerge.OUT:OUT - Pc1(process/Unscope) -> IN1 PcMerge(process/Merge) - Pc2(process/Unscope) -> IN2 PcMerge - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'MergeUnscopedOut', g - loader.load 'scope/MergeUnscopedOut', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - in2 = noflo.internalSocket.createSocket() - c.inPorts.in2.attach in2 - c.setUp done - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach -> - c.outPorts.out.detach out - out = null - it 'should remove scopes as expected', (done) -> - expected = [ - 'null < 1' - 'null DATA 1onePc1:2twoPc2:PcMerge' - 'null >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - - in1.post new noflo.IP 'openBracket', 1, scope: 'x' - in1.post new noflo.IP 'data', 'one', scope: 'x' - in1.post new noflo.IP 'closeBracket', 1, scope: 'x' - in2.post new noflo.IP 'openBracket', 1, scope: 'y' - in2.post new noflo.IP 'data', 'two', scope: 'y' - in2.post new noflo.IP 'closeBracket', 1, scope: 'y' - it 'should forward packets without scopes', (done) -> - expected = [ - 'null < 1' - 'null DATA 1onePc1:2twoPc2:PcMerge' - 'null >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - in1.post new noflo.IP 'openBracket', 1 - in1.post new noflo.IP 'data', 'one' - in1.post new noflo.IP 'closeBracket' - in2.post new noflo.IP 'openBracket', 1 - in2.post new noflo.IP 'data', 'two' - in2.post new noflo.IP 'closeBracket', 1 - it 'should remove scopes also on unscoped packet', (done) -> - expected = [ - 'null < 1' - 'null DATA 1onePc1:2twoPc2:PcMerge' - 'null >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - in1.post new noflo.IP 'openBracket', 1, scope: 'x' - in1.post new noflo.IP 'data', 'one', scope: 'x' - in1.post new noflo.IP 'closeBracket', 1, scope: 'x' - in2.post new noflo.IP 'openBracket', 1 - in2.post new noflo.IP 'data', 'two' - in2.post new noflo.IP 'closeBracket', 1 - - describe 'Process API with IIPs to addressable ports and scopes', -> - c = null - in1 = null - in2 = null - out = null - before (done) -> - fbpData = " - INPORT=Pc1.IN:IN1 - OUTPORT=PcMergeA.OUT:OUT - Pc1(process/Async) -> IN1 PcMergeA(process/MergeA) - 'twoIIP0' -> IN2[0] PcMergeA - 'twoIIP1' -> IN2[1] PcMergeA - " - noflo.graph.loadFBP fbpData, (err, g) -> - return done err if err - loader.registerComponent 'scope', 'MergeIIPA', g - loader.load 'scope/MergeIIPA', (err, instance) -> - return done err if err - c = instance - in1 = noflo.internalSocket.createSocket() - c.inPorts.in1.attach in1 - c.setUp done - beforeEach -> - out = noflo.internalSocket.createSocket() - c.outPorts.out.attach out - afterEach -> - c.outPorts.out.detach out - out = null - - it 'should forward scopes as expected', (done) -> - expected = [ - 'x < 1' - 'x DATA 1onePc1:2twoIIP0:2twoIIP1:PcMergeA' - 'x >' - ] - received = [] - brackets = [] - - out.on 'ip', (ip) -> - switch ip.type - when 'openBracket' - received.push "#{ip.scope} < #{ip.data}" - brackets.push ip.data - when 'data' - received.push "#{ip.scope} DATA #{ip.data}" - when 'closeBracket' - received.push "#{ip.scope} >" - brackets.pop() - return if brackets.length - chai.expect(received).to.eql expected - done() - - in1.post new noflo.IP 'openBracket', 1, scope: 'x' - in1.post new noflo.IP 'data', 'one', scope: 'x' - in1.post new noflo.IP 'closeBracket', 1, scope: 'x' diff --git a/spec/Scoping.js b/spec/Scoping.js new file mode 100644 index 000000000..56e6bc6f5 --- /dev/null +++ b/spec/Scoping.js @@ -0,0 +1,726 @@ +let chai; let noflo; let root; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + const path = require('path'); + root = path.resolve(__dirname, '../'); +} else { + noflo = require('noflo'); + root = 'noflo'; +} + +const processAsync = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + + c.process((input, output) => { + const data = input.getData('in'); + setTimeout(() => output.sendDone(data + c.nodeId), + 1); + }); + return c; +}; + +const processMerge = function () { + const c = new noflo.Component(); + c.inPorts.add('in1', + { datatype: 'string' }); + c.inPorts.add('in2', + { datatype: 'string' }); + c.outPorts.add('out', + { datatype: 'string' }); + + c.forwardBrackets = { in1: ['out'] }; + + c.process((input, output) => { + if (!input.has('in1', 'in2', (ip) => ip.type === 'data')) { return; } + const first = input.getData('in1'); + const second = input.getData('in2'); + + output.sendDone({ out: `1${first}:2${second}:${c.nodeId}` }); + }); + return c; +}; + +const processMergeUnscoped = function () { + const c = new noflo.Component(); + c.inPorts.add('in1', + { datatype: 'string' }); + c.inPorts.add('in2', { + datatype: 'string', + scoped: false, + }); + c.outPorts.add('out', + { datatype: 'string' }); + + c.forwardBrackets = { in1: ['out'] }; + + c.process((input, output) => { + if (!input.has('in1', 'in2', (ip) => ip.type === 'data')) { return; } + const first = input.getData('in1'); + const second = input.getData('in2'); + + output.sendDone({ out: `1${first}:2${second}:${c.nodeId}` }); + }); + return c; +}; + +const processUnscope = function () { + const c = new noflo.Component(); + c.inPorts.add('in', + { datatype: 'string' }); + c.outPorts.add('out', { + datatype: 'string', + scoped: false, + }); + + c.process((input, output) => { + const data = input.getData('in'); + setTimeout(() => { + output.sendDone(data + c.nodeId); + }, + 1); + }); + return c; +}; + +// Merge with an addressable port +const processMergeA = function () { + const c = new noflo.Component(); + c.inPorts.add('in1', + { datatype: 'string' }); + c.inPorts.add('in2', { + datatype: 'string', + addressable: true, + }); + c.outPorts.add('out', + { datatype: 'string' }); + + c.forwardBrackets = { in1: ['out'] }; + + c.process((input, output) => { + if (!input.hasData('in1', ['in2', 0], ['in2', 1])) { return; } + const first = input.getData('in1'); + const second0 = input.getData(['in2', 0]); + const second1 = input.getData(['in2', 1]); + + output.sendDone({ out: `1${first}:2${second0}:2${second1}:${c.nodeId}` }); + }); + return c; +}; + +describe('Scope isolation', () => { + let loader = null; + before((done) => { + loader = new noflo.ComponentLoader(root); + loader.listComponents((err) => { + if (err) { + done(err); + return; + } + loader.registerComponent('process', 'Async', processAsync); + loader.registerComponent('process', 'Merge', processMerge); + loader.registerComponent('process', 'MergeA', processMergeA); + loader.registerComponent('process', 'Unscope', processUnscope); + loader.registerComponent('process', 'MergeUnscoped', processMergeUnscoped); + done(); + }); + }); + describe('pure Process API merging two inputs', () => { + let c = null; + let in1 = null; + let in2 = null; + let out = null; + before((done) => { + const fbpData = 'INPORT=Pc1.IN:IN1\n' + + 'INPORT=Pc2.IN:IN2\n' + + 'OUTPORT=PcMerge.OUT:OUT\n' + + 'Pc1(process/Async) OUT -> IN1 PcMerge(process/Merge)\n' + + 'Pc2(process/Async) OUT -> IN2 PcMerge(process/Merge)'; + noflo.graph.loadFBP(fbpData, (err, g) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'Merge', g); + loader.load('scope/Merge', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + in1 = noflo.internalSocket.createSocket(); + c.inPorts.in1.attach(in1); + in2 = noflo.internalSocket.createSocket(); + c.inPorts.in2.attach(in2); + c.setUp(done); + }); + }); + }); + beforeEach(() => { + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + }); + afterEach(() => { + c.outPorts.out.detach(out); + out = null; + }); + it('should forward new-style brackets as expected', (done) => { + const expected = [ + 'CONN', + '< 1', + '< a', + 'DATA 1bazPc1:2fooPc2:PcMerge', + '>', + '>', + 'DISC', + ]; + const received = []; + + out.on('connect', () => { + received.push('CONN'); + }); + out.on('begingroup', (group) => { + received.push(`< ${group}`); + }); + out.on('data', (data) => { + received.push(`DATA ${data}`); + }); + out.on('endgroup', () => { + received.push('>'); + }); + out.on('disconnect', () => { + received.push('DISC'); + chai.expect(received).to.eql(expected); + done(); + }); + + in2.connect(); + in2.send('foo'); + in2.disconnect(); + in1.connect(); + in1.beginGroup(1); + in1.beginGroup('a'); + in1.send('baz'); + in1.endGroup(); + in1.endGroup(); + in1.disconnect(); + }); + it('should forward new-style brackets as expected regardless of sending order', (done) => { + const expected = [ + 'CONN', + '< 1', + '< a', + 'DATA 1bazPc1:2fooPc2:PcMerge', + '>', + '>', + 'DISC', + ]; + const received = []; + + out.on('connect', () => { + received.push('CONN'); + }); + out.on('begingroup', (group) => { + received.push(`< ${group}`); + }); + out.on('data', (data) => { + received.push(`DATA ${data}`); + }); + out.on('endgroup', () => { + received.push('>'); + }); + out.on('disconnect', () => { + received.push('DISC'); + chai.expect(received).to.eql(expected); + done(); + }); + + in1.connect(); + in1.beginGroup(1); + in1.beginGroup('a'); + in1.send('baz'); + in1.endGroup(); + in1.endGroup(); + in1.disconnect(); + in2.connect(); + in2.send('foo'); + in2.disconnect(); + }); + it('should forward scopes as expected', (done) => { + const expected = [ + 'x < 1', + 'x DATA 1onePc1:2twoPc2:PcMerge', + 'x >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + + in2.post(new noflo.IP('data', 'two', + { scope: 'x' })); + in1.post(new noflo.IP('openBracket', 1, + { scope: 'x' })); + in1.post(new noflo.IP('data', 'one', + { scope: 'x' })); + in1.post(new noflo.IP('closeBracket', 1, + { scope: 'x' })); + }); + it('should not forward when scopes don\'t match', (done) => { + out.on('ip', (ip) => { + throw new Error(`Received unexpected ${ip.type} packet`); + }); + c.network.once('end', () => { + done(); + }); + in2.post(new noflo.IP('data', 'two', { scope: 2 })); + in1.post(new noflo.IP('openBracket', 1, { scope: 1 })); + in1.post(new noflo.IP('data', 'one', { scope: 1 })); + in1.post(new noflo.IP('closeBracket', 1, { scope: 1 })); + }); + }); + describe('Process API with IIPs and scopes', () => { + let c = null; + let in1 = null; + let out = null; + before((done) => { + const fbpData = 'INPORT=Pc1.IN:IN1\n' + + 'OUTPORT=PcMerge.OUT:OUT\n' + + 'Pc1(process/Async) -> IN1 PcMerge(process/Merge)\n' + + '\'twoIIP\' -> IN2 PcMerge(process/Merge)'; + noflo.graph.loadFBP(fbpData, (err, g) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'MergeIIP', g); + loader.load('scope/MergeIIP', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + in1 = noflo.internalSocket.createSocket(); + c.inPorts.in1.attach(in1); + c.setUp(done); + }); + }); + }); + beforeEach(() => { + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + }); + afterEach(() => { + c.outPorts.out.detach(out); + out = null; + }); + it('should forward scopes as expected', (done) => { + const expected = [ + 'x < 1', + 'x DATA 1onePc1:2twoIIP:PcMerge', + 'x >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + + in1.post(new noflo.IP('openBracket', 1, { scope: 'x' })); + in1.post(new noflo.IP('data', 'one', { scope: 'x' })); + in1.post(new noflo.IP('closeBracket', 1, { scope: 'x' })); + }); + }); + describe('Process API with unscoped inport and scopes', () => { + let c = null; + let in1 = null; + let in2 = null; + let out = null; + before((done) => { + const fbpData = 'INPORT=Pc1.IN:IN1\n' + + 'INPORT=Pc2.IN:IN2\n' + + 'OUTPORT=PcMerge.OUT:OUT\n' + + 'Pc1(process/Async) -> IN1 PcMerge(process/MergeUnscoped)\n' + + 'Pc2(process/Async) -> IN2 PcMerge(process/MergeUnscoped)'; + noflo.graph.loadFBP(fbpData, (err, g) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'MergeUnscoped', g); + loader.load('scope/MergeUnscoped', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + in1 = noflo.internalSocket.createSocket(); + c.inPorts.in1.attach(in1); + in2 = noflo.internalSocket.createSocket(); + c.inPorts.in2.attach(in2); + c.setUp(done); + }); + }); + }); + beforeEach(() => { + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + }); + afterEach(() => { + c.outPorts.out.detach(out); + out = null; + }); + it('should forward scopes as expected', (done) => { + const expected = [ + 'x < 1', + 'x DATA 1onePc1:2twoPc2:PcMerge', + 'x >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + + in1.post(new noflo.IP('openBracket', 1, { scope: 'x' })); + in1.post(new noflo.IP('data', 'one', { scope: 'x' })); + in1.post(new noflo.IP('closeBracket', 1, { scope: 'x' })); + in2.post(new noflo.IP('openBracket', 1, { scope: 'x' })); + in2.post(new noflo.IP('data', 'two', { scope: 'x' })); + in2.post(new noflo.IP('closeBracket', 1, { scope: 'x' })); + }); + it('should forward packets without scopes', (done) => { + const expected = [ + 'null < 1', + 'null DATA 1onePc1:2twoPc2:PcMerge', + 'null >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + in1.post(new noflo.IP('openBracket', 1)); + in1.post(new noflo.IP('data', 'one')); + in1.post(new noflo.IP('closeBracket')); + in2.post(new noflo.IP('openBracket', 1)); + in2.post(new noflo.IP('data', 'two')); + in2.post(new noflo.IP('closeBracket', 1)); + }); + it('should forward scopes also on unscoped packet', (done) => { + const expected = [ + 'x < 1', + 'x DATA 1onePc1:2twoPc2:PcMerge', + 'x >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + in2.post(new noflo.IP('openBracket', 1)); + in2.post(new noflo.IP('data', 'two')); + in2.post(new noflo.IP('closeBracket', 1)); + in1.post(new noflo.IP('openBracket', 1, { scope: 'x' })); + in1.post(new noflo.IP('data', 'one', { scope: 'x' })); + in1.post(new noflo.IP('closeBracket', 1, { scope: 'x' })); + }); + }); + describe('Process API with unscoped outport and scopes', () => { + let c = null; + let in1 = null; + let in2 = null; + let out = null; + before((done) => { + const fbpData = 'INPORT=Pc1.IN:IN1\n' + + 'INPORT=Pc2.IN:IN2\n' + + 'OUTPORT=PcMerge.OUT:OUT\n' + + 'Pc1(process/Unscope) -> IN1 PcMerge(process/Merge)\n' + + 'Pc2(process/Unscope) -> IN2 PcMerge'; + noflo.graph.loadFBP(fbpData, (err, g) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'MergeUnscopedOut', g); + loader.load('scope/MergeUnscopedOut', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + in1 = noflo.internalSocket.createSocket(); + c.inPorts.in1.attach(in1); + in2 = noflo.internalSocket.createSocket(); + c.inPorts.in2.attach(in2); + c.setUp(done); + }); + }); + }); + beforeEach(() => { + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + }); + afterEach(() => { + c.outPorts.out.detach(out); + out = null; + }); + it('should remove scopes as expected', (done) => { + const expected = [ + 'null < 1', + 'null DATA 1onePc1:2twoPc2:PcMerge', + 'null >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + + in1.post(new noflo.IP('openBracket', 1, { scope: 'x' })); + in1.post(new noflo.IP('data', 'one', { scope: 'x' })); + in1.post(new noflo.IP('closeBracket', 1, { scope: 'x' })); + in2.post(new noflo.IP('openBracket', 1, { scope: 'y' })); + in2.post(new noflo.IP('data', 'two', { scope: 'y' })); + in2.post(new noflo.IP('closeBracket', 1, { scope: 'y' })); + }); + it('should forward packets without scopes', (done) => { + const expected = [ + 'null < 1', + 'null DATA 1onePc1:2twoPc2:PcMerge', + 'null >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + in1.post(new noflo.IP('openBracket', 1)); + in1.post(new noflo.IP('data', 'one')); + in1.post(new noflo.IP('closeBracket')); + in2.post(new noflo.IP('openBracket', 1)); + in2.post(new noflo.IP('data', 'two')); + in2.post(new noflo.IP('closeBracket', 1)); + }); + it('should remove scopes also on unscoped packet', (done) => { + const expected = [ + 'null < 1', + 'null DATA 1onePc1:2twoPc2:PcMerge', + 'null >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + in1.post(new noflo.IP('openBracket', 1, { scope: 'x' })); + in1.post(new noflo.IP('data', 'one', { scope: 'x' })); + in1.post(new noflo.IP('closeBracket', 1, { scope: 'x' })); + in2.post(new noflo.IP('openBracket', 1)); + in2.post(new noflo.IP('data', 'two')); + in2.post(new noflo.IP('closeBracket', 1)); + }); + }); + describe('Process API with IIPs to addressable ports and scopes', () => { + let c = null; + let in1 = null; + let out = null; + before((done) => { + const fbpData = 'INPORT=Pc1.IN:IN1\n' + + 'OUTPORT=PcMergeA.OUT:OUT\n' + + 'Pc1(process/Async) -> IN1 PcMergeA(process/MergeA)\n' + + '\'twoIIP0\' -> IN2[0] PcMergeA\n' + + '\'twoIIP1\' -> IN2[1] PcMergeA'; + noflo.graph.loadFBP(fbpData, (err, g) => { + if (err) { + done(err); + return; + } + loader.registerComponent('scope', 'MergeIIPA', g); + loader.load('scope/MergeIIPA', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + in1 = noflo.internalSocket.createSocket(); + c.inPorts.in1.attach(in1); + c.setUp(done); + }); + }); + }); + beforeEach(() => { + out = noflo.internalSocket.createSocket(); + c.outPorts.out.attach(out); + }); + afterEach(() => { + c.outPorts.out.detach(out); + out = null; + }); + it('should forward scopes as expected', (done) => { + const expected = [ + 'x < 1', + 'x DATA 1onePc1:2twoIIP0:2twoIIP1:PcMergeA', + 'x >', + ]; + const received = []; + const brackets = []; + + out.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + received.push(`${ip.scope} < ${ip.data}`); + brackets.push(ip.data); + break; + case 'data': + received.push(`${ip.scope} DATA ${ip.data}`); + break; + case 'closeBracket': + received.push(`${ip.scope} >`); + brackets.pop(); + if (brackets.length) { return; } + chai.expect(received).to.eql(expected); + done(); + break; + } + }); + + in1.post(new noflo.IP('openBracket', 1, { scope: 'x' })); + in1.post(new noflo.IP('data', 'one', { scope: 'x' })); + in1.post(new noflo.IP('closeBracket', 1, { scope: 'x' })); + }); + }); +}); diff --git a/spec/Subgraph.coffee b/spec/Subgraph.coffee deleted file mode 100644 index 21500be87..000000000 --- a/spec/Subgraph.coffee +++ /dev/null @@ -1,677 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - noflo = require '../src/lib/NoFlo.coffee' - path = require 'path' - root = path.resolve __dirname, '../' - urlPrefix = './' -else - noflo = require 'noflo' - root = 'noflo' - urlPrefix = '/' - -describe 'NoFlo Graph component', -> - c = null - g = null - loader = null - before (done) -> - loader = new noflo.ComponentLoader root - loader.listComponents done - beforeEach (done) -> - loader.load 'Graph', (err, instance) -> - return done err if err - c = instance - g = noflo.internalSocket.createSocket() - c.inPorts.graph.attach g - done() - return - - Split = -> - inst = new noflo.Component - inst.inPorts.add 'in', - datatype: 'all' - inst.outPorts.add 'out', - datatype: 'all' - inst.process (input, output) -> - data = input.getData 'in' - output.sendDone - out: data - - SubgraphMerge = -> - inst = new noflo.Component - inst.inPorts.add 'in', - datatype: 'all' - inst.outPorts.add 'out', - datatype: 'all' - inst.forwardBrackets = {} - inst.process (input, output) -> - packet = input.get 'in' - return output.done() unless packet.type is 'data' - output.sendDone - out: packet.data - - describe 'initially', -> - it 'should be ready', -> - chai.expect(c.ready).to.be.true - it 'should not contain a network', -> - chai.expect(c.network).to.be.null - it 'should have a baseDir', -> - chai.expect(c.baseDir).to.equal root - it 'should only have the graph inport', -> - chai.expect(c.inPorts.ports).to.have.keys ['graph'] - chai.expect(c.outPorts.ports).to.be.empty - - describe 'with JSON graph definition', -> - it 'should emit a ready event after network has been loaded', (done) -> - c.baseDir = root - c.once 'ready', -> - chai.expect(c.network).not.to.be.null - chai.expect(c.ready).to.be.true - done() - c.once 'network', (network) -> - network.loader.components.Split = Split - network.loader.registerComponent '', 'Merge', SubgraphMerge - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.start (err) -> - done err if err - g.send - processes: - Split: - component: 'Split' - Merge: - component: 'Merge' - it 'should expose available ports', (done) -> - c.baseDir = root - c.once 'ready', -> - chai.expect(c.inPorts.ports).to.have.keys [ - 'graph' - ] - chai.expect(c.outPorts.ports).to.be.empty - done() - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send - processes: - Split: - component: 'Split' - Merge: - component: 'Merge' - connections: [ - src: - process: 'Merge' - port: 'out' - tgt: - process: 'Split' - port: 'in' - ] - it 'should update description from the graph', (done) -> - c.baseDir = root - c.once 'ready', -> - chai.expect(c.network).not.to.be.null - chai.expect(c.ready).to.be.true - chai.expect(c.description).to.equal 'Hello, World!' - done() - c.once 'network', (network) -> - network.loader.components.Split = Split - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - chai.expect(c.description).to.equal 'Hello, World!' - c.start (err) -> - done err if err - g.send - properties: - description: 'Hello, World!' - processes: - Split: - component: 'Split' - it 'should expose only exported ports when they exist', (done) -> - c.baseDir = root - c.once 'ready', -> - chai.expect(c.inPorts.ports).to.have.keys [ - 'graph' - ] - chai.expect(c.outPorts.ports).to.have.keys [ - 'out' - ] - done() - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send - outports: - out: - process: 'Split' - port: 'out' - processes: - Split: - component: 'Split' - Merge: - component: 'Merge' - connections: [ - src: - process: 'Merge' - port: 'out' - tgt: - process: 'Split' - port: 'in' - ] - it 'should be able to run the graph', (done) -> - c.baseDir = root - c.once 'ready', -> - ins = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - c.inPorts['in'].attach ins - c.outPorts['out'].attach out - out.on 'data', (data) -> - chai.expect(data).to.equal 'Foo' - done() - ins.send 'Foo' - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send - inports: - in: - process: 'Merge' - port: 'in' - outports: - out: - process: 'Split' - port: 'out' - processes: - Split: - component: 'Split' - Merge: - component: 'Merge' - connections: [ - src: - process: 'Merge' - port: 'out' - tgt: - process: 'Split' - port: 'in' - ] - - describe 'with a Graph instance', -> - gr = null - before -> - gr = new noflo.Graph 'Hello, world' - gr.baseDir = root - gr.addNode 'Split', 'Split' - gr.addNode 'Merge', 'Merge' - gr.addEdge 'Merge', 'out', 'Split', 'in' - gr.addInport 'in', 'Merge', 'in' - gr.addOutport 'out', 'Split', 'out' - it 'should emit a ready event after network has been loaded', (done) -> - c.baseDir = root - c.once 'ready', -> - chai.expect(c.network).not.to.be.null - chai.expect(c.ready).to.be.true - done() - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send gr - chai.expect(c.ready).to.be.false - it 'should expose available ports', (done) -> - c.baseDir = root - c.once 'ready', -> - chai.expect(c.inPorts.ports).to.have.keys [ - 'graph' - 'in' - ] - chai.expect(c.outPorts.ports).to.have.keys [ - 'out' - ] - done() - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send gr - it 'should be able to run the graph', (done) -> - c.baseDir = root - doned = false - c.once 'ready', -> - ins = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - c.inPorts['in'].attach ins - c.outPorts['out'].attach out - out.on 'data', (data) -> - chai.expect(data).to.equal 'Baz' - if doned - process.exit 1 - done() - doned = true - ins.send 'Baz' - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send gr - - describe 'with a FBP file with INPORTs and OUTPORTs', -> - file = "#{urlPrefix}spec/fixtures/subgraph.fbp" - it 'should emit a ready event after network has been loaded', (done) -> - @timeout 6000 - c.baseDir = root - c.once 'ready', -> - chai.expect(c.network).not.to.be.null - chai.expect(c.ready).to.be.true - done() - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send file - chai.expect(c.ready).to.be.false - it 'should expose available ports', (done) -> - @timeout 6000 - c.baseDir = root - c.once 'ready', -> - chai.expect(c.inPorts.ports).to.have.keys [ - 'graph' - 'in' - ] - chai.expect(c.outPorts.ports).to.have.keys [ - 'out' - ] - done() - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send file - it 'should be able to run the graph', (done) -> - c.baseDir = root - @timeout 6000 - c.once 'ready', -> - ins = noflo.internalSocket.createSocket() - out = noflo.internalSocket.createSocket() - c.inPorts['in'].attach ins - c.outPorts['out'].attach out - received = false - out.on 'data', (data) -> - chai.expect(data).to.equal 'Foo' - received = true - out.on 'disconnect', -> - chai.expect(received, 'should have transmitted data').to.equal true - done() - ins.connect() - ins.send 'Foo' - ins.disconnect() - c.once 'network', -> - chai.expect(c.ready).to.be.false - chai.expect(c.network).not.to.be.null - c.network.loader.components.Split = Split - c.network.loader.components.Merge = SubgraphMerge - c.start (err) -> - done err if err - g.send file - - describe 'when a subgraph is used as a component', -> - - createSplit = -> - c = new noflo.Component - c.inPorts.add 'in', - required: true - datatype: 'string' - default: 'default-value', - c.outPorts.add 'out', - datatype: 'string' - c.process (input, output) -> - data = input.getData 'in' - output.sendDone - out: data - - grDefaults = new noflo.Graph 'Child Graph Using Defaults' - grDefaults.addNode 'SplitIn', 'Split' - grDefaults.addNode 'SplitOut', 'Split' - grDefaults.addInport 'in', 'SplitIn', 'in' - grDefaults.addOutport 'out', 'SplitOut', 'out' - grDefaults.addEdge 'SplitIn', 'out', 'SplitOut', 'in' - - grInitials = new noflo.Graph 'Child Graph Using Initials' - grInitials.addNode 'SplitIn', 'Split' - grInitials.addNode 'SplitOut', 'Split' - grInitials.addInport 'in', 'SplitIn', 'in' - grInitials.addOutport 'out', 'SplitOut', 'out' - grInitials.addInitial 'initial-value', 'SplitIn', 'in' - grInitials.addEdge 'SplitIn', 'out', 'SplitOut', 'in' - - cl = null - before (done) -> - @timeout 6000 - cl = new noflo.ComponentLoader root - cl.listComponents (err, components) -> - return done err if err - cl.components.Split = createSplit - cl.components.Defaults = grDefaults - cl.components.Initials = grInitials - done() - return - - it 'should send defaults', (done) -> - cl.load 'Defaults', (err, inst) -> - o = noflo.internalSocket.createSocket() - inst.outPorts.out.attach o - o.once 'data', (data) -> - chai.expect(data).to.equal 'default-value' - done() - inst.start (err) -> - return done err if err - return - - it 'should send initials', (done) -> - cl.load 'Initials', (err, inst) -> - o = noflo.internalSocket.createSocket() - inst.outPorts.out.attach o - o.once 'data', (data) -> - chai.expect(data).to.equal 'initial-value' - done() - inst.start (err) -> - return done err if err - return - - it 'should not send defaults when an inport is attached externally', (done) -> - cl.load 'Defaults', (err, inst) -> - i = noflo.internalSocket.createSocket() - o = noflo.internalSocket.createSocket() - inst.inPorts.in.attach i - inst.outPorts.out.attach o - o.once 'data', (data) -> - chai.expect(data).to.equal 'Foo' - done() - inst.start (err) -> - return done err if err - i.send 'Foo' - return - - it 'should deactivate after processing is complete', (done) -> - cl.load 'Defaults', (err, inst) -> - i = noflo.internalSocket.createSocket() - o = noflo.internalSocket.createSocket() - inst.inPorts.in.attach i - inst.outPorts.out.attach o - expected = [ - 'ACTIVATE 1' - 'data Foo' - 'DEACTIVATE 0' - ] - received = [] - o.on 'ip', (ip) -> - received.push "#{ip.type} #{ip.data}" - inst.on 'activate', (load) -> - received.push "ACTIVATE #{load}" - inst.on 'deactivate', (load) -> - received.push "DEACTIVATE #{load}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - inst.start (err) -> - return done err if err - i.send 'Foo' - return - - it.skip 'should activate automatically when receiving data', (done) -> - cl.load 'Defaults', (err, inst) -> - i = noflo.internalSocket.createSocket() - o = noflo.internalSocket.createSocket() - inst.inPorts.in.attach i - inst.outPorts.out.attach o - expected = [ - 'ACTIVATE 1' - 'data Foo' - 'DEACTIVATE 0' - ] - received = [] - o.on 'ip', (ip) -> - received.push "#{ip.type} #{ip.data}" - inst.on 'activate', (load) -> - received.push "ACTIVATE #{load}" - inst.on 'deactivate', (load) -> - received.push "DEACTIVATE #{load}" - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - i.send 'Foo' - return - - it 'should reactivate when receiving new data packets', (done) -> - cl.load 'Defaults', (err, inst) -> - i = noflo.internalSocket.createSocket() - o = noflo.internalSocket.createSocket() - inst.inPorts.in.attach i - inst.outPorts.out.attach o - expected = [ - 'ACTIVATE 1' - 'data Foo' - 'DEACTIVATE 0' - 'ACTIVATE 1' - 'data Bar' - 'data Baz' - 'DEACTIVATE 0' - 'ACTIVATE 1' - 'data Foobar' - 'DEACTIVATE 0' - ] - received = [] - send = [ - ['Foo'] - ['Bar', 'Baz'] - ['Foobar'] - ] - sendNext = -> - return unless send.length - sends = send.shift() - i.post new noflo.IP 'data', d for d in sends - o.on 'ip', (ip) -> - received.push "#{ip.type} #{ip.data}" - inst.on 'activate', (load) -> - received.push "ACTIVATE #{load}" - inst.on 'deactivate', (load) -> - received.push "DEACTIVATE #{load}" - sendNext() - return unless received.length is expected.length - chai.expect(received).to.eql expected - done() - inst.start (err) -> - return done err if err - sendNext() - return - describe 'event forwarding on parent network', -> - describe 'with a single level subgraph', -> - graph = null - network = null - before (done) -> - graph = new noflo.Graph 'main' - graph.baseDir = root - noflo.createNetwork graph, - delay: true - subscribeGraph: false - , (err, nw) -> - return done err if err - network = nw - network.loader.components.Split = Split - network.loader.components.Merge = SubgraphMerge - sg = new noflo.Graph 'Subgraph' - sg.addNode 'A', 'Split' - sg.addNode 'B', 'Merge' - sg.addEdge 'A', 'out', 'B', 'in' - sg.addInport 'in', 'A', 'in' - sg.addOutport 'out', 'B', 'out' - network.loader.registerGraph 'foo', 'AB', sg, (err) -> - return done err if err - network.connect done - it 'should instantiate the subgraph when node is added', (done) -> - network.addNode - id: 'Sub' - component: 'foo/AB' - , (err) -> - return done err if err - network.addNode - id: 'Split' - component: 'Split' - , (err) -> - return done err if err - network.addEdge - from: - node: 'Sub' - port: 'out' - to: - node: 'Split' - port: 'in' - , (err) -> - return done err if err - chai.expect(network.processes).not.to.be.empty - chai.expect(network.processes.Sub).to.exist - done() - it 'should be possible to start the graph', (done) -> - network.start done - it 'should forward IP events', (done) -> - network.once 'ip', (ip) -> - chai.expect(ip.id).to.equal 'DATA -> IN Sub()' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.subgraph).to.be.undefined - network.once 'ip', (ip) -> - chai.expect(ip.id).to.equal 'A() OUT -> IN B()' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.subgraph).to.eql [ - 'Sub' - ] - network.once 'ip', (ip) -> - chai.expect(ip.id).to.equal 'Sub() OUT -> IN Split()' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.subgraph).to.be.undefined - done() - network.addInitial - from: - data: 'foo' - to: - node: 'Sub' - port: 'in' - , (err) -> - return done err if err - describe 'with two levels of subgraphs', -> - graph = null - network = null - before (done) -> - graph = new noflo.Graph 'main' - graph.baseDir = root - noflo.createNetwork graph, - delay: true - subscribeGraph: false - , (err, net) -> - return done err if err - network = net - network.loader.components.Split = Split - network.loader.components.Merge = SubgraphMerge - sg = new noflo.Graph 'Subgraph' - sg.addNode 'A', 'Split' - sg.addNode 'B', 'Merge' - sg.addEdge 'A', 'out', 'B', 'in' - sg.addInport 'in', 'A', 'in' - sg.addOutport 'out', 'B', 'out' - sg2 = new noflo.Graph 'Subgraph' - sg2.addNode 'A', 'foo/AB' - sg2.addNode 'B', 'Merge' - sg2.addEdge 'A', 'out', 'B', 'in' - sg2.addInport 'in', 'A', 'in' - sg2.addOutport 'out', 'B', 'out' - network.loader.registerGraph 'foo', 'AB', sg, (err) -> - return done err if err - network.loader.registerGraph 'foo', 'AB2', sg2, (err) -> - return done err if err - network.connect done - it 'should instantiate the subgraphs when node is added', (done) -> - network.addNode - id: 'Sub' - component: 'foo/AB2' - , (err) -> - return done err if err - network.addNode - id: 'Split' - component: 'Split' - , (err) -> - return done err if err - network.addEdge - from: - node: 'Sub' - port: 'out' - to: - node: 'Split' - port: 'in' - , (err) -> - return done err if err - chai.expect(network.processes).not.to.be.empty - chai.expect(network.processes.Sub).to.exist - done() - it 'should be possible to start the graph', (done) -> - network.start done - it 'should forward IP events', (done) -> - network.once 'ip', (ip) -> - chai.expect(ip.id).to.equal 'DATA -> IN Sub()' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.subgraph).to.be.undefined - network.once 'ip', (ip) -> - chai.expect(ip.id).to.equal 'A() OUT -> IN B()' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.subgraph).to.eql [ - 'Sub' - 'A' - ] - network.once 'ip', (ip) -> - chai.expect(ip.id).to.equal 'A() OUT -> IN B()' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.subgraph).to.eql [ - 'Sub' - ] - network.once 'ip', (ip) -> - chai.expect(ip.id).to.equal 'Sub() OUT -> IN Split()' - chai.expect(ip.type).to.equal 'data' - chai.expect(ip.data).to.equal 'foo' - chai.expect(ip.subgraph).to.be.undefined - done() - network.addInitial - from: - data: 'foo' - to: - node: 'Sub' - port: 'in' - , (err) -> - return done err if err diff --git a/spec/Subgraph.js b/spec/Subgraph.js new file mode 100644 index 000000000..1f8beddc6 --- /dev/null +++ b/spec/Subgraph.js @@ -0,0 +1,906 @@ +let chai; let noflo; let root; let urlPrefix; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + noflo = require('../src/lib/NoFlo'); + const path = require('path'); + root = path.resolve(__dirname, '../'); + urlPrefix = './'; +} else { + noflo = require('noflo'); + root = 'noflo'; + urlPrefix = '/'; +} + +describe('NoFlo Graph component', () => { + let c = null; + let g = null; + let loader = null; + before((done) => { + loader = new noflo.ComponentLoader(root); + loader.listComponents(done); + }); + beforeEach((done) => { + loader.load('Graph', (err, instance) => { + if (err) { + done(err); + return; + } + c = instance; + g = noflo.internalSocket.createSocket(); + c.inPorts.graph.attach(g); + done(); + }); + }); + + const Split = function () { + const inst = new noflo.Component(); + inst.inPorts.add('in', + { datatype: 'all' }); + inst.outPorts.add('out', + { datatype: 'all' }); + inst.process((input, output) => { + const data = input.getData('in'); + output.sendDone({ out: data }); + }); + return inst; + }; + + const SubgraphMerge = function () { + const inst = new noflo.Component(); + inst.inPorts.add('in', + { datatype: 'all' }); + inst.outPorts.add('out', + { datatype: 'all' }); + inst.forwardBrackets = {}; + inst.process((input, output) => { + const packet = input.get('in'); + if (packet.type !== 'data') { + output.done(); + return; + } + output.sendDone({ out: packet.data }); + }); + return inst; + }; + + describe('initially', () => { + it('should be ready', () => { + chai.expect(c.ready).to.be.true; + }); + it('should not contain a network', () => { + chai.expect(c.network).to.be.null; + }); + it('should have a baseDir', () => { + chai.expect(c.baseDir).to.equal(root); + }); + it('should only have the graph inport', () => { + chai.expect(c.inPorts.ports).to.have.keys(['graph']); + chai.expect(c.outPorts.ports).to.be.empty; + }); + }); + describe('with JSON graph definition', () => { + it('should emit a ready event after network has been loaded', (done) => { + c.baseDir = root; + c.once('ready', () => { + chai.expect(c.network).not.to.be.null; + chai.expect(c.ready).to.be.true; + done(); + }); + c.once('network', (network) => { + network.loader.components.Split = Split; + network.loader.registerComponent('', 'Merge', SubgraphMerge); + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send({ + processes: { + Split: { + component: 'Split', + }, + Merge: { + component: 'Merge', + }, + }, + }); + }); + it('should expose available ports', (done) => { + c.baseDir = root; + c.once('ready', () => { + chai.expect(c.inPorts.ports).to.have.keys([ + 'graph', + ]); + chai.expect(c.outPorts.ports).to.be.empty; + done(); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send({ + processes: { + Split: { + component: 'Split', + }, + Merge: { + component: 'Merge', + }, + }, + connections: [{ + src: { + process: 'Merge', + port: 'out', + }, + tgt: { + process: 'Split', + port: 'in', + }, + }, + ], + }); + }); + it('should update description from the graph', (done) => { + c.baseDir = root; + c.once('ready', () => { + chai.expect(c.network).not.to.be.null; + chai.expect(c.ready).to.be.true; + chai.expect(c.description).to.equal('Hello, World!'); + done(); + }); + c.once('network', (network) => { + network.loader.components.Split = Split; + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + chai.expect(c.description).to.equal('Hello, World!'); + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send({ + properties: { + description: 'Hello, World!', + }, + processes: { + Split: { + component: 'Split', + }, + }, + }); + }); + it('should expose only exported ports when they exist', (done) => { + c.baseDir = root; + c.once('ready', () => { + chai.expect(c.inPorts.ports).to.have.keys([ + 'graph', + ]); + chai.expect(c.outPorts.ports).to.have.keys([ + 'out', + ]); + done(); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send({ + outports: { + out: { + process: 'Split', + port: 'out', + }, + }, + processes: { + Split: { + component: 'Split', + }, + Merge: { + component: 'Merge', + }, + }, + connections: [{ + src: { + process: 'Merge', + port: 'out', + }, + tgt: { + process: 'Split', + port: 'in', + }, + }, + ], + }); + }); + it('should be able to run the graph', (done) => { + c.baseDir = root; + c.once('ready', () => { + const ins = noflo.internalSocket.createSocket(); + const out = noflo.internalSocket.createSocket(); + c.inPorts.in.attach(ins); + c.outPorts.out.attach(out); + out.on('data', (data) => { + chai.expect(data).to.equal('Foo'); + done(); + }); + ins.send('Foo'); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send({ + inports: { + in: { + process: 'Merge', + port: 'in', + }, + }, + outports: { + out: { + process: 'Split', + port: 'out', + }, + }, + processes: { + Split: { + component: 'Split', + }, + Merge: { + component: 'Merge', + }, + }, + connections: [{ + src: { + process: 'Merge', + port: 'out', + }, + tgt: { + process: 'Split', + port: 'in', + }, + }, + ], + }); + }); + }); + describe('with a Graph instance', () => { + let gr = null; + before(() => { + gr = new noflo.Graph('Hello, world'); + gr.baseDir = root; + gr.addNode('Split', 'Split'); + gr.addNode('Merge', 'Merge'); + gr.addEdge('Merge', 'out', 'Split', 'in'); + gr.addInport('in', 'Merge', 'in'); + gr.addOutport('out', 'Split', 'out'); + }); + it('should emit a ready event after network has been loaded', (done) => { + c.baseDir = root; + c.once('ready', () => { + chai.expect(c.network).not.to.be.null; + chai.expect(c.ready).to.be.true; + done(); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send(gr); + chai.expect(c.ready).to.be.false; + }); + it('should expose available ports', (done) => { + c.baseDir = root; + c.once('ready', () => { + chai.expect(c.inPorts.ports).to.have.keys([ + 'graph', + 'in', + ]); + chai.expect(c.outPorts.ports).to.have.keys([ + 'out', + ]); + done(); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send(gr); + }); + it('should be able to run the graph', (done) => { + c.baseDir = root; + let doned = false; + c.once('ready', () => { + const ins = noflo.internalSocket.createSocket(); + const out = noflo.internalSocket.createSocket(); + c.inPorts.in.attach(ins); + c.outPorts.out.attach(out); + out.on('data', (data) => { + chai.expect(data).to.equal('Baz'); + if (doned) { + process.exit(1); + } + done(); + doned = true; + }); + ins.send('Baz'); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send(gr); + }); + }); + describe('with a FBP file with INPORTs and OUTPORTs', () => { + const file = `${urlPrefix}spec/fixtures/subgraph.fbp`; + it('should emit a ready event after network has been loaded', function (done) { + this.timeout(6000); + c.baseDir = root; + c.once('ready', () => { + chai.expect(c.network).not.to.be.null; + chai.expect(c.ready).to.be.true; + done(); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send(file); + chai.expect(c.ready).to.be.false; + }); + it('should expose available ports', function (done) { + this.timeout(6000); + c.baseDir = root; + c.once('ready', () => { + chai.expect(c.inPorts.ports).to.have.keys([ + 'graph', + 'in', + ]); + chai.expect(c.outPorts.ports).to.have.keys([ + 'out', + ]); + done(); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send(file); + }); + it('should be able to run the graph', function (done) { + c.baseDir = root; + this.timeout(6000); + c.once('ready', () => { + const ins = noflo.internalSocket.createSocket(); + const out = noflo.internalSocket.createSocket(); + c.inPorts.in.attach(ins); + c.outPorts.out.attach(out); + let received = false; + out.on('data', (data) => { + chai.expect(data).to.equal('Foo'); + received = true; + }); + out.on('disconnect', () => { + chai.expect(received, 'should have transmitted data').to.equal(true); + done(); + }); + ins.connect(); + ins.send('Foo'); + ins.disconnect(); + }); + c.once('network', () => { + chai.expect(c.ready).to.be.false; + chai.expect(c.network).not.to.be.null; + c.network.loader.components.Split = Split; + c.network.loader.components.Merge = SubgraphMerge; + c.start((err) => { + if (err) { done(err); } + }); + }); + g.send(file); + }); + }); + describe('when a subgraph is used as a component', () => { + const createSplit = function () { + c = new noflo.Component(); + c.inPorts.add('in', { + required: true, + datatype: 'string', + default: 'default-value', + }); + c.outPorts.add('out', + { datatype: 'string' }); + c.process((input, output) => { + const data = input.getData('in'); + output.sendDone({ out: data }); + }); + return c; + }; + + const grDefaults = new noflo.Graph('Child Graph Using Defaults'); + grDefaults.addNode('SplitIn', 'Split'); + grDefaults.addNode('SplitOut', 'Split'); + grDefaults.addInport('in', 'SplitIn', 'in'); + grDefaults.addOutport('out', 'SplitOut', 'out'); + grDefaults.addEdge('SplitIn', 'out', 'SplitOut', 'in'); + + const grInitials = new noflo.Graph('Child Graph Using Initials'); + grInitials.addNode('SplitIn', 'Split'); + grInitials.addNode('SplitOut', 'Split'); + grInitials.addInport('in', 'SplitIn', 'in'); + grInitials.addOutport('out', 'SplitOut', 'out'); + grInitials.addInitial('initial-value', 'SplitIn', 'in'); + grInitials.addEdge('SplitIn', 'out', 'SplitOut', 'in'); + + let cl = null; + before(function (done) { + this.timeout(6000); + cl = new noflo.ComponentLoader(root); + cl.listComponents((err) => { + if (err) { + done(err); + return; + } + cl.components.Split = createSplit; + cl.components.Defaults = grDefaults; + cl.components.Initials = grInitials; + done(); + }); + }); + + it('should send defaults', (done) => { + cl.load('Defaults', (err, inst) => { + const o = noflo.internalSocket.createSocket(); + inst.outPorts.out.attach(o); + o.once('data', (data) => { + chai.expect(data).to.equal('default-value'); + done(); + }); + inst.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + + it('should send initials', (done) => { + cl.load('Initials', (err, inst) => { + const o = noflo.internalSocket.createSocket(); + inst.outPorts.out.attach(o); + o.once('data', (data) => { + chai.expect(data).to.equal('initial-value'); + done(); + }); + inst.start((err) => { + if (err) { + done(err); + } + }); + }); + }); + + it('should not send defaults when an inport is attached externally', (done) => { + cl.load('Defaults', (err, inst) => { + const i = noflo.internalSocket.createSocket(); + const o = noflo.internalSocket.createSocket(); + inst.inPorts.in.attach(i); + inst.outPorts.out.attach(o); + o.once('data', (data) => { + chai.expect(data).to.equal('Foo'); + done(); + }); + inst.start((err) => { + if (err) { + done(err); + } + }); + i.send('Foo'); + }); + }); + + it('should deactivate after processing is complete', (done) => { + cl.load('Defaults', (err, inst) => { + const i = noflo.internalSocket.createSocket(); + const o = noflo.internalSocket.createSocket(); + inst.inPorts.in.attach(i); + inst.outPorts.out.attach(o); + const expected = [ + 'ACTIVATE 1', + 'data Foo', + 'DEACTIVATE 0', + ]; + const received = []; + o.on('ip', (ip) => { + received.push(`${ip.type} ${ip.data}`); + }); + inst.on('activate', (load) => { + received.push(`ACTIVATE ${load}`); + }); + inst.on('deactivate', (load) => { + received.push(`DEACTIVATE ${load}`); + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + inst.start((err) => { + if (err) { + done(err); + return; + } + i.send('Foo'); + }); + }); + }); + + it.skip('should activate automatically when receiving data', (done) => { + cl.load('Defaults', (err, inst) => { + const i = noflo.internalSocket.createSocket(); + const o = noflo.internalSocket.createSocket(); + inst.inPorts.in.attach(i); + inst.outPorts.out.attach(o); + const expected = [ + 'ACTIVATE 1', + 'data Foo', + 'DEACTIVATE 0', + ]; + const received = []; + o.on('ip', (ip) => received.push(`${ip.type} ${ip.data}`)); + inst.on('activate', (load) => received.push(`ACTIVATE ${load}`)); + inst.on('deactivate', (load) => { + received.push(`DEACTIVATE ${load}`); + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + i.send('Foo'); + }); + }); + + it('should reactivate when receiving new data packets', (done) => { + cl.load('Defaults', (err, inst) => { + const i = noflo.internalSocket.createSocket(); + const o = noflo.internalSocket.createSocket(); + inst.inPorts.in.attach(i); + inst.outPorts.out.attach(o); + const expected = [ + 'ACTIVATE 1', + 'data Foo', + 'DEACTIVATE 0', + 'ACTIVATE 1', + 'data Bar', + 'data Baz', + 'DEACTIVATE 0', + 'ACTIVATE 1', + 'data Foobar', + 'DEACTIVATE 0', + ]; + const received = []; + const send = [ + ['Foo'], + ['Bar', 'Baz'], + ['Foobar'], + ]; + const sendNext = function () { + if (!send.length) { return; } + const sends = send.shift(); + for (const d of sends) { i.post(new noflo.IP('data', d)); } + }; + o.on('ip', (ip) => { + received.push(`${ip.type} ${ip.data}`); + }); + inst.on('activate', (load) => { + received.push(`ACTIVATE ${load}`); + }); + inst.on('deactivate', (load) => { + received.push(`DEACTIVATE ${load}`); + sendNext(); + if (received.length !== expected.length) { return; } + chai.expect(received).to.eql(expected); + done(); + }); + inst.start((err) => { + if (err) { + done(err); + return; + } + sendNext(); + }); + }); + }); + }); + describe('event forwarding on parent network', () => { + describe('with a single level subgraph', () => { + let graph = null; + let network = null; + before((done) => { + graph = new noflo.Graph('main'); + graph.baseDir = root; + noflo.createNetwork(graph, { + delay: true, + subscribeGraph: false, + }, + (err, nw) => { + if (err) { + done(err); + return; + } + network = nw; + network.loader.components.Split = Split; + network.loader.components.Merge = SubgraphMerge; + const sg = new noflo.Graph('Subgraph'); + sg.addNode('A', 'Split'); + sg.addNode('B', 'Merge'); + sg.addEdge('A', 'out', 'B', 'in'); + sg.addInport('in', 'A', 'in'); + sg.addOutport('out', 'B', 'out'); + network.loader.registerGraph('foo', 'AB', sg, (err) => { + if (err) { + done(err); + return; + } + network.connect(done); + }); + }); + }); + it('should instantiate the subgraph when node is added', (done) => { + network.addNode({ + id: 'Sub', + component: 'foo/AB', + }, + (err) => { + if (err) { + done(err); + return; + } + network.addNode({ + id: 'Split', + component: 'Split', + }, + (err) => { + if (err) { + done(err); + return; + } + network.addEdge({ + from: { + node: 'Sub', + port: 'out', + }, + to: { + node: 'Split', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + chai.expect(network.processes).not.to.be.empty; + chai.expect(network.processes.Sub).to.exist; + done(); + }); + }); + }); + }); + it('should be possible to start the graph', (done) => { + network.start(done); + }); + it('should forward IP events', (done) => { + network.once('ip', (ip) => { + chai.expect(ip.id).to.equal('DATA -> IN Sub()'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.subgraph).to.be.undefined; + network.once('ip', (ip) => { + chai.expect(ip.id).to.equal('A() OUT -> IN B()'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.subgraph).to.eql([ + 'Sub', + ]); + network.once('ip', (ip) => { + chai.expect(ip.id).to.equal('Sub() OUT -> IN Split()'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.subgraph).to.be.undefined; + done(); + }); + }); + }); + network.addInitial({ + from: { + data: 'foo', + }, + to: { + node: 'Sub', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + } + }); + }); + }); + describe('with two levels of subgraphs', () => { + let graph = null; + let network = null; + before((done) => { + graph = new noflo.Graph('main'); + graph.baseDir = root; + noflo.createNetwork(graph, { + delay: true, + subscribeGraph: false, + }, + (err, net) => { + if (err) { + done(err); + return; + } + network = net; + network.loader.components.Split = Split; + network.loader.components.Merge = SubgraphMerge; + const sg = new noflo.Graph('Subgraph'); + sg.addNode('A', 'Split'); + sg.addNode('B', 'Merge'); + sg.addEdge('A', 'out', 'B', 'in'); + sg.addInport('in', 'A', 'in'); + sg.addOutport('out', 'B', 'out'); + const sg2 = new noflo.Graph('Subgraph'); + sg2.addNode('A', 'foo/AB'); + sg2.addNode('B', 'Merge'); + sg2.addEdge('A', 'out', 'B', 'in'); + sg2.addInport('in', 'A', 'in'); + sg2.addOutport('out', 'B', 'out'); + network.loader.registerGraph('foo', 'AB', sg, (err) => { + if (err) { + done(err); + return; + } + network.loader.registerGraph('foo', 'AB2', sg2, (err) => { + if (err) { + done(err); + return; + } + network.connect(done); + }); + }); + }); + }); + it('should instantiate the subgraphs when node is added', (done) => { + network.addNode({ + id: 'Sub', + component: 'foo/AB2', + }, + (err) => { + if (err) { + done(err); + return; + } + network.addNode({ + id: 'Split', + component: 'Split', + }, + (err) => { + if (err) { + done(err); + return; + } + network.addEdge({ + from: { + node: 'Sub', + port: 'out', + }, + to: { + node: 'Split', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + return; + } + chai.expect(network.processes).not.to.be.empty; + chai.expect(network.processes.Sub).to.exist; + done(); + }); + }); + }); + }); + it('should be possible to start the graph', (done) => { + network.start(done); + }); + it('should forward IP events', (done) => { + network.once('ip', (ip) => { + chai.expect(ip.id).to.equal('DATA -> IN Sub()'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.subgraph).to.be.undefined; + network.once('ip', (ip) => { + chai.expect(ip.id).to.equal('A() OUT -> IN B()'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.subgraph).to.eql([ + 'Sub', + 'A', + ]); + network.once('ip', (ip) => { + chai.expect(ip.id).to.equal('A() OUT -> IN B()'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.subgraph).to.eql([ + 'Sub', + ]); + network.once('ip', (ip) => { + chai.expect(ip.id).to.equal('Sub() OUT -> IN Split()'); + chai.expect(ip.type).to.equal('data'); + chai.expect(ip.data).to.equal('foo'); + chai.expect(ip.subgraph).to.be.undefined; + done(); + }); + }); + }); + }); + network.addInitial({ + from: { + data: 'foo', + }, + to: { + node: 'Sub', + port: 'in', + }, + }, + (err) => { + if (err) { + done(err); + } + }); + }); + }); + }); +}); diff --git a/spec/components/MergeObjects.coffee b/spec/components/MergeObjects.coffee deleted file mode 100644 index 678b260da..000000000 --- a/spec/components/MergeObjects.coffee +++ /dev/null @@ -1,42 +0,0 @@ -if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - chai = require 'chai' unless chai - component = require '../../src/lib/Component.coffee' - socket = require '../../src/lib/InternalSocket.coffee' - IP = require '../../src/lib/IP.coffee' -else - component = require 'noflo/src/lib/Component.js' - socket = require 'noflo/src/lib/InternalSocket.js' - IP = require 'noflo/src/lib/IP.js' - -exports.getComponent = -> - c = new component.Component - desciption: 'Merges two objects into one (cloning)' - inPorts: - obj1: - datatype: 'object' - desciption: 'First object' - obj2: - datatype: 'object' - desciption: 'Second object' - overwrite: - datatype: 'boolean' - desciption: 'Overwrite obj1 properties with obj2' - control: true - outPorts: - result: - datatype: 'object' - error: - datatype: 'object' - - c.process (input, output) -> - return unless input.has 'obj1', 'obj2', 'overwrite' - [obj1, obj2, overwrite] = input.getData 'obj1', 'obj2', 'overwrite' - try - src = JSON.parse JSON.stringify if overwrite then obj1 else obj2 - dst = JSON.parse JSON.stringify if overwrite then obj2 else obj1 - catch e - return output.done e - for key, val of dst - src[key] = val - output.sendDone - result: src diff --git a/spec/components/MergeObjects.js b/spec/components/MergeObjects.js new file mode 100644 index 000000000..fe03578e5 --- /dev/null +++ b/spec/components/MergeObjects.js @@ -0,0 +1,54 @@ +let component; let chai; +if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + if (!chai) { chai = require('chai'); } + component = require('../../src/lib/Component.js'); +} else { + component = require('noflo/src/lib/Component.js'); +} + +exports.getComponent = function () { + const c = new component.Component({ + desciption: 'Merges two objects into one (cloning)', + inPorts: { + obj1: { + datatype: 'object', + desciption: 'First object', + }, + obj2: { + datatype: 'object', + desciption: 'Second object', + }, + overwrite: { + datatype: 'boolean', + desciption: 'Overwrite obj1 properties with obj2', + control: true, + }, + }, + outPorts: { + result: { + datatype: 'object', + }, + error: { + datatype: 'object', + }, + }, + }); + + return c.process((input, output) => { + let dst; let src; + if (!input.has('obj1', 'obj2', 'overwrite')) { return; } + const [obj1, obj2, overwrite] = input.getData('obj1', 'obj2', 'overwrite'); + try { + src = JSON.parse(JSON.stringify(overwrite ? obj1 : obj2)); + dst = JSON.parse(JSON.stringify(overwrite ? obj2 : obj1)); + } catch (e) { + output.done(e); + return; + } + Object.keys(dst).forEach((key) => { + const val = dst[key]; + src[key] = val; + }); + output.sendDone({ result: src }); + }); +}; diff --git a/spec/fixtures/componentloader/components/Output.coffee b/spec/fixtures/componentloader/components/Output.coffee deleted file mode 100644 index 015e5987e..000000000 --- a/spec/fixtures/componentloader/components/Output.coffee +++ /dev/null @@ -1,15 +0,0 @@ -noflo = require '../../../../src/lib/NoFlo' - -exports.getComponent = -> - c = new noflo.Component - c.description = "Output stuff" - c.inPorts.add 'in', - datatype: 'string' - c.inPorts.add 'out', - datatype: 'string' - c.process = (input, output) -> - data = input.getData 'in' - console.log data - output.sendDone - out: data - c diff --git a/spec/fixtures/componentloader/components/Output.js b/spec/fixtures/componentloader/components/Output.js new file mode 100644 index 000000000..43e84482d --- /dev/null +++ b/spec/fixtures/componentloader/components/Output.js @@ -0,0 +1,16 @@ +const noflo = require('../../../../src/lib/NoFlo'); + +exports.getComponent = function () { + const c = new noflo.Component(); + c.description = 'Output stuff'; + c.inPorts.add('in', + { datatype: 'string' }); + c.inPorts.add('out', + { datatype: 'string' }); + c.process = function (input, output) { + const data = input.getData('in'); + console.log(data); + output.sendDone({ out: data }); + }; + return c; +}; diff --git a/spec/fixtures/componentloader/node_modules/example/components/Forward.coffee b/spec/fixtures/componentloader/node_modules/example/components/Forward.coffee deleted file mode 100644 index ff3a4fef8..000000000 --- a/spec/fixtures/componentloader/node_modules/example/components/Forward.coffee +++ /dev/null @@ -1,14 +0,0 @@ -noflo = require '../../../../../../src/lib/NoFlo' - -exports.getComponent = -> - c = new noflo.Component - c.description = "Forward stuff" - c.inPorts.add 'in', - datatype: 'all' - c.inPorts.add 'out', - datatype: 'all' - c.process = (input, output) -> - data = input.getData 'in' - output.sendDone - out: data - c diff --git a/spec/fixtures/componentloader/node_modules/example/components/Forward.js b/spec/fixtures/componentloader/node_modules/example/components/Forward.js new file mode 100644 index 000000000..ddfbf9359 --- /dev/null +++ b/spec/fixtures/componentloader/node_modules/example/components/Forward.js @@ -0,0 +1,15 @@ +const noflo = require('../../../../../../src/lib/NoFlo'); + +exports.getComponent = function () { + const c = new noflo.Component(); + c.description = 'Forward stuff'; + c.inPorts.add('in', + { datatype: 'all' }); + c.inPorts.add('out', + { datatype: 'all' }); + c.process = function (input, output) { + const data = input.getData('in'); + output.sendDone({ out: data }); + }; + return c; +}; diff --git a/spec/fixtures/componentloader/node_modules/example/loader.coffee b/spec/fixtures/componentloader/node_modules/example/loader.coffee deleted file mode 100644 index 5ee4937ee..000000000 --- a/spec/fixtures/componentloader/node_modules/example/loader.coffee +++ /dev/null @@ -1,17 +0,0 @@ -noflo = require '../../../../../src/lib/NoFlo' - -module.exports = (loader, callback) -> - loader.registerComponent 'example', 'Hello', -> - c = new noflo.Component - c.description = "Hello stuff" - c.icon = 'bicycle' - c.inPorts.add 'in', - datatype: 'all' - c.inPorts.add 'out', - datatype: 'all' - c.process = (input, output) -> - data = input.getData 'in' - output.sendDone - out: data - c - callback null diff --git a/spec/fixtures/componentloader/node_modules/example/loader.js b/spec/fixtures/componentloader/node_modules/example/loader.js new file mode 100644 index 000000000..59dbc255a --- /dev/null +++ b/spec/fixtures/componentloader/node_modules/example/loader.js @@ -0,0 +1,19 @@ +const noflo = require('../../../../../src/lib/NoFlo'); + +module.exports = function (loader, callback) { + loader.registerComponent('example', 'Hello', () => { + const c = new noflo.Component(); + c.description = 'Hello stuff'; + c.icon = 'bicycle'; + c.inPorts.add('in', + { datatype: 'all' }); + c.inPorts.add('out', + { datatype: 'all' }); + c.process = function (input, output) { + const data = input.getData('in'); + output.sendDone({ out: data }); + }; + return c; + }); + callback(null); +}; diff --git a/spec/fixtures/componentloader/node_modules/example/package.json b/spec/fixtures/componentloader/node_modules/example/package.json index 40596a799..18bf4eabd 100644 --- a/spec/fixtures/componentloader/node_modules/example/package.json +++ b/spec/fixtures/componentloader/node_modules/example/package.json @@ -2,9 +2,9 @@ "name": "example", "noflo": { "icon": "car", - "loader": "loader.coffee", + "loader": "loader.js", "components": { - "Forward": "components/Forward.coffee" + "Forward": "components/Forward.js" } } -} +} \ No newline at end of file diff --git a/spec/fixtures/componentloader/package.json b/spec/fixtures/componentloader/package.json index 217a602a0..86919ec8a 100644 --- a/spec/fixtures/componentloader/package.json +++ b/spec/fixtures/componentloader/package.json @@ -3,10 +3,10 @@ "noflo": { "icon": "cloud", "components": { - "Output": "components/Output.coffee" + "Output": "components/Output.js" } }, "dependencies": { "example": "" } -} +} \ No newline at end of file diff --git a/spec/fixtures/entry.js b/spec/fixtures/entry.js index 863efd523..282fe2139 100644 --- a/spec/fixtures/entry.js +++ b/spec/fixtures/entry.js @@ -1,3 +1,4 @@ +import 'babel-polyfill'; var exported = { noflo: require('../../lib/NoFlo') }; diff --git a/src/.eslintrc b/src/.eslintrc new file mode 100644 index 000000000..6f675643f --- /dev/null +++ b/src/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "airbnb-base" +} \ No newline at end of file diff --git a/src/components/Graph.coffee b/src/components/Graph.coffee deleted file mode 100644 index 20aed3222..000000000 --- a/src/components/Graph.coffee +++ /dev/null @@ -1,178 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2013-2017 Flowhub UG -# (c) 2011-2012 Henri Bergius, Nemein -# NoFlo may be freely distributed under the MIT license -# -# The Graph component is used to wrap NoFlo Networks into components inside -# another network. -noflo = require "../lib/NoFlo" - -class Graph extends noflo.Component - constructor: (metadata) -> - super() - @metadata = metadata - @network = null - @ready = true - @started = false - @starting = false - @baseDir = null - @loader = null - @load = 0 - - @inPorts = new noflo.InPorts - graph: - datatype: 'all' - description: 'NoFlo graph definition to be used with the subgraph component' - required: true - @outPorts = new noflo.OutPorts - - @inPorts.graph.on 'ip', (packet) => - return unless packet.type is 'data' - @setGraph packet.data, (err) => - # TODO: Port this part to Process API and use output.error method instead - return @error err if err - - setGraph: (graph, callback) -> - @ready = false - if typeof graph is 'object' - if typeof graph.addNode is 'function' - # Existing Graph object - @createNetwork graph, callback - return - - # JSON definition of a graph - noflo.graph.loadJSON graph, (err, instance) => - return callback err if err - instance.baseDir = @baseDir - @createNetwork instance, callback - return - - if graph.substr(0, 1) isnt "/" and graph.substr(1, 1) isnt ":" and process and process.cwd - graph = "#{process.cwd()}/#{graph}" - - noflo.graph.loadFile graph, (err, instance) => - return callback err if err - instance.baseDir = @baseDir - @createNetwork instance, callback - - createNetwork: (graph, callback) -> - @description = graph.properties.description or '' - @icon = graph.properties.icon or @icon - - graph.name = @nodeId unless graph.name - graph.componentLoader = @loader - - noflo.createNetwork graph, - delay: true - subscribeGraph: false - , (err, @network) => - return callback err if err - @emit 'network', @network - # Subscribe to network lifecycle - @subscribeNetwork @network - - # Wire the network up - @network.connect (err) => - return callback err if err - for name, node of @network.processes - # Map exported ports to local component - @findEdgePorts name, node - # Finally set ourselves as "ready" - do @setToReady - do callback - - subscribeNetwork: (network) -> - contexts = [] - @network.on 'start', => - ctx = {} - contexts.push ctx - @activate ctx - @network.on 'end', => - ctx = contexts.pop() - return unless ctx - @deactivate ctx - - isExportedInport: (port, nodeName, portName) -> - # First we check disambiguated exported ports - for pub, priv of @network.graph.inports - continue unless priv.process is nodeName and priv.port is portName - return pub - - # Component has exported ports and this isn't one of them - false - - isExportedOutport: (port, nodeName, portName) -> - # First we check disambiguated exported ports - for pub, priv of @network.graph.outports - continue unless priv.process is nodeName and priv.port is portName - return pub - - # Component has exported ports and this isn't one of them - false - - setToReady: -> - if typeof process isnt 'undefined' and process.execPath and process.execPath.indexOf('node') isnt -1 - process.nextTick => - @ready = true - @emit 'ready' - else - setTimeout => - @ready = true - @emit 'ready' - , 0 - - findEdgePorts: (name, process) -> - inPorts = process.component.inPorts.ports - outPorts = process.component.outPorts.ports - - for portName, port of inPorts - targetPortName = @isExportedInport port, name, portName - continue if targetPortName is false - @inPorts.add targetPortName, port - @inPorts[targetPortName].on 'connect', => - # Start the network implicitly if we're starting to get data - return if @starting - return if @network.isStarted() - if @network.startupDate - # Network was started, but did finish. Re-start simply - @network.setStarted true - return - # Network was never started, start properly - @setUp -> - - for portName, port of outPorts - targetPortName = @isExportedOutport port, name, portName - continue if targetPortName is false - @outPorts.add targetPortName, port - - return true - - isReady: -> - @ready - - isSubgraph: -> - true - - isLegacy: -> - false - - setUp: (callback) -> - @starting = true - unless @isReady() - @once 'ready', => - @setUp callback - return - return callback null unless @network - @network.start (err) => - return callback err if err - @starting = false - do callback - - tearDown: (callback) -> - @starting = false - return callback null unless @network - @network.stop (err) -> - return callback err if err - do callback - -exports.getComponent = (metadata) -> new Graph metadata diff --git a/src/components/Graph.js b/src/components/Graph.js new file mode 100644 index 000000000..1742701bc --- /dev/null +++ b/src/components/Graph.js @@ -0,0 +1,268 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2017 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license + +/* eslint-disable + class-methods-use-this, + import/no-unresolved, +*/ + +const noflo = require('../lib/NoFlo'); + +// The Graph component is used to wrap NoFlo Networks into components inside +// another network. +class Graph extends noflo.Component { + constructor(metadata) { + super(); + this.metadata = metadata; + this.network = null; + this.ready = true; + this.started = false; + this.starting = false; + this.baseDir = null; + this.loader = null; + this.load = 0; + + this.inPorts = new noflo.InPorts({ + graph: { + datatype: 'all', + description: 'NoFlo graph definition to be used with the subgraph component', + required: true, + }, + }); + this.outPorts = new noflo.OutPorts(); + + this.inPorts.graph.on('ip', (packet) => { + if (packet.type !== 'data') { return; } + this.setGraph(packet.data, (err) => { + // TODO: Port this part to Process API and use output.error method instead + if (err) { + this.error(err); + } + }); + }); + } + + setGraph(graph, callback) { + this.ready = false; + if (typeof graph === 'object') { + if (typeof graph.addNode === 'function') { + // Existing Graph object + this.createNetwork(graph, callback); + return; + } + + // JSON definition of a graph + noflo.graph.loadJSON(graph, (err, instance) => { + const inst = instance; + if (err) { + callback(err); + return; + } + inst.baseDir = this.baseDir; + this.createNetwork(inst, callback); + }); + return; + } + + let graphName = graph; + + if ((graphName.substr(0, 1) !== '/') && (graphName.substr(1, 1) !== ':') && process && process.cwd) { + graphName = `${process.cwd()}/${graphName}`; + } + + noflo.graph.loadFile(graphName, (err, instance) => { + const inst = instance; + if (err) { + callback(err); + return; + } + inst.baseDir = this.baseDir; + this.createNetwork(inst, callback); + }); + } + + createNetwork(graph, callback) { + this.description = graph.properties.description || ''; + this.icon = graph.properties.icon || this.icon; + + const graphObj = graph; + if (!graphObj.name) { graphObj.name = this.nodeId; } + graphObj.componentLoader = this.loader; + + noflo.createNetwork(graphObj, { + delay: true, + subscribeGraph: false, + }, + (err, network) => { + this.network = network; + if (err) { + callback(err); + return; + } + this.emit('network', this.network); + // Subscribe to network lifecycle + this.subscribeNetwork(this.network); + + // Wire the network up + this.network.connect((err2) => { + if (err2) { + callback(err2); + return; + } + Object.keys(this.network.processes).forEach((name) => { + // Map exported ports to local component + const node = this.network.processes[name]; + this.findEdgePorts(name, node); + }); + // Finally set ourselves as "ready" + this.setToReady(); + callback(); + }); + }); + } + + subscribeNetwork() { + const contexts = []; + this.network.on('start', () => { + const ctx = {}; + contexts.push(ctx); + return this.activate(ctx); + }); + return this.network.on('end', () => { + const ctx = contexts.pop(); + if (!ctx) { return; } + this.deactivate(ctx); + }); + } + + isExportedInport(port, nodeName, portName) { + // First we check disambiguated exported ports + const keys = Object.keys(this.network.graph.inports); + for (let i = 0; i < keys.length; i += 1) { + const pub = keys[i]; + const priv = this.network.graph.inports[pub]; + if (priv.process === nodeName && priv.port === portName) { + return pub; + } + } + + // Component has exported ports and this isn't one of them + return false; + } + + isExportedOutport(port, nodeName, portName) { + // First we check disambiguated exported ports + const keys = Object.keys(this.network.graph.outports); + for (let i = 0; i < keys.length; i += 1) { + const pub = keys[i]; + const priv = this.network.graph.outports[pub]; + if (priv.process === nodeName && priv.port === portName) { + return pub; + } + } + + // Component has exported ports and this isn't one of them + return false; + } + + setToReady() { + if ((typeof process !== 'undefined') && process.execPath && (process.execPath.indexOf('node') !== -1)) { + process.nextTick(() => { + this.ready = true; + return this.emit('ready'); + }); + } else { + setTimeout(() => { + this.ready = true; + return this.emit('ready'); + }, + 0); + } + } + + findEdgePorts(name, process) { + const inPorts = process.component.inPorts.ports; + const outPorts = process.component.outPorts.ports; + + Object.keys(inPorts).forEach((portName) => { + const port = inPorts[portName]; + const targetPortName = this.isExportedInport(port, name, portName); + if (targetPortName === false) { return; } + this.inPorts.add(targetPortName, port); + this.inPorts[targetPortName].on('connect', () => { + // Start the network implicitly if we're starting to get data + if (this.starting) { return; } + if (this.network.isStarted()) { return; } + if (this.network.startupDate) { + // Network was started, but did finish. Re-start simply + this.network.setStarted(true); + return; + } + // Network was never started, start properly + this.setUp(() => {}); + }); + }); + + Object.keys(outPorts).forEach((portName) => { + const port = outPorts[portName]; + const targetPortName = this.isExportedOutport(port, name, portName); + if (targetPortName === false) { return; } + this.outPorts.add(targetPortName, port); + }); + + return true; + } + + isReady() { + return this.ready; + } + + isSubgraph() { + return true; + } + + isLegacy() { + return false; + } + + setUp(callback) { + this.starting = true; + if (!this.isReady()) { + this.once('ready', () => { + this.setUp(callback); + }); + return; + } + if (!this.network) { + callback(null); + return; + } + this.network.start((err) => { + if (err) { + callback(err); + return; + } + this.starting = false; + callback(); + }); + } + + tearDown(callback) { + this.starting = false; + if (!this.network) { + callback(null); + return; + } + this.network.stop((err) => { + if (err) { + callback(err); + return; + } + callback(); + }); + } +} + +exports.getComponent = (metadata) => new Graph(metadata); diff --git a/src/lib/AsCallback.coffee b/src/lib/AsCallback.coffee deleted file mode 100644 index 1e38d9b36..000000000 --- a/src/lib/AsCallback.coffee +++ /dev/null @@ -1,245 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2017-2018 Flowhub UG -# NoFlo may be freely distributed under the MIT license -ComponentLoader = require('./ComponentLoader').ComponentLoader -Network = require('./Network').Network -IP = require('./IP') -internalSocket = require './InternalSocket' -Graph = require('fbp-graph').Graph - -# ## asCallback embedding API -# -# asCallback is a helper for embedding NoFlo components or -# graphs in other JavaScript programs. -# -# By using the `noflo.asCallback` function, you can turn any -# NoFlo component or NoFlo Graph instance into a regular, -# Node.js-style JavaScript function. -# -# Each call to that function starts a new NoFlo network where -# the given input arguments are sent as IP objects to matching -# inports. Once the network finishes, the IP objects received -# from the network will be sent to the callback function. -# -# If there was anything sent to an `error` outport, this will -# be provided as the error argument to the callback. - -# ### Option normalization -# -# Here we handle the input valus given to the `asCallback` -# function. This allows passing things like a pre-initialized -# NoFlo ComponentLoader, or giving the component loading -# baseDir context. -normalizeOptions = (options, component) -> - options = {} unless options - options.name = component unless options.name - if options.loader - options.baseDir = options.loader.baseDir - if not options.baseDir and process and process.cwd - options.baseDir = process.cwd() - unless options.loader - options.loader = new ComponentLoader options.baseDir - options.raw = false unless options.raw - options - -# ### Network preparation -# -# Each invocation of the asCallback-wrapped NoFlo graph -# creates a new network. This way we can isolate multiple -# executions of the function in their own contexts. -prepareNetwork = (component, options, callback) -> - # If we were given a graph instance, then just create a network - if typeof component is 'object' - component.componentLoader = options.loader - - network = new Network component, options - # Wire the network up - network.connect (err) -> - return callback err if err - callback null, network - return - - # Start by loading the component - options.loader.load component, (err, instance) -> - return callback err if err - # Prepare a graph wrapping the component - graph = new Graph options.name - nodeName = options.name - graph.addNode nodeName, component - # Expose ports - inPorts = instance.inPorts.ports - outPorts = instance.outPorts.ports - for port, def of inPorts - graph.addInport port, nodeName, port - for port, def of outPorts - graph.addOutport port, nodeName, port - # Prepare network - graph.componentLoader = options.loader - network = new Network graph, options - # Wire the network up and start execution - network.connect (err) -> - return callback err if err - callback null, network - -# ### Network execution -# -# Once network is ready, we connect to all of its exported -# in and outports and start the network. -# -# Input data is sent to the inports, and we collect IP -# packets received on the outports. -# -# Once the network finishes, we send the resulting IP -# objects to the callback. -runNetwork = (network, inputs, options, callback) -> - # Prepare inports - inPorts = Object.keys network.graph.inports - inSockets = {} - # Subscribe outports - received = [] - outPorts = Object.keys network.graph.outports - outSockets = {} - outPorts.forEach (outport) -> - portDef = network.graph.outports[outport] - process = network.getNode portDef.process - outSockets[outport] = internalSocket.createSocket() - process.component.outPorts[portDef.port].attach outSockets[outport] - outSockets[outport].from = - process: process - port: portDef.port - outSockets[outport].on 'ip', (ip) -> - res = {} - res[outport] = ip - received.push res - # Subscribe to process errors - onError = (err) -> - callback err.error - network.removeListener 'end', onEnd - network.once 'process-error', onError - # Subscribe network finish - onEnd = -> - # Clear listeners - for port, socket of outSockets - socket.from.process.component.outPorts[socket.from.port].detach socket - outSockets = {} - inSockets = {} - callback null, received - network.removeListener 'process-error', onError - network.once 'end', onEnd - # Start network - network.start (err) -> - return callback err if err - # Send inputs - for inputMap in inputs - for port, value of inputMap - unless inSockets[port] - portDef = network.graph.inports[port] - process = network.getNode portDef.process - inSockets[port] = internalSocket.createSocket() - process.component.inPorts[portDef.port].attach inSockets[port] - try - if IP.isIP value - inSockets[port].post value - continue - inSockets[port].post new IP 'data', value - catch e - callback e - network.removeListener 'process-error', onError - network.removeListener 'end', onEnd - return - -getType = (inputs, network) -> - # Scalar values are always simple inputs - return 'simple' unless typeof inputs is 'object' - - if Array.isArray inputs - maps = inputs.filter (entry) -> - getType(entry, network) is 'map' - # If each member if the array is an input map, this is a sequence - return 'sequence' if maps.length is inputs.length - # Otherwise arrays must be simple inputs - return 'simple' - - # Empty objects can't be maps - return 'simple' unless Object.keys(inputs).length - for key, value of inputs - return 'simple' unless network.graph.inports[key] - return 'map' - -prepareInputMap = (inputs, inputType, network) -> - # Sequence we can use as-is - return inputs if inputType is 'sequence' - # We can turn a map to a sequence by wrapping it in an array - return [inputs] if inputType is 'map' - # Simple inputs need to be converted to a sequence - inPort = Object.keys(network.graph.inports)[0] - # If we have a port named "IN", send to that - inPort = 'in' if network.graph.inports.in - map = {} - map[inPort] = inputs - return [map] - -normalizeOutput = (values, options) -> - return values if options.raw - result = [] - previous = null - current = result - for packet in values - if packet.type is 'openBracket' - previous = current - current = [] - previous.push current - if packet.type is 'data' - current.push packet.data - if packet.type is 'closeBracket' - current = previous - if result.length is 1 - return result[0] - return result - -sendOutputMap = (outputs, resultType, options, callback) -> - # First check if the output sequence contains errors - errors = outputs.filter((map) -> map.error?).map (map) -> map.error - return callback normalizeOutput errors, options if errors.length - - if resultType is 'sequence' - return callback null, outputs.map (map) -> - res = {} - for key, val of map - if options.raw - res[key] = val - continue - res[key] = normalizeOutput [val], options - return res - - # Flatten the sequence - mappedOutputs = {} - for map in outputs - for key, val of map - mappedOutputs[key] = [] unless mappedOutputs[key] - mappedOutputs[key].push val - - outputKeys = Object.keys mappedOutputs - withValue = outputKeys.filter (outport) -> - mappedOutputs[outport].length > 0 - if withValue.length is 0 - # No output - return callback null - if withValue.length is 1 and resultType is 'simple' - # Single outport - return callback null, normalizeOutput mappedOutputs[withValue[0]], options - result = {} - for port, packets of mappedOutputs - result[port] = normalizeOutput packets, options - callback null, result - -exports.asCallback = (component, options) -> - options = normalizeOptions options, component - return (inputs, callback) -> - prepareNetwork component, options, (err, network) -> - return callback err if err - resultType = getType inputs, network - inputMap = prepareInputMap inputs, resultType, network - runNetwork network, inputMap, options, (err, outputMap) -> - return callback err if err - sendOutputMap outputMap, resultType, options, callback diff --git a/src/lib/AsCallback.js b/src/lib/AsCallback.js new file mode 100644 index 000000000..bde21f794 --- /dev/null +++ b/src/lib/AsCallback.js @@ -0,0 +1,337 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2017-2018 Flowhub UG +// NoFlo may be freely distributed under the MIT license + +/* eslint-disable + no-param-reassign, +*/ +const { + Graph, +} = require('fbp-graph'); +const { + ComponentLoader, +} = require('./ComponentLoader'); +const { + Network, +} = require('./Network'); +const IP = require('./IP'); +const internalSocket = require('./InternalSocket'); + +// ## asCallback embedding API +// +// asCallback is a helper for embedding NoFlo components or +// graphs in other JavaScript programs. +// +// By using the `noflo.asCallback` function, you can turn any +// NoFlo component or NoFlo Graph instance into a regular, +// Node.js-style JavaScript function. +// +// Each call to that function starts a new NoFlo network where +// the given input arguments are sent as IP objects to matching +// inports. Once the network finishes, the IP objects received +// from the network will be sent to the callback function. +// +// If there was anything sent to an `error` outport, this will +// be provided as the error argument to the callback. + +// ### Option normalization +// +// Here we handle the input valus given to the `asCallback` +// function. This allows passing things like a pre-initialized +// NoFlo ComponentLoader, or giving the component loading +// baseDir context. +function normalizeOptions(options, component) { + if (!options) { options = {}; } + if (!options.name) { options.name = component; } + if (options.loader) { + options.baseDir = options.loader.baseDir; + } + if (!options.baseDir && process && process.cwd) { + options.baseDir = process.cwd(); + } + if (!options.loader) { + options.loader = new ComponentLoader(options.baseDir); + } + if (!options.raw) { options.raw = false; } + return options; +} + +// ### Network preparation +// +// Each invocation of the asCallback-wrapped NoFlo graph +// creates a new network. This way we can isolate multiple +// executions of the function in their own contexts. +function prepareNetwork(component, options, callback) { + // If we were given a graph instance, then just create a network + let network; + if (typeof component === 'object') { + component.componentLoader = options.loader; + + network = new Network(component, options); + // Wire the network up + network.connect((err) => { + if (err) { + callback(err); + return; + } + callback(null, network); + }); + return; + } + + // Start by loading the component + options.loader.load(component, (err, instance) => { + if (err) { + callback(err); + return; + } + // Prepare a graph wrapping the component + const graph = new Graph(options.name); + const nodeName = options.name; + graph.addNode(nodeName, component); + // Expose ports + const inPorts = instance.inPorts.ports; + const outPorts = instance.outPorts.ports; + Object.keys(inPorts).forEach((port) => { + graph.addInport(port, nodeName, port); + }); + Object.keys(outPorts).forEach((port) => { + graph.addOutport(port, nodeName, port); + }); + // Prepare network + graph.componentLoader = options.loader; + network = new Network(graph, options); + // Wire the network up and start execution + network.connect((err2) => { + if (err2) { + callback(err2); + return; + } + callback(null, network); + }); + }); +} + +// ### Network execution +// +// Once network is ready, we connect to all of its exported +// in and outports and start the network. +// +// Input data is sent to the inports, and we collect IP +// packets received on the outports. +// +// Once the network finishes, we send the resulting IP +// objects to the callback. +function runNetwork(network, inputs, options, callback) { + // Prepare inports + let inSockets = {}; + // Subscribe outports + const received = []; + const outPorts = Object.keys(network.graph.outports); + let outSockets = {}; + outPorts.forEach((outport) => { + const portDef = network.graph.outports[outport]; + const process = network.getNode(portDef.process); + outSockets[outport] = internalSocket.createSocket(); + process.component.outPorts[portDef.port].attach(outSockets[outport]); + outSockets[outport].from = { + process, + port: portDef.port, + }; + outSockets[outport].on('ip', (ip) => { + const res = {}; + res[outport] = ip; + received.push(res); + }); + }); + // Subscribe to process errors + let onEnd = null; + let onError = null; + onError = (err) => { + callback(err.error); + network.removeListener('end', onEnd); + }; + network.once('process-error', onError); + // Subscribe network finish + onEnd = () => { + // Clear listeners + Object.keys(outSockets).forEach((port) => { + const socket = outSockets[port]; + socket.from.process.component.outPorts[socket.from.port].detach(socket); + }); + outSockets = {}; + inSockets = {}; + callback(null, received); + network.removeListener('process-error', onError); + }; + network.once('end', onEnd); + // Start network + network.start((err) => { + if (err) { + callback(err); + return; + } + // Send inputs + for (let i = 0; i < inputs.length; i += 1) { + const inputMap = inputs[i]; + const keys = Object.keys(inputMap); + for (let j = 0; j < keys.length; j += 1) { + const port = keys[j]; + const value = inputMap[port]; + if (!inSockets[port]) { + const portDef = network.graph.inports[port]; + const process = network.getNode(portDef.process); + inSockets[port] = internalSocket.createSocket(); + process.component.inPorts[portDef.port].attach(inSockets[port]); + } + try { + if (IP.isIP(value)) { + inSockets[port].post(value); + } else { + inSockets[port].post(new IP('data', value)); + } + } catch (e) { + callback(e); + network.removeListener('process-error', onError); + network.removeListener('end', onEnd); + return; + } + } + } + }); +} + +function getType(inputs, network) { + // Scalar values are always simple inputs + if (typeof inputs !== 'object') { return 'simple'; } + + if (Array.isArray(inputs)) { + const maps = inputs.filter((entry) => getType(entry, network) === 'map'); + // If each member if the array is an input map, this is a sequence + if (maps.length === inputs.length) { return 'sequence'; } + // Otherwise arrays must be simple inputs + return 'simple'; + } + + // Empty objects can't be maps + const keys = Object.keys(inputs); + if (!keys.length) { return 'simple'; } + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; + if (!network.graph.inports[key]) { return 'simple'; } + } + return 'map'; +} + +function prepareInputMap(inputs, inputType, network) { + // Sequence we can use as-is + if (inputType === 'sequence') { return inputs; } + // We can turn a map to a sequence by wrapping it in an array + if (inputType === 'map') { return [inputs]; } + // Simple inputs need to be converted to a sequence + let inPort = Object.keys(network.graph.inports)[0]; + // If we have a port named "IN", send to that + if (network.graph.inports.in) { inPort = 'in'; } + const map = {}; + map[inPort] = inputs; + return [map]; +} + +function normalizeOutput(values, options) { + if (options.raw) { return values; } + const result = []; + let previous = null; + let current = result; + values.forEach((packet) => { + if (packet.type === 'openBracket') { + previous = current; + current = []; + previous.push(current); + } + if (packet.type === 'data') { + current.push(packet.data); + } + if (packet.type === 'closeBracket') { + current = previous; + } + }); + if (result.length === 1) { + return result[0]; + } + return result; +} + +function sendOutputMap(outputs, resultType, options, callback) { + // First check if the output sequence contains errors + const errors = outputs.filter((map) => map.error != null).map((map) => map.error); + if (errors.length) { + callback(normalizeOutput(errors, options)); + return; + } + + if (resultType === 'sequence') { + callback(null, outputs.map((map) => { + const res = {}; + Object.keys(map).forEach((key) => { + const val = map[key]; + if (options.raw) { + res[key] = val; + return; + } + res[key] = normalizeOutput([val], options); + }); + return res; + })); + return; + } + + // Flatten the sequence + const mappedOutputs = {}; + outputs.forEach((map) => { + Object.keys(map).forEach((key) => { + const val = map[key]; + if (!mappedOutputs[key]) { mappedOutputs[key] = []; } + mappedOutputs[key].push(val); + }); + }); + + const outputKeys = Object.keys(mappedOutputs); + const withValue = outputKeys.filter((outport) => mappedOutputs[outport].length > 0); + if (withValue.length === 0) { + // No output + callback(null); + return; + } + if ((withValue.length === 1) && (resultType === 'simple')) { + // Single outport + callback(null, normalizeOutput(mappedOutputs[withValue[0]], options)); + return; + } + const result = {}; + Object.keys(mappedOutputs).forEach((port) => { + const packets = mappedOutputs[port]; + result[port] = normalizeOutput(packets, options); + }); + callback(null, result); +} + +exports.asCallback = function asCallback(component, options) { + options = normalizeOptions(options, component); + return (inputs, callback) => { + prepareNetwork(component, options, (err, network) => { + if (err) { + callback(err); + return; + } + const resultType = getType(inputs, network); + const inputMap = prepareInputMap(inputs, resultType, network); + runNetwork(network, inputMap, options, (err2, outputMap) => { + if (err2) { + callback(err2); + return; + } + sendOutputMap(outputMap, resultType, options, callback); + }); + }); + }; +}; diff --git a/src/lib/AsComponent.coffee b/src/lib/AsComponent.coffee deleted file mode 100644 index aa7a23666..000000000 --- a/src/lib/AsComponent.coffee +++ /dev/null @@ -1,105 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2018 Flowhub UG -# NoFlo may be freely distributed under the MIT license -getParams = require 'get-function-params' -{Component} = require './Component' - -# ## asComponent generator API -# -# asComponent is a helper for turning JavaScript functions into -# NoFlo components. -# -# Each call to this function returns a component instance where -# the input parameters of the given function are converted into -# NoFlo inports, and there are `out` and `error` ports for the -# results of the function execution. -# -# Variants supported: -# -# * Regular synchronous functions: return value gets sent to `out`. Thrown errors get sent to `error` -# * Functions returning a Promise: resolved promises get sent to `out`, rejected promises to `error` -# * Functions taking a Node.js style asynchronous callback: `err` argument to callback gets sent to `error`, result gets sent to `out` -# -# Usage example: -# -# exports.getComponent = function () { -# return noflo.asComponent(Math.random, { -# description: 'Generate a random number', -# }); -# }; -# -# ### Wrapping built-in functions -# -# Built-in JavaScript functions don't make their arguments introspectable. Because of this, these -# cannot be directly converted to components. You'll have to provide a wrapper JavaScript function to make -# the arguments appear as ports. -# -# Example: -# -# exports.getComponent = function () { -# return noflo.asComponent(function (selector) { -# return document.querySelector(selector); -# }, { -# description: 'Return an element matching the CSS selector', -# icon: 'html5', -# }); -# }; -# -# ### Default values -# -# Function arguments with a default value are supported in ES6 environments. The default arguments are visible via the component's -# port interface. -# -# However, ES5 transpilation doesn't work with default values. In these cases the port with a default won't be visible. It is -# recommended to use default values only with components that don't need to run in legacy browsers. -exports.asComponent = (func, options) -> - hasCallback = false - params = getParams(func).filter (p) -> - return true unless p.param is 'callback' - hasCallback = true - false - - c = new Component options - for p in params - portOptions = - required: true - unless typeof p.default is 'undefined' - portOptions.default = p.default - portOptions.required = false - c.inPorts.add p.param, portOptions - c.forwardBrackets[p.param] = ['out', 'error'] - unless params.length - c.inPorts.add 'in', - datatype: 'bang' - - c.outPorts.add 'out' - c.outPorts.add 'error' - c.process (input, output) -> - if params.length - for p in params - return unless input.hasData p.param - values = params.map (p) -> - input.getData p.param - else - return unless input.hasData 'in' - input.getData 'in' - values = [] - - if hasCallback - # Handle Node.js style async functions - values.push (err, res) -> - return output.done err if err - output.sendDone res - res = func.apply null, values - return - - res = func.apply null, values - if res and typeof res is 'object' and typeof res.then is 'function' - # Result is a Promise, resolve and handle - res.then (val) -> - output.sendDone val - , (err) -> - output.done err - return - output.sendDone res - c diff --git a/src/lib/AsComponent.js b/src/lib/AsComponent.js new file mode 100644 index 000000000..f58e45f42 --- /dev/null +++ b/src/lib/AsComponent.js @@ -0,0 +1,121 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2018 Flowhub UG +// NoFlo may be freely distributed under the MIT license +const getParams = require('get-function-params'); +const { Component } = require('./Component'); + +// ## asComponent generator API +// +// asComponent is a helper for turning JavaScript functions into +// NoFlo components. +// +// Each call to this function returns a component instance where +// the input parameters of the given function are converted into +// NoFlo inports, and there are `out` and `error` ports for the +// results of the function execution. +// +// Variants supported: +// +// * Regular synchronous functions: return value gets sent to `out`. +// Thrown errors get sent to `error` +// * Functions returning a Promise: resolved promises get sent to `out`, +// rejected promises to `error` +// * Functions taking a Node.js style asynchronous callback: `err` argument +// to callback gets sent to `error`, result gets sent to `out` +// +// Usage example: +// +// exports.getComponent = function () { +// return noflo.asComponent(Math.random, { +// description: 'Generate a random number', +// }); +// }; +// +// ### Wrapping built-in functions +// +// Built-in JavaScript functions don't make their arguments introspectable. +// Because of this, these cannot be directly converted to components. +// You'll have to provide a wrapper JavaScript function to make the arguments appear as ports. +// +// Example: +// +// exports.getComponent = function () { +// return noflo.asComponent(function (selector) { +// return document.querySelector(selector); +// }, { +// description: 'Return an element matching the CSS selector', +// icon: 'html5', +// }); +// }; +// +// ### Default values +// +// Function arguments with a default value are supported in ES6 environments. +// The default arguments are visible via the component's port interface. +// +// However, ES5 transpilation doesn't work with default values. +// In these cases the port with a default won't be visible. It is +// recommended to use default values only with components that don't need to run in legacy browsers. +exports.asComponent = function asComponent(func, options) { + let hasCallback = false; + const params = getParams(func).filter((p) => { + if (p.param !== 'callback') { return true; } + hasCallback = true; + return false; + }); + + const c = new Component(options); + params.forEach((p) => { + const portOptions = { required: true }; + if (typeof p.default !== 'undefined') { + portOptions.default = p.default; + portOptions.required = false; + } + c.inPorts.add(p.param, portOptions); + c.forwardBrackets[p.param] = ['out', 'error']; + }); + if (!params.length) { + c.inPorts.add('in', + { datatype: 'bang' }); + } + + c.outPorts.add('out'); + c.outPorts.add('error'); + c.process((input, output) => { + let values; + if (params.length) { + for (let i = 0; i < params.length; i += 1) { + const p = params[i]; + if (!input.hasData(p.param)) { return; } + } + values = params.map((p) => input.getData(p.param)); + } else { + if (!input.hasData('in')) { return; } + input.getData('in'); + values = []; + } + + if (hasCallback) { + // Handle Node.js style async functions + values.push((err, res) => { + if (err) { + output.done(err); + return; + } + output.sendDone(res); + }); + func(...values); + return; + } + + const res = func(...values); + if (res && (typeof res === 'object') && (typeof res.then === 'function')) { + // Result is a Promise, resolve and handle + res.then((val) => output.sendDone(val), + (err) => output.done(err)); + return; + } + output.sendDone(res); + }); + return c; +}; diff --git a/src/lib/BaseNetwork.coffee b/src/lib/BaseNetwork.coffee deleted file mode 100644 index a86591272..000000000 --- a/src/lib/BaseNetwork.coffee +++ /dev/null @@ -1,685 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2013-2018 Flowhub UG -# (c) 2011-2012 Henri Bergius, Nemein -# NoFlo may be freely distributed under the MIT license -internalSocket = require "./InternalSocket" -graph = require "fbp-graph" -{EventEmitter} = require 'events' -platform = require './Platform' -componentLoader = require './ComponentLoader' -utils = require './Utils' -IP = require './IP' - -# ## The NoFlo network coordinator -# -# NoFlo networks consist of processes connected to each other -# via sockets attached from outports to inports. -# -# The role of the network coordinator is to take a graph and -# instantiate all the necessary processes from the designated -# components, attach sockets between them, and handle the sending -# of Initial Information Packets. -class BaseNetwork extends EventEmitter - # Processes contains all the instantiated components for this network - processes: {} - # Connections contains all the socket connections in the network - connections: [] - # Initials contains all Initial Information Packets (IIPs) - initials: [] - # Container to hold sockets that will be sending default data. - defaults: [] - # The Graph this network is instantiated with - graph: null - # Start-up timestamp for the network, used for calculating uptime - startupDate: null - - # All NoFlo networks are instantiated with a graph. Upon instantiation - # they will load all the needed components, instantiate them, and - # set up the defined connections and IIPs. - constructor: (graph, options = {}) -> - super() - @options = options - @processes = {} - @connections = [] - @initials = [] - @nextInitials = [] - @defaults = [] - @graph = graph - @started = false - @stopped = true - @debug = true - @eventBuffer = [] - - # On Node.js we default the baseDir for component loading to - # the current working directory - unless platform.isBrowser() - @baseDir = graph.baseDir or process.cwd() - # On browser we default the baseDir to the Component loading - # root - else - @baseDir = graph.baseDir or '/' - - # As most NoFlo networks are long-running processes, the - # network coordinator marks down the start-up time. This - # way we can calculate the uptime of the network. - @startupDate = null - - # Initialize a Component Loader for the network - if graph.componentLoader - @loader = graph.componentLoader - else - @loader = new componentLoader.ComponentLoader @baseDir, @options - - # The uptime of the network is the current time minus the start-up - # time, in seconds. - uptime: -> - return 0 unless @startupDate - new Date() - @startupDate - - getActiveProcesses: -> - active = [] - return active unless @started - for name, process of @processes - if process.component.load > 0 - # Modern component with load - active.push name - if process.component.__openConnections > 0 - # Legacy component - active.push name - return active - - bufferedEmit: (event, payload) -> - # Errors get emitted immediately, like does network end - if event in ['icon', 'error', 'process-error', 'end'] - @emit event, payload - return - if not @isStarted() and event isnt 'end' - @eventBuffer.push - type: event - payload: payload - return - - @emit event, payload - - if event is 'start' - # Once network has started we can send the IP-related events - for ev in @eventBuffer - @emit ev.type, ev.payload - @eventBuffer = [] - - if event is 'ip' - # Emit also the legacy events from IP - switch payload.type - when 'openBracket' - @bufferedEmit 'begingroup', payload - return - when 'closeBracket' - @bufferedEmit 'endgroup', payload - return - when 'data' - @bufferedEmit 'data', payload - return - - # ## Loading components - # - # Components can be passed to the NoFlo network in two ways: - # - # * As direct, instantiated JavaScript objects - # * As filenames - load: (component, metadata, callback) -> - @loader.load component, callback, metadata - - # ## Add a process to the network - # - # Processes can be added to a network at either start-up time - # or later. The processes are added with a node definition object - # that includes the following properties: - # - # * `id`: Identifier of the process in the network. Typically a string - # * `component`: Filename or path of a NoFlo component, or a component instance object - addNode: (node, options, callback) -> - if typeof options is 'function' - callback = options - options = {} - # Processes are treated as singletons by their identifier. If - # we already have a process with the given ID, return that. - if @processes[node.id] - callback null, @processes[node.id] - return - - process = - id: node.id - - # No component defined, just register the process but don't start. - unless node.component - @processes[process.id] = process - callback null, process - return - - # Load the component for the process. - @load node.component, node.metadata, (err, instance) => - return callback err if err - instance.nodeId = node.id - process.component = instance - process.componentName = node.component - - # Inform the ports of the node name - inPorts = process.component.inPorts.ports - outPorts = process.component.outPorts.ports - for name, port of inPorts - port.node = node.id - port.nodeInstance = instance - port.name = name - - for name, port of outPorts - port.node = node.id - port.nodeInstance = instance - port.name = name - - @subscribeSubgraph process if instance.isSubgraph() - - @subscribeNode process - - # Store and return the process instance - @processes[process.id] = process - callback null, process - - removeNode: (node, callback) -> - process = @getNode node.id - unless process - return callback new Error "Node #{node.id} not found" - process.component.shutdown (err) => - return callback err if err - delete @processes[node.id] - callback null - - renameNode: (oldId, newId, callback) -> - process = @getNode oldId - return callback new Error "Process #{oldId} not found" unless process - - # Inform the process of its ID - process.id = newId - - # Inform the ports of the node name - inPorts = process.component.inPorts.ports - outPorts = process.component.outPorts.ports - for name, port of inPorts - continue unless port - port.node = newId - for name, port of outPorts - continue unless port - port.node = newId - - @processes[newId] = process - delete @processes[oldId] - callback null - - # Get process by its ID. - getNode: (id) -> - @processes[id] - - connect: (done = ->) -> - # Wrap the future which will be called when done in a function and return - # it - callStack = 0 - serialize = (next, add) => - (type) => - # Add either a Node, an Initial, or an Edge and move on to the next one - # when done - this["add#{type}"] add, - initial: true - , (err) -> - return done err if err - callStack++ - if callStack % 100 is 0 - setTimeout -> - next type - , 0 - return - next type - - # Serialize default socket creation then call callback when done - setDefaults = utils.reduceRight @graph.nodes, serialize, -> done() - - # Serialize initializers then call defaults. - initializers = utils.reduceRight @graph.initializers, serialize, -> setDefaults "Defaults" - - # Serialize edge creators then call the initializers. - edges = utils.reduceRight @graph.edges, serialize, -> initializers "Initial" - - # Serialize node creators then call the edge creators - nodes = utils.reduceRight @graph.nodes, serialize, -> edges "Edge" - # Start with node creators - nodes "Node" - - connectPort: (socket, process, port, index, inbound, callback) -> - if inbound - socket.to = - process: process - port: port - index: index - - unless process.component.inPorts and process.component.inPorts[port] - callback new Error "No inport '#{port}' defined in process #{process.id} (#{socket.getId()})" - return - if process.component.inPorts[port].isAddressable() - process.component.inPorts[port].attach socket, index - do callback - return - process.component.inPorts[port].attach socket - do callback - return - - socket.from = - process: process - port: port - index: index - - unless process.component.outPorts and process.component.outPorts[port] - callback new Error "No outport '#{port}' defined in process #{process.id} (#{socket.getId()})" - return - - if process.component.outPorts[port].isAddressable() - process.component.outPorts[port].attach socket, index - do callback - return - process.component.outPorts[port].attach socket - do callback - return - - subscribeSubgraph: (node) -> - unless node.component.isReady() - node.component.once 'ready', => - @subscribeSubgraph node - return - - return unless node.component.network - - node.component.network.setDebug @debug - - emitSub = (type, data) => - if type is 'process-error' and @listeners('process-error').length is 0 - throw data.error if data.id and data.metadata and data.error - throw data - data = {} unless data - if data.subgraph - unless data.subgraph.unshift - data.subgraph = [data.subgraph] - data.subgraph.unshift node.id - else - data.subgraph = [node.id] - @bufferedEmit type, data - - node.component.network.on 'ip', (data) -> emitSub 'ip', data - node.component.network.on 'process-error', (data) -> - emitSub 'process-error', data - - # Subscribe to events from all connected sockets and re-emit them - subscribeSocket: (socket, source) -> - socket.on 'ip', (ip) => - @bufferedEmit 'ip', - id: socket.getId() - type: ip.type - socket: socket - data: ip.data - metadata: socket.metadata - socket.on 'error', (event) => - if @listeners('process-error').length is 0 - throw event.error if event.id and event.metadata and event.error - throw event - @bufferedEmit 'process-error', event - return unless source?.component?.isLegacy() - # Handle activation for legacy components via connects/disconnects - socket.on 'connect', -> - source.component.__openConnections = 0 unless source.component.__openConnections - source.component.__openConnections++ - socket.on 'disconnect', => - source.component.__openConnections-- - if source.component.__openConnections < 0 - source.component.__openConnections = 0 - if source.component.__openConnections is 0 - @checkIfFinished() - - subscribeNode: (node) -> - node.component.on 'activate', (load) => - @abortDebounce = true if @debouncedEnd - node.component.on 'deactivate', (load) => - return if load > 0 - @checkIfFinished() - return unless node.component.getIcon - node.component.on 'icon', => - @bufferedEmit 'icon', - id: node.id - icon: node.component.getIcon() - - addEdge: (edge, options, callback) -> - if typeof options is 'function' - callback = options - options = {} - socket = internalSocket.createSocket edge.metadata - socket.setDebug @debug - - from = @getNode edge.from.node - unless from - return callback new Error "No process defined for outbound node #{edge.from.node}" - unless from.component - return callback new Error "No component defined for outbound node #{edge.from.node}" - unless from.component.isReady() - from.component.once "ready", => - @addEdge edge, callback - - return - - to = @getNode edge.to.node - unless to - return callback new Error "No process defined for inbound node #{edge.to.node}" - unless to.component - return callback new Error "No component defined for inbound node #{edge.to.node}" - unless to.component.isReady() - to.component.once "ready", => - @addEdge edge, callback - - return - - # Subscribe to events from the socket - @subscribeSocket socket, from - - @connectPort socket, to, edge.to.port, edge.to.index, true, (err) => - return callback err if err - @connectPort socket, from, edge.from.port, edge.from.index, false, (err) => - return callback err if err - - @connections.push socket - callback() - - removeEdge: (edge, callback) -> - for connection in @connections - continue unless connection - continue unless edge.to.node is connection.to.process.id and edge.to.port is connection.to.port - connection.to.process.component.inPorts[connection.to.port].detach connection - if edge.from.node - if connection.from and edge.from.node is connection.from.process.id and edge.from.port is connection.from.port - connection.from.process.component.outPorts[connection.from.port].detach connection - @connections.splice @connections.indexOf(connection), 1 - do callback - - addDefaults: (node, options, callback) -> - if typeof options is 'function' - callback = options - options = {} - - process = @getNode node.id - unless process - return callback new Error "Process #{node.id} not defined" - unless process.component - return callback new Error "No component defined for node #{node.id}" - - unless process.component.isReady() - process.component.setMaxListeners 0 - process.component.once "ready", => - @addDefaults process, callback - return - - for key, port of process.component.inPorts.ports - # Attach a socket to any defaulted inPorts as long as they aren't already attached. - if port.hasDefault() and not port.isAttached() - socket = internalSocket.createSocket() - socket.setDebug @debug - - # Subscribe to events from the socket - @subscribeSocket socket - - @connectPort socket, process, key, undefined, true, -> - - @connections.push socket - - @defaults.push socket - - callback() - - addInitial: (initializer, options, callback) -> - if typeof options is 'function' - callback = options - options = {} - - socket = internalSocket.createSocket initializer.metadata - socket.setDebug @debug - - # Subscribe to events from the socket - @subscribeSocket socket - - to = @getNode initializer.to.node - unless to - return callback new Error "No process defined for inbound node #{initializer.to.node}" - unless to.component - return callback new Error "No component defined for inbound node #{initializer.to.node}" - - unless to.component.isReady() or to.component.inPorts[initializer.to.port] - to.component.setMaxListeners 0 - to.component.once "ready", => - @addInitial initializer, callback - return - - @connectPort socket, to, initializer.to.port, initializer.to.index, true, (err) => - return callback err if err - - @connections.push socket - - init = - socket: socket - data: initializer.from.data - @initials.push init - @nextInitials.push init - - if @isRunning() - # Network is running now, send initials immediately - do @sendInitials - else if not @isStopped() - # Network has finished but hasn't been stopped, set - # started and set - @setStarted true - do @sendInitials - - callback() - - removeInitial: (initializer, callback) -> - for connection in @connections - continue unless connection - continue unless initializer.to.node is connection.to.process.id and initializer.to.port is connection.to.port - connection.to.process.component.inPorts[connection.to.port].detach connection - @connections.splice @connections.indexOf(connection), 1 - - for init in @initials - continue unless init - continue unless init.socket is connection - @initials.splice @initials.indexOf(init), 1 - for init in @nextInitials - continue unless init - continue unless init.socket is connection - @nextInitials.splice @nextInitials.indexOf(init), 1 - - do callback - - sendInitial: (initial) -> - initial.socket.post new IP 'data', initial.data, - initial: true - - sendInitials: (callback) -> - unless callback - callback = -> - - send = => - @sendInitial initial for initial in @initials - @initials = [] - do callback - - if typeof process isnt 'undefined' and process.execPath and process.execPath.indexOf('node') isnt -1 - # nextTick is faster on Node.js - process.nextTick send - else - setTimeout send, 0 - - isStarted: -> - @started - isStopped: -> - @stopped - - isRunning: -> - return @getActiveProcesses().length > 0 - - startComponents: (callback) -> - unless callback - callback = -> - - # Emit start event when all processes are started - count = 0 - length = if @processes then Object.keys(@processes).length else 0 - onProcessStart = (err) -> - return callback err if err - count++ - callback() if count is length - - # Perform any startup routines necessary for every component. - return callback() unless @processes and Object.keys(@processes).length - for id, process of @processes - if process.component.isStarted() - onProcessStart() - continue - if process.component.start.length is 0 - platform.deprecated 'component.start method without callback is deprecated' - process.component.start() - onProcessStart() - continue - process.component.start onProcessStart - - sendDefaults: (callback) -> - unless callback - callback = -> - - return callback() unless @defaults.length - - for socket in @defaults - # Don't send defaults if more than one socket is present on the port. - # This case should only happen when a subgraph is created as a component - # as its network is instantiated and its inputs are serialized before - # a socket is attached from the "parent" graph. - continue unless socket.to.process.component.inPorts[socket.to.port].sockets.length is 1 - socket.connect() - socket.send() - socket.disconnect() - - do callback - - start: (callback) -> - unless callback - platform.deprecated 'Calling network.start() without callback is deprecated' - callback = -> - - @abortDebounce = true if @debouncedEnd - - if @started - @stop (err) => - return callback err if err - @start callback - return - - @initials = @nextInitials.slice 0 - @eventBuffer = [] - @startComponents (err) => - return callback err if err - @sendInitials (err) => - return callback err if err - @sendDefaults (err) => - return callback err if err - @setStarted true - callback null - return - return - return - return - - stop: (callback) -> - unless callback - platform.deprecated 'Calling network.stop() without callback is deprecated' - callback = -> - - @abortDebounce = true if @debouncedEnd - - unless @started - @stopped = true - return callback null - - # Disconnect all connections - for connection in @connections - continue unless connection.isConnected() - connection.disconnect() - - # Emit stop event when all processes are stopped - count = 0 - length = if @processes then Object.keys(@processes).length else 0 - onProcessEnd = (err) => - return callback err if err - count++ - if count is length - @setStarted false - @stopped = true - callback() - unless @processes and Object.keys(@processes).length - @setStarted false - @stopped = true - return callback() - # Tell processes to shut down - for id, process of @processes - unless process.component.isStarted() - onProcessEnd() - continue - if process.component.shutdown.length is 0 - platform.deprecated 'component.shutdown method without callback is deprecated' - process.component.shutdown() - onProcessEnd() - continue - process.component.shutdown onProcessEnd - - setStarted: (started) -> - return if @started is started - unless started - # Ending the execution - @started = false - @bufferedEmit 'end', - start: @startupDate - end: new Date - uptime: @uptime() - return - - # Starting the execution - @startupDate = new Date unless @startupDate - @started = true - @stopped = false - @bufferedEmit 'start', - start: @startupDate - - checkIfFinished: -> - return if @isRunning() - delete @abortDebounce - unless @debouncedEnd - @debouncedEnd = utils.debounce => - return if @abortDebounce - return if @isRunning() - @setStarted false - , 50 - do @debouncedEnd - - getDebug: () -> - @debug - - setDebug: (active) -> - return if active == @debug - @debug = active - - for socket in @connections - socket.setDebug active - for processId, process of @processes - instance = process.component - instance.network.setDebug active if instance.isSubgraph() - -module.exports = BaseNetwork diff --git a/src/lib/BaseNetwork.js b/src/lib/BaseNetwork.js new file mode 100644 index 000000000..abee7fd59 --- /dev/null +++ b/src/lib/BaseNetwork.js @@ -0,0 +1,900 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2018 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license + +/* eslint-disable + no-param-reassign, + no-underscore-dangle, +*/ + +const { EventEmitter } = require('events'); +const internalSocket = require('./InternalSocket'); +const platform = require('./Platform'); +const componentLoader = require('./ComponentLoader'); +const utils = require('./Utils'); +const IP = require('./IP'); + +function connectPort(socket, process, port, index, inbound, callback) { + if (inbound) { + socket.to = { + process, + port, + index, + }; + + if (!process.component.inPorts || !process.component.inPorts[port]) { + callback(new Error(`No inport '${port}' defined in process ${process.id} (${socket.getId()})`)); + return; + } + if (process.component.inPorts[port].isAddressable()) { + process.component.inPorts[port].attach(socket, index); + callback(); + return; + } + process.component.inPorts[port].attach(socket); + callback(); + return; + } + + socket.from = { + process, + port, + index, + }; + + if (!process.component.outPorts || !process.component.outPorts[port]) { + callback(new Error(`No outport '${port}' defined in process ${process.id} (${socket.getId()})`)); + return; + } + + if (process.component.outPorts[port].isAddressable()) { + process.component.outPorts[port].attach(socket, index); + callback(); + return; + } + process.component.outPorts[port].attach(socket); + callback(); +} + +function sendInitial(initial) { + initial.socket.post(new IP('data', initial.data, + { initial: true })); +} + +// ## The NoFlo network coordinator +// +// NoFlo networks consist of processes connected to each other +// via sockets attached from outports to inports. +// +// The role of the network coordinator is to take a graph and +// instantiate all the necessary processes from the designated +// components, attach sockets between them, and handle the sending +// of Initial Information Packets. +class BaseNetwork extends EventEmitter { + // All NoFlo networks are instantiated with a graph. Upon instantiation + // they will load all the needed components, instantiate them, and + // set up the defined connections and IIPs. + constructor(graph, options) { + if (options == null) { options = {}; } + super(); + this.options = options; + // Processes contains all the instantiated components for this network + this.processes = {}; + // Connections contains all the socket connections in the network + this.connections = []; + // Initials contains all Initial Information Packets (IIPs) + this.initials = []; + this.nextInitials = []; + // Container to hold sockets that will be sending default data. + this.defaults = []; + // The Graph this network is instantiated with + this.graph = graph; + this.started = false; + this.stopped = true; + this.debug = true; + this.eventBuffer = []; + + // On Node.js we default the baseDir for component loading to + // the current working directory + if (!platform.isBrowser()) { + this.baseDir = graph.baseDir || process.cwd(); + // On browser we default the baseDir to the Component loading + // root + } else { + this.baseDir = graph.baseDir || '/'; + } + + // As most NoFlo networks are long-running processes, the + // network coordinator marks down the start-up time. This + // way we can calculate the uptime of the network. + this.startupDate = null; + + // Initialize a Component Loader for the network + if (graph.componentLoader) { + this.loader = graph.componentLoader; + } else { + this.loader = new componentLoader.ComponentLoader(this.baseDir, this.options); + } + } + + // The uptime of the network is the current time minus the start-up + // time, in seconds. + uptime() { + if (!this.startupDate) { return 0; } + return new Date() - this.startupDate; + } + + getActiveProcesses() { + const active = []; + if (!this.started) { return active; } + Object.keys(this.processes).forEach((name) => { + const process = this.processes[name]; + if (process.component.load > 0) { + // Modern component with load + active.push(name); + } + if (process.component.__openConnections > 0) { + // Legacy component + active.push(name); + } + }); + return active; + } + + bufferedEmit(event, payload) { + // Errors get emitted immediately, like does network end + if (['icon', 'error', 'process-error', 'end'].includes(event)) { + this.emit(event, payload); + return; + } + if (!this.isStarted() && (event !== 'end')) { + this.eventBuffer.push({ + type: event, + payload, + }); + return; + } + + this.emit(event, payload); + + if (event === 'start') { + // Once network has started we can send the IP-related events + this.eventBuffer.forEach((ev) => { + this.emit(ev.type, ev.payload); + }); + this.eventBuffer = []; + } + + if (event === 'ip') { + // Emit also the legacy events from IP + switch (payload.type) { + case 'openBracket': + this.bufferedEmit('begingroup', payload); + return; + case 'closeBracket': + this.bufferedEmit('endgroup', payload); + return; + case 'data': + this.bufferedEmit('data', payload); + break; + default: + } + } + } + + // ## Loading components + // + // Components can be passed to the NoFlo network in two ways: + // + // * As direct, instantiated JavaScript objects + // * As filenames + load(component, metadata, callback) { + this.loader.load(component, callback, metadata); + } + + // ## Add a process to the network + // + // Processes can be added to a network at either start-up time + // or later. The processes are added with a node definition object + // that includes the following properties: + // + // * `id`: Identifier of the process in the network. Typically a string + // * `component`: Filename or path of a NoFlo component, or a component instance object + addNode(node, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + // Processes are treated as singletons by their identifier. If + // we already have a process with the given ID, return that. + if (this.processes[node.id]) { + callback(null, this.processes[node.id]); + return; + } + + const process = { id: node.id }; + + // No component defined, just register the process but don't start. + if (!node.component) { + this.processes[process.id] = process; + callback(null, process); + return; + } + + // Load the component for the process. + this.load(node.component, node.metadata, (err, instance) => { + if (err) { + callback(err); + return; + } + instance.nodeId = node.id; + process.component = instance; + process.componentName = node.component; + + // Inform the ports of the node name + const inPorts = process.component.inPorts.ports; + const outPorts = process.component.outPorts.ports; + Object.keys(inPorts).forEach((name) => { + const port = inPorts[name]; + port.node = node.id; + port.nodeInstance = instance; + port.name = name; + }); + + Object.keys(outPorts).forEach((name) => { + const port = outPorts[name]; + port.node = node.id; + port.nodeInstance = instance; + port.name = name; + }); + + if (instance.isSubgraph()) { this.subscribeSubgraph(process); } + + this.subscribeNode(process); + + // Store and return the process instance + this.processes[process.id] = process; + callback(null, process); + }); + } + + removeNode(node, callback) { + const process = this.getNode(node.id); + if (!process) { + callback(new Error(`Node ${node.id} not found`)); + return; + } + process.component.shutdown((err) => { + if (err) { + callback(err); + return; + } + delete this.processes[node.id]; + callback(null); + }); + } + + renameNode(oldId, newId, callback) { + const process = this.getNode(oldId); + if (!process) { + callback(new Error(`Process ${oldId} not found`)); + return; + } + + // Inform the process of its ID + process.id = newId; + + // Inform the ports of the node name + const inPorts = process.component.inPorts.ports; + const outPorts = process.component.outPorts.ports; + Object.keys(inPorts).forEach((name) => { + const port = inPorts[name]; + if (!port) { return; } + port.node = newId; + }); + Object.keys(outPorts).forEach((name) => { + const port = outPorts[name]; + if (!port) { return; } + port.node = newId; + }); + + this.processes[newId] = process; + delete this.processes[oldId]; + callback(null); + } + + // Get process by its ID. + getNode(id) { + return this.processes[id]; + } + + connect(done = () => {}) { + // Wrap the future which will be called when done in a function and return + // it + let callStack = 0; + const serialize = (next, add) => (type) => { + // Add either a Node, an Initial, or an Edge and move on to the next one + // when done + this[`add${type}`](add, + { initial: true }, + (err) => { + if (err) { + done(err); + return; + } + callStack += 1; + if ((callStack % 100) === 0) { + setTimeout(() => { + next(type); + }, + 0); + return; + } + next(type); + }); + }; + + // Serialize default socket creation then call callback when done + const setDefaults = utils.reduceRight(this.graph.nodes, serialize, () => { + done(); + }); + + // Serialize initializers then call defaults. + const initializers = utils.reduceRight(this.graph.initializers, serialize, () => { + setDefaults('Defaults'); + }); + + // Serialize edge creators then call the initializers. + const edges = utils.reduceRight(this.graph.edges, serialize, () => { + initializers('Initial'); + }); + + // Serialize node creators then call the edge creators + const nodes = utils.reduceRight(this.graph.nodes, serialize, () => { + edges('Edge'); + }); + // Start with node creators + nodes('Node'); + } + + subscribeSubgraph(node) { + if (!node.component.isReady()) { + node.component.once('ready', () => { + this.subscribeSubgraph(node); + }); + return; + } + + if (!node.component.network) { return; } + + node.component.network.setDebug(this.debug); + + const emitSub = (type, data) => { + if ((type === 'process-error') && (this.listeners('process-error').length === 0)) { + if (data.id && data.metadata && data.error) { throw data.error; } + throw data; + } + if (!data) { data = {}; } + if (data.subgraph) { + if (!data.subgraph.unshift) { + data.subgraph = [data.subgraph]; + } + data.subgraph.unshift(node.id); + } else { + data.subgraph = [node.id]; + } + return this.bufferedEmit(type, data); + }; + + node.component.network.on('ip', (data) => { + emitSub('ip', data); + }); + node.component.network.on('process-error', (data) => { + emitSub('process-error', data); + }); + } + + // Subscribe to events from all connected sockets and re-emit them + subscribeSocket(socket, source) { + socket.on('ip', (ip) => { + this.bufferedEmit('ip', { + id: socket.getId(), + type: ip.type, + socket, + data: ip.data, + metadata: socket.metadata, + }); + }); + socket.on('error', (event) => { + if (this.listeners('process-error').length === 0) { + if (event.id && event.metadata && event.error) { throw event.error; } + throw event; + } + this.bufferedEmit('process-error', event); + }); + if (!source || !source.component || !source.component.isLegacy()) { + return; + } + // Handle activation for legacy components via connects/disconnects + socket.on('connect', () => { + if (!source.component.__openConnections) { source.component.__openConnections = 0; } + source.component.__openConnections += 1; + }); + socket.on('disconnect', () => { + source.component.__openConnections -= 1; + if (source.component.__openConnections < 0) { + source.component.__openConnections = 0; + } + if (source.component.__openConnections === 0) { + this.checkIfFinished(); + } + }); + } + + subscribeNode(node) { + node.component.on('activate', () => { + if (this.debouncedEnd) { this.abortDebounce = true; } + }); + node.component.on('deactivate', (load) => { + if (load > 0) { return; } + this.checkIfFinished(); + }); + if (!node.component.getIcon) { return; } + node.component.on('icon', () => { + this.bufferedEmit('icon', { + id: node.id, + icon: node.component.getIcon(), + }); + }); + } + + addEdge(edge, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + const socket = internalSocket.createSocket(edge.metadata); + socket.setDebug(this.debug); + + const from = this.getNode(edge.from.node); + if (!from) { + callback(new Error(`No process defined for outbound node ${edge.from.node}`)); + return; + } + if (!from.component) { + callback(new Error(`No component defined for outbound node ${edge.from.node}`)); + return; + } + if (!from.component.isReady()) { + from.component.once('ready', () => { + this.addEdge(edge, callback); + }); + + return; + } + + const to = this.getNode(edge.to.node); + if (!to) { + callback(new Error(`No process defined for inbound node ${edge.to.node}`)); + return; + } + if (!to.component) { + callback(new Error(`No component defined for inbound node ${edge.to.node}`)); + return; + } + if (!to.component.isReady()) { + to.component.once('ready', () => { + this.addEdge(edge, callback); + }); + + return; + } + + // Subscribe to events from the socket + this.subscribeSocket(socket, from); + + connectPort(socket, to, edge.to.port, edge.to.index, true, (err) => { + if (err) { + callback(err); + return; + } + connectPort(socket, from, edge.from.port, edge.from.index, false, (err2) => { + if (err2) { + callback(err2); + return; + } + + this.connections.push(socket); + callback(); + }); + }); + } + + removeEdge(edge, callback) { + this.connections.forEach((connection) => { + if (!connection) { return; } + if ((edge.to.node !== connection.to.process.id) || (edge.to.port !== connection.to.port)) { + return; + } + connection.to.process.component.inPorts[connection.to.port].detach(connection); + if (edge.from.node) { + if (connection.from + && (edge.from.node === connection.from.process.id) + && (edge.from.port === connection.from.port)) { + connection.from.process.component.outPorts[connection.from.port].detach(connection); + } + } + this.connections.splice(this.connections.indexOf(connection), 1); + callback(); + }); + } + + addDefaults(node, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + const process = this.getNode(node.id); + if (!process) { + callback(new Error(`Process ${node.id} not defined`)); + return; + } + if (!process.component) { + callback(new Error(`No component defined for node ${node.id}`)); + return; + } + + if (!process.component.isReady()) { + process.component.setMaxListeners(0); + process.component.once('ready', () => { + this.addDefaults(process, callback); + }); + return; + } + + Object.keys(process.component.inPorts.ports).forEach((key) => { + // Attach a socket to any defaulted inPorts as long as they aren't already attached. + const port = process.component.inPorts.ports[key]; + if (port.hasDefault() && !port.isAttached()) { + const socket = internalSocket.createSocket(); + socket.setDebug(this.debug); + + // Subscribe to events from the socket + this.subscribeSocket(socket); + + connectPort(socket, process, key, undefined, true, () => {}); + + this.connections.push(socket); + + this.defaults.push(socket); + } + }); + + callback(); + } + + addInitial(initializer, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + const socket = internalSocket.createSocket(initializer.metadata); + socket.setDebug(this.debug); + + // Subscribe to events from the socket + this.subscribeSocket(socket); + + const to = this.getNode(initializer.to.node); + if (!to) { + callback(new Error(`No process defined for inbound node ${initializer.to.node}`)); + return; + } + if (!to.component) { + callback(new Error(`No component defined for inbound node ${initializer.to.node}`)); + return; + } + + if (!to.component.isReady() && !to.component.inPorts[initializer.to.port]) { + to.component.setMaxListeners(0); + to.component.once('ready', () => { + this.addInitial(initializer, callback); + }); + return; + } + + connectPort(socket, to, initializer.to.port, initializer.to.index, true, (err) => { + if (err) { + callback(err); + return; + } + + this.connections.push(socket); + + const init = { + socket, + data: initializer.from.data, + }; + this.initials.push(init); + this.nextInitials.push(init); + + if (this.isRunning()) { + // Network is running now, send initials immediately + (this.sendInitials)(); + } else if (!this.isStopped()) { + // Network has finished but hasn't been stopped, set + // started and set + this.setStarted(true); + (this.sendInitials)(); + } + + callback(); + }); + } + + removeInitial(initializer, callback) { + this.connections.forEach((connection) => { + if (!connection) { return; } + if ((initializer.to.node !== connection.to.process.id) + || (initializer.to.port !== connection.to.port)) { + return; + } + connection.to.process.component.inPorts[connection.to.port].detach(connection); + this.connections.splice(this.connections.indexOf(connection), 1); + + for (let i = 0; i < this.initials.length; i += 1) { + const init = this.initials[i]; + if (!init) { return; } + if (init.socket !== connection) { return; } + this.initials.splice(this.initials.indexOf(init), 1); + } + for (let i = 0; i < this.nextInitials.length; i += 1) { + const init = this.nextInitials[i]; + if (!init) { return; } + if (init.socket !== connection) { return; } + this.nextInitials.splice(this.nextInitials.indexOf(init), 1); + } + }); + + callback(); + } + + sendInitials(callback = () => {}) { + const send = () => { + this.initials.forEach((initial) => { sendInitial(initial); }); + this.initials = []; + callback(); + }; + + if ((typeof process !== 'undefined') && process.execPath && (process.execPath.indexOf('node') !== -1)) { + // nextTick is faster on Node.js + process.nextTick(send); + } else { + setTimeout(send, 0); + } + } + + isStarted() { + return this.started; + } + + isStopped() { + return this.stopped; + } + + isRunning() { + return this.getActiveProcesses().length > 0; + } + + startComponents(callback = () => {}) { + // Emit start event when all processes are started + let count = 0; + const length = this.processes ? Object.keys(this.processes).length : 0; + const onProcessStart = (err) => { + if (err) { + callback(err); + return; + } + count += 1; + if (count === length) { callback(); } + }; + + // Perform any startup routines necessary for every component. + if (!this.processes || !Object.keys(this.processes).length) { + callback(); + return; + } + Object.keys(this.processes).forEach((id) => { + const process = this.processes[id]; + if (process.component.isStarted()) { + onProcessStart(); + return; + } + if (process.component.start.length === 0) { + platform.deprecated('component.start method without callback is deprecated'); + process.component.start(); + onProcessStart(); + return; + } + process.component.start(onProcessStart); + }); + } + + sendDefaults(callback = () => {}) { + if (!this.defaults.length) { + callback(); + return; + } + + this.defaults.forEach((socket) => { + // Don't send defaults if more than one socket is present on the port. + // This case should only happen when a subgraph is created as a component + // as its network is instantiated and its inputs are serialized before + // a socket is attached from the "parent" graph. + if (socket.to.process.component.inPorts[socket.to.port].sockets.length !== 1) { return; } + socket.connect(); + socket.send(); + socket.disconnect(); + }); + + callback(); + } + + start(callback) { + if (!callback) { + platform.deprecated('Calling network.start() without callback is deprecated'); + callback = () => {}; + } + + if (this.debouncedEnd) { this.abortDebounce = true; } + + if (this.started) { + this.stop((err) => { + if (err) { + callback(err); + return; + } + this.start(callback); + }); + return; + } + + this.initials = this.nextInitials.slice(0); + this.eventBuffer = []; + this.startComponents((err) => { + if (err) { + callback(err); + return; + } + this.sendInitials((err2) => { + if (err2) { + callback(err2); + return; + } + this.sendDefaults((err3) => { + if (err3) { + callback(err3); + return; + } + this.setStarted(true); + callback(null); + }); + }); + }); + } + + stop(callback) { + if (!callback) { + platform.deprecated('Calling network.stop() without callback is deprecated'); + callback = () => {}; + } + + if (this.debouncedEnd) { this.abortDebounce = true; } + + if (!this.started) { + this.stopped = true; + callback(null); + return; + } + + // Disconnect all connections + this.connections.forEach((connection) => { + if (!connection.isConnected()) { return; } + connection.disconnect(); + }); + + // Emit stop event when all processes are stopped + let count = 0; + const length = this.processes ? Object.keys(this.processes).length : 0; + const onProcessEnd = (err) => { + if (err) { + callback(err); + return; + } + count += 1; + if (count === length) { + this.setStarted(false); + this.stopped = true; + callback(); + } + }; + if (!this.processes || !Object.keys(this.processes).length) { + this.setStarted(false); + this.stopped = true; + callback(); + return; + } + // Tell processes to shut down + Object.keys(this.processes).forEach((id) => { + const process = this.processes[id]; + if (!process.component.isStarted()) { + onProcessEnd(); + return; + } + if (process.component.shutdown.length === 0) { + platform.deprecated('component.shutdown method without callback is deprecated'); + process.component.shutdown(); + onProcessEnd(); + return; + } + process.component.shutdown(onProcessEnd); + }); + } + + setStarted(started) { + if (this.started === started) { return; } + if (!started) { + // Ending the execution + this.started = false; + this.bufferedEmit('end', { + start: this.startupDate, + end: new Date(), + uptime: this.uptime(), + }); + return; + } + + // Starting the execution + if (!this.startupDate) { this.startupDate = new Date(); } + this.started = true; + this.stopped = false; + this.bufferedEmit('start', + { start: this.startupDate }); + } + + checkIfFinished() { + if (this.isRunning()) { return; } + delete this.abortDebounce; + if (!this.debouncedEnd) { + this.debouncedEnd = utils.debounce(() => { + if (this.abortDebounce) { return; } + if (this.isRunning()) { return; } + this.setStarted(false); + }, + 50); + } + (this.debouncedEnd)(); + } + + getDebug() { + return this.debug; + } + + setDebug(active) { + if (active === this.debug) { return; } + this.debug = active; + + this.connections.forEach((socket) => { + socket.setDebug(active); + }); + Object.keys(this.processes).forEach((processId) => { + const process = this.processes[processId]; + const instance = process.component; + if (instance.isSubgraph()) { instance.network.setDebug(active); } + }); + } +} + +module.exports = BaseNetwork; diff --git a/src/lib/BasePort.coffee b/src/lib/BasePort.coffee deleted file mode 100644 index be85647a8..000000000 --- a/src/lib/BasePort.coffee +++ /dev/null @@ -1,136 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2014-2017 Flowhub UG -# NoFlo may be freely distributed under the MIT license -{EventEmitter} = require 'events' - -# ## NoFlo Port Base class -# -# Base port type used for options normalization. Both inports and outports extend this class. - -# The list of valid datatypes for ports. -validTypes = [ - 'all' - 'string' - 'number' - 'int' - 'object' - 'array' - 'boolean' - 'color' - 'date' - 'bang' - 'function' - 'buffer' - 'stream' -] - -class BasePort extends EventEmitter - constructor: (options) -> - super() - # Options holds all options of the current port - @options = @handleOptions options - # Sockets list contains all currently attached - # connections to the port - @sockets = [] - # Name of the graph node this port is in - @node = null - # Name of the port - @name = null - - handleOptions: (options) -> - options = {} unless options - # We default to the `all` type if no explicit datatype - # was provided - options.datatype = 'all' unless options.datatype - # By default ports are not required for graph execution - options.required = false if options.required is undefined - - # Normalize the legacy `integer` type to `int`. - options.datatype = 'int' if options.datatype is 'integer' - - # Ensure datatype defined for the port is valid - if validTypes.indexOf(options.datatype) is -1 - throw new Error "Invalid port datatype '#{options.datatype}' specified, valid are #{validTypes.join(', ')}" - - # Ensure schema defined for the port is valid - if options.type and not options.schema - options.schema = options.type - delete options.type - if options.schema and options.schema.indexOf('/') is -1 - throw new Error "Invalid port schema '#{options.schema}' specified. Should be URL or MIME type" - - options - - getId: -> - unless @node and @name - return 'Port' - "#{@node} #{@name.toUpperCase()}" - - getDataType: -> @options.datatype - getSchema: -> @options.schema or null - getDescription: -> @options.description - - attach: (socket, index = null) -> - if not @isAddressable() or index is null - index = @sockets.length - @sockets[index] = socket - @attachSocket socket, index - if @isAddressable() - @emit 'attach', socket, index - return - @emit 'attach', socket - - attachSocket: -> - - detach: (socket) -> - index = @sockets.indexOf socket - if index is -1 - return - @sockets[index] = undefined - if @isAddressable() - @emit 'detach', socket, index - return - @emit 'detach', socket - - isAddressable: -> - return true if @options.addressable - false - - isBuffered: -> - return true if @options.buffered - false - - isRequired: -> - return true if @options.required - false - - isAttached: (socketId = null) -> - if @isAddressable() and socketId isnt null - return true if @sockets[socketId] - return false - return true if @sockets.length - false - - listAttached: -> - attached = [] - for socket, idx in @sockets - continue unless socket - attached.push idx - attached - - isConnected: (socketId = null) -> - if @isAddressable() - throw new Error "#{@getId()}: Socket ID required" if socketId is null - throw new Error "#{@getId()}: Socket #{socketId} not available" unless @sockets[socketId] - return @sockets[socketId].isConnected() - - connected = false - @sockets.forEach (socket) -> - return unless socket - if socket.isConnected() - connected = true - return connected - - canAttach: -> true - -module.exports = BasePort diff --git a/src/lib/BasePort.js b/src/lib/BasePort.js new file mode 100644 index 000000000..198eba3d3 --- /dev/null +++ b/src/lib/BasePort.js @@ -0,0 +1,166 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2014-2017 Flowhub UG +// NoFlo may be freely distributed under the MIT license +const { EventEmitter } = require('events'); + +// ## NoFlo Port Base class +// +// Base port type used for options normalization. Both inports and outports extend this class. + +// The list of valid datatypes for ports. +const validTypes = [ + 'all', + 'string', + 'number', + 'int', + 'object', + 'array', + 'boolean', + 'color', + 'date', + 'bang', + 'function', + 'buffer', + 'stream', +]; + +function handleOptions(options = {}) { + // We default to the `all` type if no explicit datatype + // was provided + let datatype = options.datatype || 'all'; + // Normalize the legacy `integer` type to `int`. + if (datatype === 'integer') { datatype = 'int'; } + + // By default ports are not required for graph execution + const required = options.required || false; + + // Ensure datatype defined for the port is valid + if (validTypes.indexOf(datatype) === -1) { + throw new Error(`Invalid port datatype '${datatype}' specified, valid are ${validTypes.join(', ')}`); + } + + // Ensure schema defined for the port is valid + const schema = options.schema || options.type; + + if (schema && (schema.indexOf('/') === -1)) { + throw new Error(`Invalid port schema '${schema}' specified. Should be URL or MIME type`); + } + + /* eslint-disable prefer-object-spread */ + return Object.assign({}, options, { + datatype, + required, + schema, + }); +} + +module.exports = class BasePort extends EventEmitter { + constructor(options) { + super(); + // Options holds all options of the current port + this.options = handleOptions(options); + // Sockets list contains all currently attached + // connections to the port + this.sockets = []; + // Name of the graph node this port is in + this.node = null; + // Name of the port + this.name = null; + } + + getId() { + if (!this.node || !this.name) { + return 'Port'; + } + return `${this.node} ${this.name.toUpperCase()}`; + } + + getDataType() { return this.options.datatype; } + + getSchema() { return this.options.schema || null; } + + getDescription() { return this.options.description; } + + attach(socket, index = null) { + let idx = index; + if (!this.isAddressable() || (index === null)) { + idx = this.sockets.length; + } + this.sockets[idx] = socket; + this.attachSocket(socket, idx); + if (this.isAddressable()) { + this.emit('attach', socket, idx); + return; + } + this.emit('attach', socket); + } + + /* eslint-disable class-methods-use-this */ + attachSocket() { } + + detach(socket) { + const index = this.sockets.indexOf(socket); + if (index === -1) { + return; + } + this.sockets[index] = undefined; + if (this.isAddressable()) { + this.emit('detach', socket, index); + return; + } + this.emit('detach', socket); + } + + isAddressable() { + if (this.options.addressable) { return true; } + return false; + } + + isBuffered() { + if (this.options.buffered) { return true; } + return false; + } + + isRequired() { + if (this.options.required) { return true; } + return false; + } + + isAttached(socketId = null) { + if (this.isAddressable() && (socketId !== null)) { + if (this.sockets[socketId]) { return true; } + return false; + } + if (this.sockets.length) { return true; } + return false; + } + + listAttached() { + const attached = []; + for (let idx = 0; idx < this.sockets.length; idx += 1) { + const socket = this.sockets[idx]; + if (socket) { attached.push(idx); } + } + return attached; + } + + isConnected(socketId = null) { + if (this.isAddressable()) { + if (socketId === null) { throw new Error(`${this.getId()}: Socket ID required`); } + if (!this.sockets[socketId]) { throw new Error(`${this.getId()}: Socket ${socketId} not available`); } + return this.sockets[socketId].isConnected(); + } + + let connected = false; + this.sockets.forEach((socket) => { + if (!socket) { return; } + if (socket.isConnected()) { + connected = true; + } + }); + return connected; + } + + /* eslint-disable class-methods-use-this */ + canAttach() { return true; } +}; diff --git a/src/lib/Component.coffee b/src/lib/Component.coffee deleted file mode 100644 index e350c1f6b..000000000 --- a/src/lib/Component.coffee +++ /dev/null @@ -1,866 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2013-2017 Flowhub UG -# (c) 2011-2012 Henri Bergius, Nemein -# NoFlo may be freely distributed under the MIT license -{EventEmitter} = require 'events' - -ports = require './Ports' -IP = require './IP' - -debug = require('debug') 'noflo:component' -debugBrackets = require('debug') 'noflo:component:brackets' -debugSend = require('debug') 'noflo:component:send' - -# ## NoFlo Component Base class -# -# The `noflo.Component` interface provides a way to instantiate -# and extend NoFlo components. -class Component extends EventEmitter - description: '' - icon: null - - constructor: (options) -> - super() - options = {} unless options - - # Prepare inports, if any were given in options. - # They can also be set up imperatively after component - # instantiation by using the `component.inPorts.add` - # method. - options.inPorts = {} unless options.inPorts - if options.inPorts instanceof ports.InPorts - @inPorts = options.inPorts - else - @inPorts = new ports.InPorts options.inPorts - - # Prepare outports, if any were given in options. - # They can also be set up imperatively after component - # instantiation by using the `component.outPorts.add` - # method. - options.outPorts = {} unless options.outPorts - if options.outPorts instanceof ports.OutPorts - @outPorts = options.outPorts - else - @outPorts = new ports.OutPorts options.outPorts - - # Set the default component icon and description - @icon = options.icon if options.icon - @description = options.description if options.description - - # Initially the component is not started - @started = false - @load = 0 - - # Whether the component should keep send packets - # out in the order they were received - @ordered = options.ordered ? false - @autoOrdering = options.autoOrdering ? null - - # Queue for handling ordered output packets - @outputQ = [] - - # Context used for bracket forwarding - @bracketContext = - in: {} - out: {} - - # Whether the component should activate when it - # receives packets - @activateOnInput = options.activateOnInput ? true - - # Bracket forwarding rules. By default we forward - # brackets from `in` port to `out` and `error` ports. - @forwardBrackets = in: ['out', 'error'] - if 'forwardBrackets' of options - @forwardBrackets = options.forwardBrackets - - # The component's process function can either be - # passed in options, or given imperatively after - # instantation using the `component.process` method. - if typeof options.process is 'function' - @process options.process - - getDescription: -> @description - - isReady: -> true - - isSubgraph: -> false - - setIcon: (@icon) -> - @emit 'icon', @icon - getIcon: -> @icon - - # ### Error emitting helper - # - # If component has an `error` outport that is connected, errors - # are sent as IP objects there. If the port is not connected, - # errors are thrown. - error: (e, groups = [], errorPort = 'error', scope = null) => - if @outPorts[errorPort] and (@outPorts[errorPort].isAttached() or not @outPorts[errorPort].isRequired()) - @outPorts[errorPort].openBracket group, scope: scope for group in groups - @outPorts[errorPort].data e, scope: scope - @outPorts[errorPort].closeBracket group, scope: scope for group in groups - return - throw e - - # ### Setup - # - # The setUp method is for component-specific initialization. - # Called at network start-up. - # - # Override in component implementation to do component-specific - # setup work. - setUp: (callback) -> - do callback - return - - # ### Setup - # - # The tearDown method is for component-specific cleanup. Called - # at network shutdown - # - # Override in component implementation to do component-specific - # cleanup work, like clearing any accumulated state. - tearDown: (callback) -> - do callback - return - - # ### Start - # - # Called when network starts. This sets calls the setUp - # method and sets the component to a started state. - start: (callback) -> - return callback() if @isStarted() - @setUp (err) => - return callback err if err - @started = true - @emit 'start' - callback null - return - return - - # ### Shutdown - # - # Called when network is shut down. This sets calls the - # tearDown method and sets the component back to a - # non-started state. - # - # The callback is called when tearDown finishes and - # all active processing contexts have ended. - shutdown: (callback) -> - finalize = => - # Clear contents of inport buffers - inPorts = @inPorts.ports or @inPorts - for portName, inPort of inPorts - continue unless typeof inPort.clear is 'function' - inPort.clear() - # Clear bracket context - @bracketContext = - in: {} - out: {} - return callback() unless @isStarted() - @started = false - @emit 'end' - callback() - return - - # Tell the component that it is time to shut down - @tearDown (err) => - return callback err if err - if @load > 0 - # Some in-flight processes, wait for them to finish - checkLoad = (load) -> - return if load > 0 - @removeListener 'deactivate', checkLoad - finalize() - return - @on 'deactivate', checkLoad - return - finalize() - return - return - - isStarted: -> @started - - # Ensures braket forwarding map is correct for the existing ports - prepareForwarding: -> - for inPort, outPorts of @forwardBrackets - unless inPort of @inPorts.ports - delete @forwardBrackets[inPort] - continue - tmp = [] - for outPort in outPorts - tmp.push outPort if outPort of @outPorts.ports - if tmp.length is 0 - delete @forwardBrackets[inPort] - else - @forwardBrackets[inPort] = tmp - - # Method for determining if a component is using the modern - # NoFlo Process API - isLegacy: -> - # Process API - return false if @handle - # Legacy - true - - # Sets process handler function - process: (handle) -> - unless typeof handle is 'function' - throw new Error "Process handler must be a function" - unless @inPorts - throw new Error "Component ports must be defined before process function" - @prepareForwarding() - @handle = handle - for name, port of @inPorts.ports - do (name, port) => - port.name = name unless port.name - port.on 'ip', (ip) => - @handleIP ip, port - @ - - # Method for checking if a given inport is set up for - # automatic bracket forwarding - isForwardingInport: (port) -> - if typeof port is 'string' - portName = port - else - portName = port.name - if portName of @forwardBrackets - return true - false - - # Method for checking if a given outport is set up for - # automatic bracket forwarding - isForwardingOutport: (inport, outport) -> - if typeof inport is 'string' - inportName = inport - else - inportName = inport.name - if typeof outport is 'string' - outportName = outport - else - outportName = outport.name - return false unless @forwardBrackets[inportName] - return true if @forwardBrackets[inportName].indexOf(outportName) isnt -1 - false - - # Method for checking whether the component sends packets - # in the same order they were received. - isOrdered: -> - return true if @ordered - return true if @autoOrdering - false - - # ### Handling IP objects - # - # The component has received an Information Packet. Call the - # processing function so that firing pattern preconditions can - # be checked and component can do processing as needed. - handleIP: (ip, port) -> - unless port.options.triggering - # If port is non-triggering, we can skip the process function call - return - - if ip.type is 'openBracket' and @autoOrdering is null and not @ordered - # Switch component to ordered mode when receiving a stream unless - # auto-ordering is disabled - debug "#{@nodeId} port '#{port.name}' entered auto-ordering mode" - @autoOrdering = true - - # Initialize the result object for situations where output needs - # to be queued to be kept in order - result = {} - - if @isForwardingInport port - # For bracket-forwarding inports we need to initialize a bracket context - # so that brackets can be sent as part of the output, and closed after. - if ip.type is 'openBracket' - # For forwarding ports openBrackets don't fire - return - - if ip.type is 'closeBracket' - # For forwarding ports closeBrackets don't fire - # However, we need to handle several different scenarios: - # A. There are closeBrackets in queue before current packet - # B. There are closeBrackets in queue after current packet - # C. We've queued the results from all in-flight processes and - # new closeBracket arrives - buf = port.getBuffer ip.scope, ip.index - dataPackets = buf.filter (ip) -> ip.type is 'data' - if @outputQ.length >= @load and dataPackets.length is 0 - return unless buf[0] is ip - # Remove from buffer - port.get ip.scope, ip.index - context = @getBracketContext('in', port.name, ip.scope, ip.index).pop() - context.closeIp = ip - debugBrackets "#{@nodeId} closeBracket-C from '#{context.source}' to #{context.ports}: '#{ip.data}'" - result = - __resolved: true - __bracketClosingAfter: [context] - @outputQ.push result - do @processOutputQueue - # Check if buffer contains data IPs. If it does, we want to allow - # firing - return unless dataPackets.length - - # Prepare the input/output pair - context = new ProcessContext ip, @, port, result - input = new ProcessInput @inPorts, context - output = new ProcessOutput @outPorts, context - try - # Call the processing function - @handle input, output, context - catch e - @deactivate context - output.sendDone e - - return if context.activated - # If receiving an IP object didn't cause the component to - # activate, log that input conditions were not met - if port.isAddressable() - debug "#{@nodeId} packet on '#{port.name}[#{ip.index}]' didn't match preconditions: #{ip.type}" - return - debug "#{@nodeId} packet on '#{port.name}' didn't match preconditions: #{ip.type}" - return - - # Get the current bracket forwarding context for an IP object - getBracketContext: (type, port, scope, idx) -> - {name, index} = ports.normalizePortName port - index = idx if idx? - portsList = if type is 'in' then @inPorts else @outPorts - if portsList[name].isAddressable() - port = "#{name}[#{index}]" - # Ensure we have a bracket context for the current scope - @bracketContext[type][port] = {} unless @bracketContext[type][port] - @bracketContext[type][port][scope] = [] unless @bracketContext[type][port][scope] - return @bracketContext[type][port][scope] - - # Add an IP object to the list of results to be sent in - # order - addToResult: (result, port, ip, before = false) -> - {name, index} = ports.normalizePortName port - method = if before then 'unshift' else 'push' - if @outPorts[name].isAddressable() - idx = if index then parseInt(index) else ip.index - result[name] = {} unless result[name] - result[name][idx] = [] unless result[name][idx] - ip.index = idx - result[name][idx][method] ip - return - result[name] = [] unless result[name] - result[name][method] ip - - # Get contexts that can be forwarded with this in/outport - # pair. - getForwardableContexts: (inport, outport, contexts) -> - {name, index} = ports.normalizePortName outport - forwardable = [] - contexts.forEach (ctx, idx) => - # No forwarding to this outport - return unless @isForwardingOutport inport, name - # We have already forwarded this context to this outport - return unless ctx.ports.indexOf(outport) is -1 - # See if we have already forwarded the same bracket from another - # inport - outContext = @getBracketContext('out', name, ctx.ip.scope, index)[idx] - if outContext - return if outContext.ip.data is ctx.ip.data and outContext.ports.indexOf(outport) isnt -1 - forwardable.push ctx - return forwardable - - # Add any bracket forwards needed to the result queue - addBracketForwards: (result) -> - if result.__bracketClosingBefore?.length - for context in result.__bracketClosingBefore - debugBrackets "#{@nodeId} closeBracket-A from '#{context.source}' to #{context.ports}: '#{context.closeIp.data}'" - continue unless context.ports.length - for port in context.ports - ipClone = context.closeIp.clone() - @addToResult result, port, ipClone, true - @getBracketContext('out', port, ipClone.scope).pop() - - if result.__bracketContext - # First see if there are any brackets to forward. We need to reverse - # the keys so that they get added in correct order - Object.keys(result.__bracketContext).reverse().forEach (inport) => - context = result.__bracketContext[inport] - return unless context.length - for outport, ips of result - continue if outport.indexOf('__') is 0 - if @outPorts[outport].isAddressable() - for idx, idxIps of ips - # Don't register indexes we're only sending brackets to - datas = idxIps.filter (ip) -> ip.type is 'data' - continue unless datas.length - portIdentifier = "#{outport}[#{idx}]" - unforwarded = @getForwardableContexts inport, portIdentifier, context - continue unless unforwarded.length - forwardedOpens = [] - for ctx in unforwarded - debugBrackets "#{@nodeId} openBracket from '#{inport}' to '#{portIdentifier}': '#{ctx.ip.data}'" - ipClone = ctx.ip.clone() - ipClone.index = parseInt idx - forwardedOpens.push ipClone - ctx.ports.push portIdentifier - @getBracketContext('out', outport, ctx.ip.scope, idx).push ctx - forwardedOpens.reverse() - @addToResult result, outport, ip, true for ip in forwardedOpens - continue - # Don't register ports we're only sending brackets to - datas = ips.filter (ip) -> ip.type is 'data' - continue unless datas.length - unforwarded = @getForwardableContexts inport, outport, context - continue unless unforwarded.length - forwardedOpens = [] - for ctx in unforwarded - debugBrackets "#{@nodeId} openBracket from '#{inport}' to '#{outport}': '#{ctx.ip.data}'" - forwardedOpens.push ctx.ip.clone() - ctx.ports.push outport - @getBracketContext('out', outport, ctx.ip.scope).push ctx - forwardedOpens.reverse() - @addToResult result, outport, ip, true for ip in forwardedOpens - - if result.__bracketClosingAfter?.length - for context in result.__bracketClosingAfter - debugBrackets "#{@nodeId} closeBracket-B from '#{context.source}' to #{context.ports}: '#{context.closeIp.data}'" - continue unless context.ports.length - for port in context.ports - ipClone = context.closeIp.clone() - @addToResult result, port, ipClone, false - @getBracketContext('out', port, ipClone.scope).pop() - - delete result.__bracketClosingBefore - delete result.__bracketContext - delete result.__bracketClosingAfter - - # Whenever an execution context finishes, send all resolved - # output from the queue in the order it is in. - processOutputQueue: -> - while @outputQ.length > 0 - break unless @outputQ[0].__resolved - result = @outputQ.shift() - @addBracketForwards result - for port, ips of result - continue if port.indexOf('__') is 0 - if @outPorts.ports[port].isAddressable() - for idx, idxIps of ips - idx = parseInt idx - continue unless @outPorts.ports[port].isAttached idx - for ip in idxIps - portIdentifier = "#{port}[#{ip.index}]" - if ip.type is 'openBracket' - debugSend "#{@nodeId} sending #{portIdentifier} < '#{ip.data}'" - else if ip.type is 'closeBracket' - debugSend "#{@nodeId} sending #{portIdentifier} > '#{ip.data}'" - else - debugSend "#{@nodeId} sending #{portIdentifier} DATA" - unless @outPorts[port].options.scoped - ip.scope = null - @outPorts[port].sendIP ip - continue - continue unless @outPorts.ports[port].isAttached() - for ip in ips - portIdentifier = port - if ip.type is 'openBracket' - debugSend "#{@nodeId} sending #{portIdentifier} < '#{ip.data}'" - else if ip.type is 'closeBracket' - debugSend "#{@nodeId} sending #{portIdentifier} > '#{ip.data}'" - else - debugSend "#{@nodeId} sending #{portIdentifier} DATA" - unless @outPorts[port].options.scoped - ip.scope = null - @outPorts[port].sendIP ip - - # Signal that component has activated. There may be multiple - # activated contexts at the same time - activate: (context) -> - return if context.activated # prevent double activation - context.activated = true - context.deactivated = false - @load++ - @emit 'activate', @load - if @ordered or @autoOrdering - @outputQ.push context.result - - - # Signal that component has deactivated. There may be multiple - # activated contexts at the same time - deactivate: (context) -> - return if context.deactivated # prevent double deactivation - context.deactivated = true - context.activated = false - if @isOrdered() - @processOutputQueue() - @load-- - @emit 'deactivate', @load - -class ProcessContext - constructor: (@ip, @nodeInstance, @port, @result) -> - @scope = @ip.scope - @activated = false - @deactivated = false - activate: -> - # Push a new result value if previous has been sent already - if @result.__resolved or @nodeInstance.outputQ.indexOf(@result) is -1 - @result = {} - @nodeInstance.activate @ - deactivate: -> - @result.__resolved = true unless @result.__resolved - @nodeInstance.deactivate @ - -class ProcessInput - constructor: (@ports, @context) -> - @nodeInstance = @context.nodeInstance - @ip = @context.ip - @port = @context.port - @result = @context.result - @scope = @context.scope - - # When preconditions are met, set component state to `activated` - activate: -> - return if @context.activated - if @nodeInstance.isOrdered() - # We're handling packets in order. Set the result as non-resolved - # so that it can be send when the order comes up - @result.__resolved = false - @nodeInstance.activate @context - if @port.isAddressable() - debug "#{@nodeInstance.nodeId} packet on '#{@port.name}[#{@ip.index}]' caused activation #{@nodeInstance.load}: #{@ip.type}" - else - debug "#{@nodeInstance.nodeId} packet on '#{@port.name}' caused activation #{@nodeInstance.load}: #{@ip.type}" - - # ## Connection listing - # This allows components to check which input ports are attached. This is - # useful mainly for addressable ports - attached: (args...) -> - args = ['in'] unless args.length - res = [] - for port in args - unless @ports[port] - throw new Error "Node #{@nodeInstance.nodeId} has no port '#{port}'" - res.push @ports[port].listAttached() - return res.pop() if args.length is 1 - res - - # ## Input preconditions - # When the processing function is called, it can check if input buffers - # contain the packets needed for the process to fire. - # This precondition handling is done via the `has` and `hasStream` methods. - - # Returns true if a port (or ports joined by logical AND) has a new IP - # Passing a validation callback as a last argument allows more selective - # checking of packets. - has: (args...) -> - args = ['in'] unless args.length - if typeof args[args.length - 1] is 'function' - validate = args.pop() - else - validate = -> true - for port in args - if Array.isArray port - unless @ports[port[0]] - throw new Error "Node #{@nodeInstance.nodeId} has no port '#{port[0]}'" - unless @ports[port[0]].isAddressable() - throw new Error "Non-addressable ports, access must be with string #{port[0]}" - return false unless @ports[port[0]].has @scope, port[1], validate - continue - unless @ports[port] - throw new Error "Node #{@nodeInstance.nodeId} has no port '#{port}'" - if @ports[port].isAddressable() - throw new Error "For addressable ports, access must be with array [#{port}, idx]" - return false unless @ports[port].has @scope, validate - return true - - # Returns true if the ports contain data packets - hasData: (args...) -> - args = ['in'] unless args.length - args.push (ip) -> ip.type is 'data' - return @has.apply @, args - - # Returns true if a port has a complete stream in its input buffer. - hasStream: (args...) -> - args = ['in'] unless args.length - - if typeof args[args.length - 1] is 'function' - validateStream = args.pop() - else - validateStream = -> true - - for port in args - portBrackets = [] - dataBrackets = [] - hasData = false - validate = (ip) -> - if ip.type is 'openBracket' - portBrackets.push ip.data - return false - if ip.type is 'data' - # Run the stream validation callback - hasData = validateStream ip, portBrackets - # Data IP on its own is a valid stream - return hasData unless portBrackets.length - # Otherwise we need to check for complete stream - return false - if ip.type is 'closeBracket' - portBrackets.pop() - return false if portBrackets.length - return false unless hasData - return true - return false unless @has port, validate - true - - # ## Input processing - # - # Once preconditions have been met, the processing function can read from - # the input buffers. Reading packets sets the component as "activated". - # - # Fetches IP object(s) for port(s) - get: (args...) -> - @activate() - args = ['in'] unless args.length - res = [] - for port in args - if Array.isArray port - [portname, idx] = port - unless @ports[portname].isAddressable() - throw new Error 'Non-addressable ports, access must be with string portname' - else - portname = port - if @ports[portname].isAddressable() - throw new Error 'For addressable ports, access must be with array [portname, idx]' - if @nodeInstance.isForwardingInport portname - ip = @__getForForwarding portname, idx - res.push ip - continue - ip = @ports[portname].get @scope, idx - res.push ip - - if args.length is 1 then res[0] else res - - __getForForwarding: (port, idx) -> - prefix = [] - dataIp = null - # Read IPs until we hit data - loop - # Read next packet - ip = @ports[port].get @scope, idx - # Stop at the end of the buffer - break unless ip - if ip.type is 'data' - # Hit the data IP, stop here - dataIp = ip - break - # Keep track of bracket closings and openings before - prefix.push ip - - # Forwarding brackets that came before data packet need to manipulate context - # and be added to result so they can be forwarded correctly to ports that - # need them - for ip in prefix - if ip.type is 'closeBracket' - # Bracket closings before data should remove bracket context - @result.__bracketClosingBefore = [] unless @result.__bracketClosingBefore - context = @nodeInstance.getBracketContext('in', port, @scope, idx).pop() - context.closeIp = ip - @result.__bracketClosingBefore.push context - continue - if ip.type is 'openBracket' - # Bracket openings need to go to bracket context - @nodeInstance.getBracketContext('in', port, @scope, idx).push - ip: ip - ports: [] - source: port - continue - - # Add current bracket context to the result so that when we send - # to ports we can also add the surrounding brackets - @result.__bracketContext = {} unless @result.__bracketContext - @result.__bracketContext[port] = @nodeInstance.getBracketContext('in', port, @scope, idx).slice 0 - # Bracket closings that were in buffer after the data packet need to - # be added to result for done() to read them from - return dataIp - - # Fetches `data` property of IP object(s) for given port(s) - getData: (args...) -> - args = ['in'] unless args.length - - datas = [] - for port in args - packet = @get port - unless packet? - # we add the null packet to the array so when getting - # multiple ports, if one is null we still return it - # so the indexes are correct. - datas.push packet - continue - - until packet.type is 'data' - packet = @get port - break unless packet - - datas.push packet.data - - return datas.pop() if args.length is 1 - datas - - # Fetches a complete data stream from the buffer. - getStream: (args...) -> - args = ['in'] unless args.length - datas = [] - for port in args - portBrackets = [] - portPackets = [] - hasData = false - ip = @get port - datas.push undefined unless ip - while ip - if ip.type is 'openBracket' - unless portBrackets.length - # First openBracket in stream, drop previous - portPackets = [] - hasData = false - portBrackets.push ip.data - portPackets.push ip - if ip.type is 'data' - portPackets.push ip - hasData = true - # Unbracketed data packet is a valid stream - break unless portBrackets.length - if ip.type is 'closeBracket' - portPackets.push ip - portBrackets.pop() - if hasData and not portBrackets.length - # Last close bracket finishes stream if there was data inside - break - ip = @get port - datas.push portPackets - - return datas.pop() if args.length is 1 - datas - -class ProcessOutput - constructor: (@ports, @context) -> - @nodeInstance = @context.nodeInstance - @ip = @context.ip - @result = @context.result - @scope = @context.scope - - # Checks if a value is an Error - isError: (err) -> - err instanceof Error or - Array.isArray(err) and err.length > 0 and err[0] instanceof Error - - # Sends an error object - error: (err) -> - multiple = Array.isArray err - err = [err] unless multiple - if 'error' of @ports and - (@ports.error.isAttached() or not @ports.error.isRequired()) - @sendIP 'error', new IP 'openBracket' if multiple - @sendIP 'error', e for e in err - @sendIP 'error', new IP 'closeBracket' if multiple - else - throw e for e in err - - # Sends a single IP object to a port - sendIP: (port, packet) -> - unless IP.isIP packet - ip = new IP 'data', packet - else - ip = packet - ip.scope = @scope if @scope isnt null and ip.scope is null - - if @nodeInstance.outPorts[port].isAddressable() and ip.index is null - throw new Error 'Sending packets to addressable ports requires specifying index' - - if @nodeInstance.isOrdered() - @nodeInstance.addToResult @result, port, ip - return - unless @nodeInstance.outPorts[port].options.scoped - ip.scope = null - @nodeInstance.outPorts[port].sendIP ip - - # Sends packets for each port as a key in the map - # or sends Error or a list of Errors if passed such - send: (outputMap) -> - return @error outputMap if @isError outputMap - - componentPorts = [] - mapIsInPorts = false - for port in Object.keys @ports.ports - componentPorts.push port if port isnt 'error' and port isnt 'ports' and port isnt '_callbacks' - if not mapIsInPorts and outputMap? and typeof outputMap is 'object' and Object.keys(outputMap).indexOf(port) isnt -1 - mapIsInPorts = true - - if componentPorts.length is 1 and not mapIsInPorts - @sendIP componentPorts[0], outputMap - return - - if componentPorts.length > 1 and not mapIsInPorts - throw new Error 'Port must be specified for sending output' - - for port, packet of outputMap - @sendIP port, packet - - # Sends the argument via `send()` and marks activation as `done()` - sendDone: (outputMap) -> - @send outputMap - @done() - - # Makes a map-style component pass a result value to `out` - # keeping all IP metadata received from `in`, - # or modifying it if `options` is provided - pass: (data, options = {}) -> - unless 'out' of @ports - throw new Error 'output.pass() requires port "out" to be present' - for key, val of options - @ip[key] = val - @ip.data = data - @sendIP 'out', @ip - @done() - - # Finishes process activation gracefully - done: (error) -> - @result.__resolved = true - @nodeInstance.activate @context - @error error if error - - isLast = => - # We only care about real output sets with processing data - resultsOnly = @nodeInstance.outputQ.filter (q) -> - return true unless q.__resolved - if Object.keys(q).length is 2 and q.__bracketClosingAfter - return false - true - pos = resultsOnly.indexOf @result - len = resultsOnly.length - load = @nodeInstance.load - return true if pos is len - 1 - return true if pos is -1 and load is len + 1 - return true if len <= 1 and load is 1 - false - if @nodeInstance.isOrdered() and isLast() - # We're doing bracket forwarding. See if there are - # dangling closeBrackets in buffer since we're the - # last running process function. - for port, contexts of @nodeInstance.bracketContext.in - continue unless contexts[@scope] - nodeContext = contexts[@scope] - continue unless nodeContext.length - context = nodeContext[nodeContext.length - 1] - buf = @nodeInstance.inPorts[context.source].getBuffer context.ip.scope, context.ip.index - loop - break unless buf.length - break unless buf[0].type is 'closeBracket' - ip = @nodeInstance.inPorts[context.source].get context.ip.scope, context.ip.index - ctx = nodeContext.pop() - ctx.closeIp = ip - @result.__bracketClosingAfter = [] unless @result.__bracketClosingAfter - @result.__bracketClosingAfter.push ctx - - debug "#{@nodeInstance.nodeId} finished processing #{@nodeInstance.load}" - - @nodeInstance.deactivate @context - -exports.Component = Component diff --git a/src/lib/Component.js b/src/lib/Component.js new file mode 100644 index 000000000..cfffd695a --- /dev/null +++ b/src/lib/Component.js @@ -0,0 +1,615 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2017 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license + +/* eslint-disable + class-methods-use-this, + no-underscore-dangle, +*/ +const { EventEmitter } = require('events'); +const debug = require('debug')('noflo:component'); +const debugBrackets = require('debug')('noflo:component:brackets'); +const debugSend = require('debug')('noflo:component:send'); + +const ports = require('./Ports'); +const ProcessContext = require('./ProcessContext'); +const ProcessInput = require('./ProcessInput'); +const ProcessOutput = require('./ProcessOutput'); + +// ## NoFlo Component Base class +// +// The `noflo.Component` interface provides a way to instantiate +// and extend NoFlo components. +class Component extends EventEmitter { + constructor(options = {}) { + super(); + const opts = options; + // Prepare inports, if any were given in options. + // They can also be set up imperatively after component + // instantiation by using the `component.inPorts.add` + // method. + if (!opts.inPorts) { opts.inPorts = {}; } + if (opts.inPorts instanceof ports.InPorts) { + this.inPorts = opts.inPorts; + } else { + this.inPorts = new ports.InPorts(opts.inPorts); + } + + // Prepare outports, if any were given in opts. + // They can also be set up imperatively after component + // instantiation by using the `component.outPorts.add` + // method. + if (!opts.outPorts) { opts.outPorts = {}; } + if (opts.outPorts instanceof ports.OutPorts) { + this.outPorts = opts.outPorts; + } else { + this.outPorts = new ports.OutPorts(opts.outPorts); + } + + // Set the default component icon and description + this.icon = opts.icon ? opts.icon : this.constructor.icon; + this.description = opts.description ? opts.description : this.constructor.description; + + // Initially the component is not started + this.started = false; + this.load = 0; + + // Whether the component should keep send packets + // out in the order they were received + this.ordered = opts.ordered != null ? opts.ordered : false; + this.autoOrdering = opts.autoOrdering != null ? opts.autoOrdering : null; + + // Queue for handling ordered output packets + this.outputQ = []; + + // Context used for bracket forwarding + this.bracketContext = { + in: {}, + out: {}, + }; + + // Whether the component should activate when it + // receives packets + this.activateOnInput = opts.activateOnInput != null ? opts.activateOnInput : true; + + // Bracket forwarding rules. By default we forward + // brackets from `in` port to `out` and `error` ports. + this.forwardBrackets = { in: ['out', 'error'] }; + if ('forwardBrackets' in opts) { + this.forwardBrackets = opts.forwardBrackets; + } + + // The component's process function can either be + // passed in opts, or given imperatively after + // instantation using the `component.process` method. + if (typeof opts.process === 'function') { + this.process(opts.process); + } + } + + getDescription() { return this.description; } + + isReady() { return true; } + + isSubgraph() { return false; } + + setIcon(icon) { + this.icon = icon; + this.emit('icon', this.icon); + } + + getIcon() { return this.icon; } + + // ### Error emitting helper + // + // If component has an `error` outport that is connected, errors + // are sent as IP objects there. If the port is not connected, + // errors are thrown. + error(e, groups = [], errorPort = 'error', scope = null) { + if (this.outPorts[errorPort] + && (this.outPorts[errorPort].isAttached() || !this.outPorts[errorPort].isRequired())) { + groups.forEach((group) => { this.outPorts[errorPort].openBracket(group, { scope }); }); + this.outPorts[errorPort].data(e, { scope }); + groups.forEach((group) => { this.outPorts[errorPort].closeBracket(group, { scope }); }); + return; + } + throw e; + } + + // ### Setup + // + // The setUp method is for component-specific initialization. + // Called at network start-up. + // + // Override in component implementation to do component-specific + // setup work. + setUp(callback) { + callback(); + } + + // ### Setup + // + // The tearDown method is for component-specific cleanup. Called + // at network shutdown + // + // Override in component implementation to do component-specific + // cleanup work, like clearing any accumulated state. + tearDown(callback) { + callback(); + } + + // ### Start + // + // Called when network starts. This sets calls the setUp + // method and sets the component to a started state. + start(callback) { + if (this.isStarted()) { + callback(); + return; + } + this.setUp((err) => { + if (err) { + callback(err); + return; + } + this.started = true; + this.emit('start'); + callback(null); + }); + } + + // ### Shutdown + // + // Called when network is shut down. This sets calls the + // tearDown method and sets the component back to a + // non-started state. + // + // The callback is called when tearDown finishes and + // all active processing contexts have ended. + shutdown(callback) { + const finalize = () => { + // Clear contents of inport buffers + const inPorts = this.inPorts.ports || this.inPorts; + Object.keys(inPorts).forEach((portName) => { + const inPort = inPorts[portName]; + if (typeof inPort.clear !== 'function') { return; } + inPort.clear(); + }); + // Clear bracket context + this.bracketContext = { + in: {}, + out: {}, + }; + if (!this.isStarted()) { + callback(); + return; + } + this.started = false; + this.emit('end'); + callback(); + }; + + // Tell the component that it is time to shut down + this.tearDown((err) => { + if (err) { + callback(err); + return; + } + if (this.load > 0) { + // Some in-flight processes, wait for them to finish + const checkLoad = (load) => { + if (load > 0) { return; } + this.removeListener('deactivate', checkLoad); + finalize(); + }; + this.on('deactivate', checkLoad); + return; + } + finalize(); + }); + } + + isStarted() { return this.started; } + + // Ensures braket forwarding map is correct for the existing ports + prepareForwarding() { + Object.keys(this.forwardBrackets).forEach((inPort) => { + const outPorts = this.forwardBrackets[inPort]; + if (!(inPort in this.inPorts.ports)) { + delete this.forwardBrackets[inPort]; + return; + } + const tmp = []; + outPorts.forEach((outPort) => { + if (outPort in this.outPorts.ports) { tmp.push(outPort); } + }); + if (tmp.length === 0) { + delete this.forwardBrackets[inPort]; + } else { + this.forwardBrackets[inPort] = tmp; + } + }); + } + + // Method for determining if a component is using the modern + // NoFlo Process API + isLegacy() { + // Process API + if (this.handle) { return false; } + // Legacy + return true; + } + + // Sets process handler function + process(handle) { + if (typeof handle !== 'function') { + throw new Error('Process handler must be a function'); + } + if (!this.inPorts) { + throw new Error('Component ports must be defined before process function'); + } + this.prepareForwarding(); + this.handle = handle; + Object.keys(this.inPorts.ports).forEach((name) => { + const port = this.inPorts.ports[name]; + if (!port.name) { port.name = name; } + port.on('ip', (ip) => this.handleIP(ip, port)); + }); + return this; + } + + // Method for checking if a given inport is set up for + // automatic bracket forwarding + isForwardingInport(port) { + let portName; + if (typeof port === 'string') { + portName = port; + } else { + portName = port.name; + } + if (portName in this.forwardBrackets) { + return true; + } + return false; + } + + // Method for checking if a given outport is set up for + // automatic bracket forwarding + isForwardingOutport(inport, outport) { + let inportName; let + outportName; + if (typeof inport === 'string') { + inportName = inport; + } else { + inportName = inport.name; + } + if (typeof outport === 'string') { + outportName = outport; + } else { + outportName = outport.name; + } + if (!this.forwardBrackets[inportName]) { return false; } + if (this.forwardBrackets[inportName].indexOf(outportName) !== -1) { return true; } + return false; + } + + // Method for checking whether the component sends packets + // in the same order they were received. + isOrdered() { + if (this.ordered) { return true; } + if (this.autoOrdering) { return true; } + return false; + } + + // ### Handling IP objects + // + // The component has received an Information Packet. Call the + // processing function so that firing pattern preconditions can + // be checked and component can do processing as needed. + handleIP(ip, port) { + let context; + if (!port.options.triggering) { + // If port is non-triggering, we can skip the process function call + return; + } + + if ((ip.type === 'openBracket') && (this.autoOrdering === null) && !this.ordered) { + // Switch component to ordered mode when receiving a stream unless + // auto-ordering is disabled + debug(`${this.nodeId} port '${port.name}' entered auto-ordering mode`); + this.autoOrdering = true; + } + + // Initialize the result object for situations where output needs + // to be queued to be kept in order + let result = {}; + + if (this.isForwardingInport(port)) { + // For bracket-forwarding inports we need to initialize a bracket context + // so that brackets can be sent as part of the output, and closed after. + if (ip.type === 'openBracket') { + // For forwarding ports openBrackets don't fire + return; + } + + if (ip.type === 'closeBracket') { + // For forwarding ports closeBrackets don't fire + // However, we need to handle several different scenarios: + // A. There are closeBrackets in queue before current packet + // B. There are closeBrackets in queue after current packet + // C. We've queued the results from all in-flight processes and + // new closeBracket arrives + const buf = port.getBuffer(ip.scope, ip.index); + const dataPackets = buf.filter((p) => p.type === 'data'); + if ((this.outputQ.length >= this.load) && (dataPackets.length === 0)) { + if (buf[0] !== ip) { return; } + // Remove from buffer + port.get(ip.scope, ip.index); + context = this.getBracketContext('in', port.name, ip.scope, ip.index).pop(); + context.closeIp = ip; + debugBrackets(`${this.nodeId} closeBracket-C from '${context.source}' to ${context.ports}: '${ip.data}'`); + result = { + __resolved: true, + __bracketClosingAfter: [context], + }; + this.outputQ.push(result); + this.processOutputQueue(); + } + // Check if buffer contains data IPs. If it does, we want to allow + // firing + if (!dataPackets.length) { return; } + } + } + + // Prepare the input/output pair + context = new ProcessContext(ip, this, port, result); + const input = new ProcessInput(this.inPorts, context); + const output = new ProcessOutput(this.outPorts, context); + try { + // Call the processing function + this.handle(input, output, context); + } catch (e) { + this.deactivate(context); + output.sendDone(e); + } + + if (context.activated) { return; } + // If receiving an IP object didn't cause the component to + // activate, log that input conditions were not met + if (port.isAddressable()) { + debug(`${this.nodeId} packet on '${port.name}[${ip.index}]' didn't match preconditions: ${ip.type}`); + return; + } + debug(`${this.nodeId} packet on '${port.name}' didn't match preconditions: ${ip.type}`); + } + + // Get the current bracket forwarding context for an IP object + getBracketContext(type, port, scope, idx) { + let { name, index } = ports.normalizePortName(port); + if (idx != null) { index = idx; } + const portsList = type === 'in' ? this.inPorts : this.outPorts; + if (portsList[name].isAddressable()) { + name = `${name}[${index}]`; + } else { + name = port; + } + // Ensure we have a bracket context for the current scope + if (!this.bracketContext[type][name]) { + this.bracketContext[type][name] = {}; + } + if (!this.bracketContext[type][name][scope]) { + this.bracketContext[type][name][scope] = []; + } + return this.bracketContext[type][name][scope]; + } + + // Add an IP object to the list of results to be sent in + // order + addToResult(result, port, packet, before = false) { + const res = result; + const ip = packet; + const { name, index } = ports.normalizePortName(port); + const method = before ? 'unshift' : 'push'; + if (this.outPorts[name].isAddressable()) { + const idx = index ? parseInt(index, 10) : ip.index; + if (!res[name]) { res[name] = {}; } + if (!res[name][idx]) { res[name][idx] = []; } + ip.index = idx; + res[name][idx][method](ip); + return; + } + if (!res[name]) { res[name] = []; } + res[name][method](ip); + } + + // Get contexts that can be forwarded with this in/outport + // pair. + getForwardableContexts(inport, outport, contexts) { + const { name, index } = ports.normalizePortName(outport); + const forwardable = []; + contexts.forEach((ctx, idx) => { + // No forwarding to this outport + if (!this.isForwardingOutport(inport, name)) { return; } + // We have already forwarded this context to this outport + if (ctx.ports.indexOf(outport) !== -1) { return; } + // See if we have already forwarded the same bracket from another + // inport + const outContext = this.getBracketContext('out', name, ctx.ip.scope, index)[idx]; + if (outContext) { + if ((outContext.ip.data === ctx.ip.data) && (outContext.ports.indexOf(outport) !== -1)) { + return; + } + } + forwardable.push(ctx); + }); + return forwardable; + } + + // Add any bracket forwards needed to the result queue + addBracketForwards(result) { + const res = result; + if (res.__bracketClosingBefore != null ? res.__bracketClosingBefore.length : undefined) { + res.__bracketClosingBefore.forEach((context) => { + debugBrackets(`${this.nodeId} closeBracket-A from '${context.source}' to ${context.ports}: '${context.closeIp.data}'`); + if (!context.ports.length) { return; } + context.ports.forEach((port) => { + const ipClone = context.closeIp.clone(); + this.addToResult(res, port, ipClone, true); + this.getBracketContext('out', port, ipClone.scope).pop(); + }); + }); + } + + if (res.__bracketContext) { + // First see if there are any brackets to forward. We need to reverse + // the keys so that they get added in correct order + Object.keys(res.__bracketContext).reverse().forEach((inport) => { + const context = res.__bracketContext[inport]; + if (!context.length) { return; } + Object.keys(res).forEach((outport) => { + let datas; let forwardedOpens; let unforwarded; + const ips = res[outport]; + if (outport.indexOf('__') === 0) { return; } + if (this.outPorts[outport].isAddressable()) { + Object.keys(ips).forEach((idx) => { + // Don't register indexes we're only sending brackets to + const idxIps = ips[idx]; + datas = idxIps.filter((ip) => ip.type === 'data'); + if (!datas.length) { return; } + const portIdentifier = `${outport}[${idx}]`; + unforwarded = this.getForwardableContexts(inport, portIdentifier, context); + if (!unforwarded.length) { return; } + forwardedOpens = []; + unforwarded.forEach((ctx) => { + debugBrackets(`${this.nodeId} openBracket from '${inport}' to '${portIdentifier}': '${ctx.ip.data}'`); + const ipClone = ctx.ip.clone(); + ipClone.index = parseInt(idx, 10); + forwardedOpens.push(ipClone); + ctx.ports.push(portIdentifier); + this.getBracketContext('out', outport, ctx.ip.scope, idx).push(ctx); + }); + forwardedOpens.reverse(); + forwardedOpens.forEach((ip) => { this.addToResult(res, outport, ip, true); }); + }); + return; + } + // Don't register ports we're only sending brackets to + datas = ips.filter((ip) => ip.type === 'data'); + if (!datas.length) { return; } + unforwarded = this.getForwardableContexts(inport, outport, context); + if (!unforwarded.length) { return; } + forwardedOpens = []; + unforwarded.forEach((ctx) => { + debugBrackets(`${this.nodeId} openBracket from '${inport}' to '${outport}': '${ctx.ip.data}'`); + forwardedOpens.push(ctx.ip.clone()); + ctx.ports.push(outport); + this.getBracketContext('out', outport, ctx.ip.scope).push(ctx); + }); + forwardedOpens.reverse(); + forwardedOpens.forEach((ip) => { this.addToResult(res, outport, ip, true); }); + }); + }); + } + + if (res.__bracketClosingAfter != null ? res.__bracketClosingAfter.length : undefined) { + res.__bracketClosingAfter.forEach((context) => { + debugBrackets(`${this.nodeId} closeBracket-B from '${context.source}' to ${context.ports}: '${context.closeIp.data}'`); + if (!context.ports.length) { return; } + context.ports.forEach((port) => { + const ipClone = context.closeIp.clone(); + this.addToResult(res, port, ipClone, false); + this.getBracketContext('out', port, ipClone.scope).pop(); + }); + }); + } + + delete res.__bracketClosingBefore; + delete res.__bracketContext; + delete res.__bracketClosingAfter; + } + + // Whenever an execution context finishes, send all resolved + // output from the queue in the order it is in. + processOutputQueue() { + while (this.outputQ.length > 0) { + if (!this.outputQ[0].__resolved) { break; } + const result = this.outputQ.shift(); + this.addBracketForwards(result); + Object.keys(result).forEach((port) => { + let portIdentifier; + const ips = result[port]; + if (port.indexOf('__') === 0) { return; } + if (this.outPorts.ports[port].isAddressable()) { + Object.keys(ips).forEach((index) => { + const idxIps = ips[index]; + const idx = parseInt(index, 10); + if (!this.outPorts.ports[port].isAttached(idx)) { return; } + idxIps.forEach((packet) => { + const ip = packet; + portIdentifier = `${port}[${ip.index}]`; + if (ip.type === 'openBracket') { + debugSend(`${this.nodeId} sending ${portIdentifier} < '${ip.data}'`); + } else if (ip.type === 'closeBracket') { + debugSend(`${this.nodeId} sending ${portIdentifier} > '${ip.data}'`); + } else { + debugSend(`${this.nodeId} sending ${portIdentifier} DATA`); + } + if (!this.outPorts[port].options.scoped) { + ip.scope = null; + } + this.outPorts[port].sendIP(ip); + }); + }); + return; + } + if (!this.outPorts.ports[port].isAttached()) { return; } + ips.forEach((packet) => { + const ip = packet; + portIdentifier = port; + if (ip.type === 'openBracket') { + debugSend(`${this.nodeId} sending ${portIdentifier} < '${ip.data}'`); + } else if (ip.type === 'closeBracket') { + debugSend(`${this.nodeId} sending ${portIdentifier} > '${ip.data}'`); + } else { + debugSend(`${this.nodeId} sending ${portIdentifier} DATA`); + } + if (!this.outPorts[port].options.scoped) { + ip.scope = null; + } + this.outPorts[port].sendIP(ip); + }); + }); + } + } + + // Signal that component has activated. There may be multiple + // activated contexts at the same time + activate(context) { + if (context.activated) { return; } // prevent double activation + context.activated = true; + context.deactivated = false; + this.load += 1; + this.emit('activate', this.load); + if (this.ordered || this.autoOrdering) { + this.outputQ.push(context.result); + } + } + + // Signal that component has deactivated. There may be multiple + // activated contexts at the same time + deactivate(context) { + if (context.deactivated) { return; } // prevent double deactivation + context.deactivated = true; + context.activated = false; + if (this.isOrdered()) { + this.processOutputQueue(); + } + this.load -= 1; + this.emit('deactivate', this.load); + } +} +Component.description = ''; +Component.icon = null; + +exports.Component = Component; diff --git a/src/lib/ComponentLoader.coffee b/src/lib/ComponentLoader.coffee deleted file mode 100644 index 64c8d9c7f..000000000 --- a/src/lib/ComponentLoader.coffee +++ /dev/null @@ -1,265 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2013-2017 Flowhub UG -# (c) 2013 Henri Bergius, Nemein -# NoFlo may be freely distributed under the MIT license -fbpGraph = require 'fbp-graph' -{EventEmitter} = require 'events' -registerLoader = require './loader/register' -platform = require './Platform' - -# ## The NoFlo Component Loader -# -# The Component Loader is responsible for discovering components -# available in the running system, as well as for instantiating -# them. -# -# Internally the loader uses a registered, platform-specific -# loader. NoFlo ships with a loader for Node.js that discovers -# components from the current project's `components/` and -# `graphs/` folders, as well as those folders of any installed -# NPM dependencies. For browsers and embedded devices it is -# possible to generate a statically configured component -# loader using the [noflo-component-loader](https://github.com/noflo/noflo-component-loader) webpack plugin. -class ComponentLoader extends EventEmitter - constructor: (baseDir, options = {}) -> - super() - @baseDir = baseDir - @options = options - @components = null - @libraryIcons = {} - @processing = false - @ready = false - @setMaxListeners 0 - - # Get the library prefix for a given module name. This - # is mostly used for generating valid names for namespaced - # NPM modules, as well as for convenience renaming all - # `noflo-` prefixed modules with just their base name. - # - # Examples: - # - # * `my-project` becomes `my-project` - # * `@foo/my-project` becomes `my-project` - # * `noflo-core` becomes `core` - getModulePrefix: (name) -> - return '' unless name - return '' if name is 'noflo' - name = name.replace /\@[a-z\-]+\//, '' if name[0] is '@' - name.replace /^noflo-/, '' - - # Get the list of all available components - listComponents: (callback) -> - if @processing - @once 'ready', => - callback null, @components - return - return callback null, @components if @components - - @ready = false - @processing = true - - @components = {} - registerLoader.register @, (err) => - if err - return callback err - throw err - @processing = false - @ready = true - @emit 'ready', true - callback null, @components - return - - # Load an instance of a specific component. If the - # registered component is a JSON or FBP graph, it will - # be loaded as an instance of the NoFlo subgraph - # component. - load: (name, callback, metadata) -> - unless @ready - @listComponents (err) => - return callback err if err - @load name, callback, metadata - return - - component = @components[name] - unless component - # Try an alias - for componentName of @components - if componentName.split('/')[1] is name - component = @components[componentName] - break - unless component - # Failure to load - callback new Error "Component #{name} not available with base #{@baseDir}" - return - - if @isGraph component - @loadGraph name, component, callback, metadata - return - - @createComponent name, component, metadata, (err, instance) => - return callback err if err - if not instance - callback new Error "Component #{name} could not be loaded." - return - - instance.baseDir = @baseDir if name is 'Graph' - instance.componentName = name if typeof name is 'string' - - if instance.isLegacy() - platform.deprecated "Component #{name} uses legacy NoFlo APIs. Please port to Process API" - - @setIcon name, instance - callback null, instance - - # Creates an instance of a component. - createComponent: (name, component, metadata, callback) -> - implementation = component - unless implementation - return callback new Error "Component #{name} not available" - - # If a string was specified, attempt to `require` it. - if typeof implementation is 'string' - if typeof registerLoader.dynamicLoad is 'function' - registerLoader.dynamicLoad name, implementation, metadata, callback - return - return callback Error "Dynamic loading of #{implementation} for component #{name} not available on this platform." - - # Attempt to create the component instance using the `getComponent` method. - if typeof implementation.getComponent is 'function' - try - instance = implementation.getComponent metadata - catch e - return callback e - # Attempt to create a component using a factory function. - else if typeof implementation is 'function' - try - instance = implementation metadata - catch e - return callback e - else - callback new Error "Invalid type #{typeof(implementation)} for component #{name}." - return - - callback null, instance - - # Check if a given filesystem path is actually a graph - isGraph: (cPath) -> - # Live graph instance - return true if typeof cPath is 'object' and cPath instanceof fbpGraph.Graph - # Graph JSON definition - return true if typeof cPath is 'object' and cPath.processes and cPath.connections - return false unless typeof cPath is 'string' - # Graph file path - cPath.indexOf('.fbp') isnt -1 or cPath.indexOf('.json') isnt -1 - - # Load a graph as a NoFlo subgraph component instance - loadGraph: (name, component, callback, metadata) -> - @createComponent name, @components['Graph'], metadata, (err, graph) => - return callback err if err - graph.loader = @ - graph.baseDir = @baseDir - graph.inPorts.remove 'graph' - graph.setGraph component, (err) => - return callback err if err - @setIcon name, graph - callback null, graph - return - return - - # Set icon for the component instance. If the instance - # has an icon set, then this is a no-op. Otherwise we - # determine an icon based on the module it is coming - # from, or use a fallback icon separately for subgraphs - # and elementary components. - setIcon: (name, instance) -> - # See if component has an icon - return if not instance.getIcon or instance.getIcon() - - # See if library has an icon - [library, componentName] = name.split '/' - if componentName and @getLibraryIcon library - instance.setIcon @getLibraryIcon library - return - - # See if instance is a subgraph - if instance.isSubgraph() - instance.setIcon 'sitemap' - return - - instance.setIcon 'gear' - return - - getLibraryIcon: (prefix) -> - if @libraryIcons[prefix] - return @libraryIcons[prefix] - return null - - setLibraryIcon: (prefix, icon) -> - @libraryIcons[prefix] = icon - - normalizeName: (packageId, name) -> - prefix = @getModulePrefix packageId - fullName = "#{prefix}/#{name}" - fullName = name unless packageId - fullName - - # ### Registering components at runtime - # - # In addition to components discovered by the loader, - # it is possible to register components at runtime. - # - # With the `registerComponent` method you can register - # a NoFlo Component constructor or factory method - # as a component available for loading. - registerComponent: (packageId, name, cPath, callback) -> - fullName = @normalizeName packageId, name - @components[fullName] = cPath - do callback if callback - - # With the `registerGraph` method you can register new - # graphs as loadable components. - registerGraph: (packageId, name, gPath, callback) -> - @registerComponent packageId, name, gPath, callback - - # With `registerLoader` you can register custom component - # loaders. They will be called immediately and can register - # any components or graphs they wish. - registerLoader: (loader, callback) -> - loader @, callback - - # With `setSource` you can register a component by providing - # a source code string. Supported languages and techniques - # depend on the runtime environment, for example CoffeeScript - # components can only be registered via `setSource` if - # the environment has a CoffeeScript compiler loaded. - setSource: (packageId, name, source, language, callback) -> - unless registerLoader.setSource - return callback new Error 'setSource not allowed' - - unless @ready - @listComponents (err) => - return callback err if err - @setSource packageId, name, source, language, callback - return - - registerLoader.setSource @, packageId, name, source, language, callback - - # `getSource` allows fetching the source code of a registered - # component as a string. - getSource: (name, callback) -> - unless registerLoader.getSource - return callback new Error 'getSource not allowed' - unless @ready - @listComponents (err) => - return callback err if err - @getSource name, callback - return - - registerLoader.getSource @, name, callback - - clear: -> - @components = null - @ready = false - @processing = false - -exports.ComponentLoader = ComponentLoader diff --git a/src/lib/ComponentLoader.js b/src/lib/ComponentLoader.js new file mode 100644 index 000000000..2c3b28f3e --- /dev/null +++ b/src/lib/ComponentLoader.js @@ -0,0 +1,350 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2017 Flowhub UG +// (c) 2013 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license + +/* eslint-disable + class-methods-use-this, + import/no-unresolved, +*/ + +const fbpGraph = require('fbp-graph'); +const { EventEmitter } = require('events'); +const registerLoader = require('./loader/register'); +const platform = require('./Platform'); + +// ## The NoFlo Component Loader +// +// The Component Loader is responsible for discovering components +// available in the running system, as well as for instantiating +// them. +// +// Internally the loader uses a registered, platform-specific +// loader. NoFlo ships with a loader for Node.js that discovers +// components from the current project's `components/` and +// `graphs/` folders, as well as those folders of any installed +// NPM dependencies. For browsers and embedded devices it is +// possible to generate a statically configured component +// loader using the [noflo-component-loader](https://github.com/noflo/noflo-component-loader) webpack plugin. +class ComponentLoader extends EventEmitter { + constructor(baseDir, options = {}) { + super(); + this.baseDir = baseDir; + this.options = options; + this.components = null; + this.libraryIcons = {}; + this.processing = false; + this.ready = false; + this.setMaxListeners(0); + } + + // Get the library prefix for a given module name. This + // is mostly used for generating valid names for namespaced + // NPM modules, as well as for convenience renaming all + // `noflo-` prefixed modules with just their base name. + // + // Examples: + // + // * `my-project` becomes `my-project` + // * `@foo/my-project` becomes `my-project` + // * `noflo-core` becomes `core` + getModulePrefix(name) { + if (!name) { return ''; } + let res = name; + if (res === 'noflo') { return ''; } + if (res[0] === '@') { res = res.replace(/@[a-z-]+\//, ''); } + return res.replace(/^noflo-/, ''); + } + + // Get the list of all available components + listComponents(callback) { + if (this.processing) { + this.once('ready', () => callback(null, this.components)); + return; + } + if (this.components) { + callback(null, this.components); + return; + } + + this.ready = false; + this.processing = true; + + this.components = {}; + registerLoader.register(this, (err) => { + if (err) { + callback(err); + return; + } + this.processing = false; + this.ready = true; + this.emit('ready', true); + callback(null, this.components); + }); + } + + // Load an instance of a specific component. If the + // registered component is a JSON or FBP graph, it will + // be loaded as an instance of the NoFlo subgraph + // component. + load(name, callback, metadata) { + if (!this.ready) { + this.listComponents((err) => { + if (err) { + callback(err); + return; + } + this.load(name, callback, metadata); + }); + return; + } + + let component = this.components[name]; + if (!component) { + // Try an alias + const keys = Object.keys(this.components); + for (let i = 0; i < keys.length; i += 1) { + const componentName = keys[i]; + if (componentName.split('/')[1] === name) { + component = this.components[componentName]; + break; + } + } + if (!component) { + // Failure to load + callback(new Error(`Component ${name} not available with base ${this.baseDir}`)); + return; + } + } + + if (this.isGraph(component)) { + this.loadGraph(name, component, callback, metadata); + return; + } + + this.createComponent(name, component, metadata, (err, instance) => { + const inst = instance; + if (err) { + callback(err); + return; + } + if (!inst) { + callback(new Error(`Component ${name} could not be loaded.`)); + return; + } + + if (name === 'Graph') { inst.baseDir = this.baseDir; } + if (typeof name === 'string') { inst.componentName = name; } + + if (inst.isLegacy()) { + platform.deprecated(`Component ${name} uses legacy NoFlo APIs. Please port to Process API`); + } + + this.setIcon(name, inst); + callback(null, inst); + }); + } + + // Creates an instance of a component. + createComponent(name, component, metadata, callback) { + let e; let + instance; + const implementation = component; + if (!implementation) { + callback(new Error(`Component ${name} not available`)); + return; + } + + // If a string was specified, attempt to `require` it. + if (typeof implementation === 'string') { + if (typeof registerLoader.dynamicLoad === 'function') { + registerLoader.dynamicLoad(name, implementation, metadata, callback); + return; + } + callback(Error(`Dynamic loading of ${implementation} for component ${name} not available on this platform.`)); + return; + } + + // Attempt to create the component instance using the `getComponent` method. + if (typeof implementation.getComponent === 'function') { + try { + instance = implementation.getComponent(metadata); + } catch (error) { + e = error; + callback(e); + return; + } + // Attempt to create a component using a factory function. + } else if (typeof implementation === 'function') { + try { + instance = implementation(metadata); + } catch (error1) { + e = error1; + callback(e); + return; + } + } else { + callback(new Error(`Invalid type ${typeof (implementation)} for component ${name}.`)); + return; + } + + callback(null, instance); + } + + // Check if a given filesystem path is actually a graph + isGraph(cPath) { + // Live graph instance + if ((typeof cPath === 'object') && cPath instanceof fbpGraph.Graph) { return true; } + // Graph JSON definition + if ((typeof cPath === 'object') && cPath.processes && cPath.connections) { return true; } + if (typeof cPath !== 'string') { return false; } + // Graph file path + return (cPath.indexOf('.fbp') !== -1) || (cPath.indexOf('.json') !== -1); + } + + // Load a graph as a NoFlo subgraph component instance + loadGraph(name, component, callback, metadata) { + this.createComponent(name, this.components.Graph, metadata, (error, graph) => { + const g = graph; + if (error) { + callback(error); + return; + } + g.loader = this; + g.baseDir = this.baseDir; + g.inPorts.remove('graph'); + g.setGraph(component, (err) => { + if (err) { + callback(err); + return; + } + this.setIcon(name, g); + callback(null, g); + }); + }); + } + + // Set icon for the component instance. If the instance + // has an icon set, then this is a no-op. Otherwise we + // determine an icon based on the module it is coming + // from, or use a fallback icon separately for subgraphs + // and elementary components. + setIcon(name, instance) { + // See if component has an icon + if (!instance.getIcon || instance.getIcon()) { return; } + + // See if library has an icon + const [library, componentName] = name.split('/'); + if (componentName && this.getLibraryIcon(library)) { + instance.setIcon(this.getLibraryIcon(library)); + return; + } + + // See if instance is a subgraph + if (instance.isSubgraph()) { + instance.setIcon('sitemap'); + return; + } + + instance.setIcon('gear'); + } + + getLibraryIcon(prefix) { + if (this.libraryIcons[prefix]) { + return this.libraryIcons[prefix]; + } + return null; + } + + setLibraryIcon(prefix, icon) { + this.libraryIcons[prefix] = icon; + } + + normalizeName(packageId, name) { + const prefix = this.getModulePrefix(packageId); + let fullName = `${prefix}/${name}`; + if (!packageId) { fullName = name; } + return fullName; + } + + // ### Registering components at runtime + // + // In addition to components discovered by the loader, + // it is possible to register components at runtime. + // + // With the `registerComponent` method you can register + // a NoFlo Component constructor or factory method + // as a component available for loading. + registerComponent(packageId, name, cPath, callback) { + const fullName = this.normalizeName(packageId, name); + this.components[fullName] = cPath; + if (callback) { callback(); } + } + + // With the `registerGraph` method you can register new + // graphs as loadable components. + registerGraph(packageId, name, gPath, callback) { + this.registerComponent(packageId, name, gPath, callback); + } + + // With `registerLoader` you can register custom component + // loaders. They will be called immediately and can register + // any components or graphs they wish. + registerLoader(loader, callback) { + loader(this, callback); + } + + // With `setSource` you can register a component by providing + // a source code string. Supported languages and techniques + // depend on the runtime environment, for example CoffeeScript + // components can only be registered via `setSource` if + // the environment has a CoffeeScript compiler loaded. + setSource(packageId, name, source, language, callback) { + if (!registerLoader.setSource) { + callback(new Error('setSource not allowed')); + return; + } + + if (!this.ready) { + this.listComponents((err) => { + if (err) { + callback(err); + return; + } + this.setSource(packageId, name, source, language, callback); + }); + return; + } + + registerLoader.setSource(this, packageId, name, source, language, callback); + } + + // `getSource` allows fetching the source code of a registered + // component as a string. + getSource(name, callback) { + if (!registerLoader.getSource) { + callback(new Error('getSource not allowed')); + return; + } + if (!this.ready) { + this.listComponents((err) => { + if (err) { + callback(err); + return; + } + this.getSource(name, callback); + }); + return; + } + + registerLoader.getSource(this, name, callback); + } + + clear() { + this.components = null; + this.ready = false; + this.processing = false; + } +} + +exports.ComponentLoader = ComponentLoader; diff --git a/src/lib/Helpers.coffee b/src/lib/Helpers.coffee deleted file mode 100644 index e050beefa..000000000 --- a/src/lib/Helpers.coffee +++ /dev/null @@ -1,507 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2014-2017 Flowhub UG -# NoFlo may be freely distributed under the MIT license -InternalSocket = require './InternalSocket' -IP = require './IP' -platform = require './Platform' -utils = require './Utils' -debug = require('debug') 'noflo:helpers' - -# ## NoFlo WirePattern helper -# -# **Note:** WirePattern is no longer the recommended way to build -# NoFlo components. Please use [Process API](https://noflojs.org/documentation/components/) instead. -# -# WirePattern makes your component collect data from several inports -# and activates a handler `proc` only when a tuple from all of these -# ports is complete. The signature of handler function is: -# ``` -# proc = (combinedInputData, inputGroups, outputPorts, asyncCallback) -> -# ``` -# -# With `config.forwardGroups = true` it would forward group IPs from -# inputs to the output sending them along with the data. This option also -# accepts string or array values, if you want to forward groups from specific -# port(s) only. By default group forwarding is `false`. -# -# substream cannot be interrupted by other packets, which is important when -# doing asynchronous processing. In fact, `sendStreams` is enabled by default -# on all outports when `config.async` is `true`. -# -# WirePattern supports async `proc` handlers. Set `config.async = true` and -# make sure that `proc` accepts callback as 4th parameter and calls it when -# async operation completes or fails. -exports.WirePattern = (component, config, proc) -> - # In ports - inPorts = if 'in' of config then config.in else 'in' - inPorts = [ inPorts ] unless utils.isArray inPorts - # Out ports - outPorts = if 'out' of config then config.out else 'out' - outPorts = [ outPorts ] unless utils.isArray outPorts - # Error port - config.error = 'error' unless 'error' of config - # For async process - config.async = false unless 'async' of config - # Keep correct output order for async mode - config.ordered = true unless 'ordered' of config - # Group requests by group ID - config.group = false unless 'group' of config - # Group requests by object field - config.field = null unless 'field' of config - # Forward group events from specific inputs to the output: - # - false: don't forward anything - # - true: forward unique groups of all inputs - # - string: forward groups of a specific port only - # - array: forward unique groups of inports in the list - config.forwardGroups = false unless 'forwardGroups' of config - if config.forwardGroups - if typeof config.forwardGroups is 'string' - # Collect groups from one and only port? - config.forwardGroups = [config.forwardGroups] - if typeof config.forwardGroups is 'boolean' - # Forward groups from each port? - config.forwardGroups = inPorts - # Receive streams feature - config.receiveStreams = false unless 'receiveStreams' of config - if config.receiveStreams - throw new Error 'WirePattern receiveStreams is deprecated' - # if typeof config.receiveStreams is 'string' - # config.receiveStreams = [ config.receiveStreams ] - # Send streams feature - config.sendStreams = false unless 'sendStreams' of config - if config.sendStreams - throw new Error 'WirePattern sendStreams is deprecated' - # if typeof config.sendStreams is 'string' - # config.sendStreams = [ config.sendStreams ] - config.sendStreams = outPorts if config.async - # Parameter ports - config.params = [] unless 'params' of config - config.params = [ config.params ] if typeof config.params is 'string' - # Node name - config.name = '' unless 'name' of config - # Drop premature input before all params are received - config.dropInput = false unless 'dropInput' of config - # Firing policy for addressable ports - unless 'arrayPolicy' of config - config.arrayPolicy = - in: 'any' - params: 'all' - - config.inPorts = inPorts - config.outPorts = outPorts - # Warn user of deprecated features - checkDeprecation config, proc - # Allow users to selectively fall back to legacy WirePattern implementation - if config.legacy or process?.env?.NOFLO_WIREPATTERN_LEGACY - platform.deprecated 'noflo.helpers.WirePattern legacy mode is deprecated' - return processApiWirePattern component, config, proc - -# Takes WirePattern configuration of a component and sets up -# Process API to handle it. -processApiWirePattern = (component, config, func) -> - # Make param ports control ports - setupControlPorts component, config - # Set up sendDefaults function - setupSendDefaults component - # Set up bracket forwarding rules - setupBracketForwarding component, config - component.ordered = config.ordered - # Create the processing function - component.process (input, output, context) -> - # Abort unless WirePattern-style preconditions don't match - return unless checkWirePatternPreconditions config, input, output - # Populate component.params from control ports - component.params = populateParams config, input - # Read input data - data = getInputData config, input - # Read bracket context of first inport - groups = getGroupContext component, config.inPorts[0], input - # Produce proxy object wrapping output in legacy-style port API - outProxy = getOutputProxy config.outPorts, output - - debug "WirePattern Process API call with", data, groups, component.params, context.scope - - postpone = -> - throw new Error 'noflo.helpers.WirePattern postpone is deprecated' - resume = -> - throw new Error 'noflo.helpers.WirePattern resume is deprecated' - - # Async WirePattern will call the output.done callback itself - errorHandler = setupErrorHandler component, config, output - func.call component, data, groups, outProxy, (err) -> - do errorHandler - output.done err - , postpone, resume, input.scope - -# Provide deprecation warnings on certain more esoteric WirePattern features -checkDeprecation = (config, func) -> - # First check the conditions that force us to fall back on legacy WirePattern - if config.group - platform.deprecated 'noflo.helpers.WirePattern group option is deprecated. Please port to Process API' - if config.field - platform.deprecated 'noflo.helpers.WirePattern field option is deprecated. Please port to Process API' - # Then add deprecation warnings for other unwanted behaviors - if func.length > 4 - platform.deprecated 'noflo.helpers.WirePattern postpone and resume are deprecated. Please port to Process API' - unless config.async - throw new Error 'noflo.helpers.WirePattern synchronous is deprecated. Please use async: true' - if func.length < 4 - throw new Error 'noflo.helpers.WirePattern callback doesn\'t use callback argument' - unless config.error is 'error' - platform.deprecated 'noflo.helpers.WirePattern custom error port name is deprecated. Please switch to "error" or port to WirePattern' - return - -# Updates component port definitions to control prots for WirePattern -# -style params array -setupControlPorts = (component, config) -> - for param in config.params - component.inPorts[param].options.control = true - -# Sets up Process API bracket forwarding rules for WirePattern configuration -setupBracketForwarding = (component, config) -> - # Start with empty bracket forwarding config - component.forwardBrackets = {} - return unless config.forwardGroups - # By default we forward from all inports - inPorts = config.inPorts - if utils.isArray config.forwardGroups - # Selective forwarding enabled - inPorts = config.forwardGroups - for inPort in inPorts - component.forwardBrackets[inPort] = [] - # Forward to all declared outports - for outPort in config.outPorts - component.forwardBrackets[inPort].push outPort - # If component has an error outport, forward there too - if component.outPorts.error - component.forwardBrackets[inPort].push 'error' - return - -setupErrorHandler = (component, config, output) -> - errors = [] - errorHandler = (e, groups = []) -> - platform.deprecated 'noflo.helpers.WirePattern error method is deprecated. Please send error to callback instead' - errors.push - err: e - groups: groups - component.hasErrors = true - failHandler = (e = null, groups = []) -> - platform.deprecated 'noflo.helpers.WirePattern fail method is deprecated. Please send error to callback instead' - errorHandler e, groups if e - sendErrors() - output.done() - - sendErrors = -> - return unless errors.length - output.sendIP 'error', new IP 'openBracket', config.name if config.name - errors.forEach (e) -> - output.sendIP 'error', new IP 'openBracket', grp for grp in e.groups - output.sendIP 'error', new IP 'data', e.err - output.sendIP 'error', new IP 'closeBracket', grp for grp in e.groups - output.sendIP 'error', new IP 'closeBracket', config.name if config.name - component.hasErrors = false - errors = [] - - component.hasErrors = false - component.error = errorHandler - component.fail = failHandler - - sendErrors - -setupSendDefaults = (component) -> - portsWithDefaults = Object.keys(component.inPorts.ports).filter (p) -> - return false unless component.inPorts[p].options.control - return false unless component.inPorts[p].hasDefault() - true - component.sendDefaults = -> - platform.deprecated 'noflo.helpers.WirePattern sendDefaults method is deprecated. Please start with a Network' - portsWithDefaults.forEach (port) -> - tempSocket = InternalSocket.createSocket() - component.inPorts[port].attach tempSocket - tempSocket.send() - tempSocket.disconnect() - component.inPorts[port].detach tempSocket - -populateParams = (config, input) -> - return {} unless config.params.length - params = {} - for paramPort in config.params - if input.ports[paramPort].isAddressable() - params[paramPort] = {} - for idx in input.attached paramPort - continue unless input.hasData [paramPort, idx] - params[paramPort][idx] = input.getData [paramPort, idx] - continue - params[paramPort] = input.getData paramPort - return params - -reorderBuffer = (buffer, matcher) -> - # Move matching IP packet to be first in buffer - # - # Note: the collation mechanism as shown below is not a - # very nice way to deal with inputs as it messes with - # input buffer order. Much better to handle collation - # in a specialized component or to separate flows by - # scope. - # - # The trick here is to order the input in a way that - # still allows bracket forwarding to work. So if we - # want to first process packet B in stream like: - # - # < 1 - # < 2 - # A - # > 2 - # < 3 - # B - # > 3 - # > 1 - # - # We need to change the stream to be like: - # - # < 1 - # < 3 - # B - # > 3 - # < 2 - # A - # > 2 - # > 1 - substream = null - brackets = [] - substreamBrackets = [] - for ip, idx in buffer - if ip.type is 'openBracket' - brackets.push ip.data - substreamBrackets.push ip - continue - if ip.type is 'closeBracket' - brackets.pop() - substream.push ip if substream - substreamBrackets.pop() if substreamBrackets.length - break if substream and not substreamBrackets.length - continue - unless matcher ip, brackets - # Reset substream bracket tracking when we hit data - substreamBrackets = [] - continue - # Match found, start tracking the actual substream - substream = substreamBrackets.slice 0 - substream.push ip - # See where in the buffer the matching substream begins - substreamIdx = buffer.indexOf substream[0] - # No need to reorder if matching packet is already first - return if substreamIdx is 0 - # Remove substream from its natural position - buffer.splice substreamIdx, substream.length - # Place the substream in the beginning - substream.reverse() - buffer.unshift ip for ip in substream - -handleInputCollation = (data, config, input, port, idx) -> - return if not config.group and not config.field - if config.group - buf = input.ports[port].getBuffer input.scope, idx - reorderBuffer buf, (ip, brackets) -> - for grp, idx in input.collatedBy.brackets - return false unless brackets[idx] is grp - true - - if config.field - data[config.field] = input.collatedBy.field - buf = input.ports[port].getBuffer input.scope, idx - reorderBuffer buf, (ip) -> - ip.data[config.field] is data[config.field] - -getInputData = (config, input) -> - data = {} - for port in config.inPorts - if input.ports[port].isAddressable() - data[port] = {} - for idx in input.attached port - continue unless input.hasData [port, idx] - handleInputCollation data, config, input, port, idx - data[port][idx] = input.getData [port, idx] - continue - continue unless input.hasData port - handleInputCollation data, config, input, port - data[port] = input.getData port - if config.inPorts.length is 1 - return data[config.inPorts[0]] - return data - -getGroupContext = (component, port, input) -> - return [] unless input.result.__bracketContext?[port]? - return input.collatedBy.brackets if input.collatedBy?.brackets - input.result.__bracketContext[port].filter((c) -> - c.source is port - ).map (c) -> c.ip.data - -getOutputProxy = (ports, output) -> - outProxy = {} - ports.forEach (port) -> - outProxy[port] = - connect: -> - beginGroup: (group, idx) -> - ip = new IP 'openBracket', group - ip.index = idx - output.sendIP port, ip - send: (data, idx) -> - ip = new IP 'data', data - ip.index = idx - output.sendIP port, ip - endGroup: (group, idx) -> - ip = new IP 'closeBracket', group - ip.index = idx - output.sendIP port, ip - disconnect: -> - if ports.length is 1 - return outProxy[ports[0]] - return outProxy - -checkWirePatternPreconditions = (config, input, output) -> - # First check for required params - paramsOk = checkWirePatternPreconditionsParams config, input - # Then check actual input ports - inputsOk = checkWirePatternPreconditionsInput config, input - # If input port has data but param requirements are not met, and we're in dropInput - # mode, read the data and call done - if config.dropInput and not paramsOk - # Drop all received input packets since params are not available - packetsDropped = false - for port in config.inPorts - if input.ports[port].isAddressable() - attached = input.attached port - continue unless attached.length - for idx in attached - while input.has [port, idx] - packetsDropped = true - input.get([port, idx]).drop() - continue - while input.has port - packetsDropped = true - input.get(port).drop() - # If we ended up dropping inputs because of missing params, we need to - # deactivate here - output.done() if packetsDropped - # Pass precondition check only if both params and inputs are OK - return inputsOk and paramsOk - -checkWirePatternPreconditionsParams = (config, input) -> - for param in config.params - continue unless input.ports[param].isRequired() - if input.ports[param].isAddressable() - attached = input.attached param - return false unless attached.length - withData = attached.filter (idx) -> input.hasData [param, idx] - if config.arrayPolicy.params is 'all' - return false unless withData.length is attached.length - continue - return false unless withData.length - continue - return false unless input.hasData param - true - -checkWirePatternPreconditionsInput = (config, input) -> - if config.group - bracketsAtPorts = {} - input.collatedBy = - brackets: [] - ready: false - checkBrackets = (left, right) -> - for bracket, idx in left - return false unless right[idx] is bracket - true - checkPacket = (ip, brackets) -> - # With data packets we validate bracket matching - bracketsToCheck = brackets.slice 0 - if config.group instanceof RegExp - # Basic regexp validation for the brackets - bracketsToCheck = bracketsToCheck.slice 0, 1 - return false unless bracketsToCheck.length - return false unless config.group.test bracketsToCheck[0] - - if input.collatedBy.ready - # We already know what brackets we're looking for, match - return checkBrackets input.collatedBy.brackets, bracketsToCheck - - bracketId = bracketsToCheck.join ':' - bracketsAtPorts[bracketId] = [] unless bracketsAtPorts[bracketId] - if bracketsAtPorts[bracketId].indexOf(port) is -1 - # Register that this port had these brackets - bracketsAtPorts[bracketId].push port - - # To prevent deadlocks we see all bracket sets, and validate if at least - # one of them matches. This means we return true until the last inport - # where we actually check. - return true unless config.inPorts.indexOf(port) is config.inPorts.length - 1 - - # Brackets that are not in every port are invalid - return false unless bracketsAtPorts[bracketId].length is config.inPorts.length - return false if input.collatedBy.ready - input.collatedBy.ready = true - input.collatedBy.brackets = bracketsToCheck - true - - if config.field - input.collatedBy = - field: undefined - ready: false - - checkPort = (port) -> - # Without collation rules any data packet is OK - return input.hasData port if not config.group and not config.field - - # With collation rules set we need can only work when we have full - # streams - if config.group - portBrackets = [] - dataBrackets = [] - hasMatching = false - buf = input.ports[port].getBuffer input.scope - for ip in buf - if ip.type is 'openBracket' - portBrackets.push ip.data - continue - if ip.type is 'closeBracket' - portBrackets.pop() - continue if portBrackets.length - continue unless hasData - hasMatching = true - continue - hasData = checkPacket ip, portBrackets - continue - return hasMatching - - if config.field - return input.hasStream port, (ip) -> - # Use first data packet to define what to collate by - unless input.collatedBy.ready - input.collatedBy.field = ip.data[config.field] - input.collatedBy.ready = true - return true - return ip.data[config.field] is input.collatedBy.field - - for port in config.inPorts - if input.ports[port].isAddressable() - attached = input.attached port - return false unless attached.length - withData = attached.filter (idx) -> checkPort [port, idx] - if config.arrayPolicy['in'] is 'all' - return false unless withData.length is attached.length - continue - return false unless withData.length - continue - return false unless checkPort port - true - -# `CustomError` returns an `Error` object carrying additional properties. -exports.CustomError = (message, options) -> - err = new Error message - return exports.CustomizeError err, options - -# `CustomizeError` sets additional options for an `Error` object. -exports.CustomizeError = (err, options) -> - for own key, val of options - err[key] = val - return err diff --git a/src/lib/IP.coffee b/src/lib/IP.coffee deleted file mode 100644 index 002b3c980..000000000 --- a/src/lib/IP.coffee +++ /dev/null @@ -1,67 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2016-2017 Flowhub UG -# NoFlo may be freely distributed under the MIT license - -# ## Information Packets -# -# IP objects are the way information is transmitted between -# components running in a NoFlo network. IP objects contain -# a `type` that defines whether they're regular `data` IPs -# or whether they are the beginning or end of a stream -# (`openBracket`, `closeBracket`). -# -# The component currently holding an IP object is identified -# with the `owner` key. -# -# By default, IP objects may be sent to multiple components. -# If they're set to be clonable, each component will receive -# its own clone of the IP. This should be enabled for any -# IP object working with data that is safe to clone. -# -# It is also possible to carry metadata with an IP object. -# For example, the `datatype` and `schema` of the sending -# port is transmitted with the IP object. -module.exports = class IP - # Valid IP types - @types: [ - 'data' - 'openBracket' - 'closeBracket' - ] - - # Detects if an arbitrary value is an IP - @isIP: (obj) -> - obj and typeof obj is 'object' and obj._isIP is true - - # Creates as new IP object - # Valid types: 'data', 'openBracket', 'closeBracket' - constructor: (@type = 'data', @data = null, options = {}) -> - @_isIP = true - @scope = null # sync scope id - @owner = null # packet owner process - @clonable = false # cloning safety flag - @index = null # addressable port index - @schema = null - @datatype = 'all' - for key, val of options - this[key] = val - - # Creates a new IP copying its contents by value not reference - clone: -> - ip = new IP @type - for key, val of @ - continue if ['owner'].indexOf(key) isnt -1 - continue if val is null - if typeof(val) is 'object' - ip[key] = JSON.parse JSON.stringify val - else - ip[key] = val - ip - - # Moves an IP to a different owner - move: (@owner) -> - # no-op - - # Frees IP contents - drop: -> - delete this[key] for key, val of @ diff --git a/src/lib/IP.js b/src/lib/IP.js new file mode 100644 index 000000000..5e2b0b5df --- /dev/null +++ b/src/lib/IP.js @@ -0,0 +1,81 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2016-2017 Flowhub UG +// NoFlo may be freely distributed under the MIT license + +// ## Information Packets +// +// IP objects are the way information is transmitted between +// components running in a NoFlo network. IP objects contain +// a `type` that defines whether they're regular `data` IPs +// or whether they are the beginning or end of a stream +// (`openBracket`, `closeBracket`). +// +// The component currently holding an IP object is identified +// with the `owner` key. +// +// By default, IP objects may be sent to multiple components. +// If they're set to be clonable, each component will receive +// its own clone of the IP. This should be enabled for any +// IP object working with data that is safe to clone. +// +// It is also possible to carry metadata with an IP object. +// For example, the `datatype` and `schema` of the sending +// port is transmitted with the IP object. + +// Valid IP types: +// - 'data' +// - 'openBracket' +// - 'closeBracket' + +module.exports = class IP { + // Detects if an arbitrary value is an IP + static isIP(obj) { + return obj && (typeof obj === 'object') && (obj.isIP === true); + } + + // Creates as new IP object + // Valid types: 'data', 'openBracket', 'closeBracket' + constructor(type, data = null, options = {}) { + this.type = type || 'data'; + this.data = data; + this.isIP = true; + this.scope = null; // sync scope id + this.owner = null; // packet owner process + this.clonable = false; // cloning safety flag + this.index = null; // addressable port index + this.schema = null; + this.datatype = 'all'; + if (typeof options === 'object') { + Object.keys(options).forEach((key) => { this[key] = options[key]; }); + } + return this; + } + + // Creates a new IP copying its contents by value not reference + clone() { + const ip = new IP(this.type); + Object.keys(this).forEach((key) => { + const val = this[key]; + if (key === 'owner') { return; } + if (val === null) { return; } + if (typeof (val) === 'object') { + ip[key] = JSON.parse(JSON.stringify(val)); + } else { + ip[key] = val; + } + }); + return ip; + } + + // Moves an IP to a different owner + move(owner) { + // no-op + this.owner = owner; + return this; + } + + // Frees IP contents + drop() { + Object.keys(this).forEach((key) => { delete this[key]; }); + } +}; diff --git a/src/lib/InPort.coffee b/src/lib/InPort.coffee deleted file mode 100644 index a23d531c0..000000000 --- a/src/lib/InPort.coffee +++ /dev/null @@ -1,169 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2014-2017 Flowhub UG -# NoFlo may be freely distributed under the MIT license -BasePort = require './BasePort' -IP = require './IP' - -# ## NoFlo inport -# -# Input Port (inport) implementation for NoFlo components. These -# ports are the way a component receives Information Packets. -class InPort extends BasePort - constructor: (options = {}) -> - options.control ?= false - options.scoped ?= true - options.triggering ?= true - - if options.process - throw new Error 'InPort process callback is deprecated. Please use Process API' - - if options.handle - throw new Error 'InPort handle callback is deprecated. Please use Process API' - - super options - - @prepareBuffer() - - # Assign a delegate for retrieving data should this inPort - attachSocket: (socket, localId = null) -> - # have a default value. - if @hasDefault() - socket.setDataDelegate => @options.default - - socket.on 'connect', => - @handleSocketEvent 'connect', socket, localId - socket.on 'begingroup', (group) => - @handleSocketEvent 'begingroup', group, localId - socket.on 'data', (data) => - @validateData data - @handleSocketEvent 'data', data, localId - socket.on 'endgroup', (group) => - @handleSocketEvent 'endgroup', group, localId - socket.on 'disconnect', => - @handleSocketEvent 'disconnect', socket, localId - socket.on 'ip', (ip) => - @handleIP ip, localId - - handleIP: (ip, id) -> - return if @options.control and ip.type isnt 'data' - ip.owner = @nodeInstance - ip.index = id if @isAddressable() - if ip.datatype is 'all' - # Stamp non-specific IP objects with port datatype - ip.datatype = @getDataType() - if @getSchema() and not ip.schema - # Stamp non-specific IP objects with port schema - ip.schema = @getSchema() - - buf = @prepareBufferForIP ip - buf.push ip - buf.shift() if @options.control and buf.length > 1 - - @emit 'ip', ip, id - - handleSocketEvent: (event, payload, id) -> - # Emit port event - return @emit event, payload, id if @isAddressable() - @emit event, payload - - hasDefault: -> - return @options.default isnt undefined - - prepareBuffer: -> - if @isAddressable() - @scopedBuffer = {} if @options.scoped - @indexedBuffer = {} - @iipBuffer = {} - return - @scopedBuffer = {} if @options.scoped - @iipBuffer = [] - @buffer = [] - return - - prepareBufferForIP: (ip) -> - if @isAddressable() - if ip.scope? and @options.scoped - @scopedBuffer[ip.scope] = [] unless ip.scope of @scopedBuffer - @scopedBuffer[ip.scope][ip.index] = [] unless ip.index of @scopedBuffer[ip.scope] - return @scopedBuffer[ip.scope][ip.index] - if ip.initial - @iipBuffer[ip.index] = [] unless ip.index of @iipBuffer - return @iipBuffer[ip.index] - @indexedBuffer[ip.index] = [] unless ip.index of @indexedBuffer - return @indexedBuffer[ip.index] - if ip.scope? and @options.scoped - @scopedBuffer[ip.scope] = [] unless ip.scope of @scopedBuffer - return @scopedBuffer[ip.scope] - if ip.initial - return @iipBuffer - return @buffer - - validateData: (data) -> - return unless @options.values - if @options.values.indexOf(data) is -1 - throw new Error "Invalid data='#{data}' received, not in [#{@options.values}]" - - getBuffer: (scope, idx, initial = false) -> - if @isAddressable() - if scope? and @options.scoped - return undefined unless scope of @scopedBuffer - return undefined unless idx of @scopedBuffer[scope] - return @scopedBuffer[scope][idx] - if initial - return undefined unless idx of @iipBuffer - return @iipBuffer[idx] - return undefined unless idx of @indexedBuffer - return @indexedBuffer[idx] - if scope? and @options.scoped - return undefined unless scope of @scopedBuffer - return @scopedBuffer[scope] - if initial - return @iipBuffer - return @buffer - - getFromBuffer: (scope, idx, initial = false) -> - buf = @getBuffer scope, idx, initial - return undefined unless buf?.length - return if @options.control then buf[buf.length - 1] else buf.shift() - - # Fetches a packet from the port - get: (scope, idx) -> - res = @getFromBuffer scope, idx - return res if res isnt undefined - # Try to find an IIP instead - @getFromBuffer null, idx, true - - hasIPinBuffer: (scope, idx, validate, initial = false) -> - buf = @getBuffer scope, idx, initial - return false unless buf?.length - for packet in buf - return true if validate packet - false - - hasIIP: (idx, validate) -> - @hasIPinBuffer null, idx, validate, true - - # Returns true if port contains packet(s) matching the validator - has: (scope, idx, validate) -> - unless @isAddressable() - validate = idx - idx = null - return true if @hasIPinBuffer scope, idx, validate - return true if @hasIIP idx, validate - false - - # Returns the number of data packets in an inport - length: (scope, idx) -> - buf = @getBuffer scope, idx - return 0 unless buf - return buf.length - - # Tells if buffer has packets or not - ready: (scope, idx) -> - return @length(scope) > 0 - - # Clears inport buffers - clear: -> - @prepareBuffer() - -module.exports = InPort diff --git a/src/lib/InPort.js b/src/lib/InPort.js new file mode 100644 index 000000000..bd3a1a5ac --- /dev/null +++ b/src/lib/InPort.js @@ -0,0 +1,206 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2014-2017 Flowhub UG +// NoFlo may be freely distributed under the MIT license +const BasePort = require('./BasePort'); + +// ## NoFlo inport +// +// Input Port (inport) implementation for NoFlo components. These +// ports are the way a component receives Information Packets. +module.exports = class InPort extends BasePort { + constructor(options = {}) { + const opts = options; + if (opts.control == null) { opts.control = false; } + if (opts.scoped == null) { opts.scoped = true; } + if (opts.triggering == null) { opts.triggering = true; } + + if (opts.process) { + throw new Error('InPort process callback is deprecated. Please use Process API'); + } + + if (opts.handle) { + throw new Error('InPort handle callback is deprecated. Please use Process API'); + } + + super(opts); + + this.prepareBuffer(); + } + + // Assign a delegate for retrieving data should this inPort + attachSocket(socket, localId = null) { + // have a default value. + if (this.hasDefault()) { + socket.setDataDelegate(() => this.options.default); + } + + socket.on('connect', () => this.handleSocketEvent('connect', socket, localId)); + socket.on('begingroup', (group) => this.handleSocketEvent('begingroup', group, localId)); + socket.on('data', (data) => { + this.validateData(data); + return this.handleSocketEvent('data', data, localId); + }); + socket.on('endgroup', (group) => this.handleSocketEvent('endgroup', group, localId)); + socket.on('disconnect', () => this.handleSocketEvent('disconnect', socket, localId)); + socket.on('ip', (ip) => this.handleIP(ip, localId)); + } + + handleIP(packet, index) { + if (this.options.control && (packet.type !== 'data')) { return; } + const ip = packet; + ip.owner = this.nodeInstance; + if (this.isAddressable()) { ip.index = index; } + if (ip.datatype === 'all') { + // Stamp non-specific IP objects with port datatype + ip.datatype = this.getDataType(); + } + if (this.getSchema() && !ip.schema) { + // Stamp non-specific IP objects with port schema + ip.schema = this.getSchema(); + } + + const buf = this.prepareBufferForIP(ip); + buf.push(ip); + if (this.options.control && (buf.length > 1)) { buf.shift(); } + + this.emit('ip', ip, index); + } + + handleSocketEvent(event, payload, id) { + // Emit port event + if (this.isAddressable()) { + return this.emit(event, payload, id); + } + return this.emit(event, payload); + } + + hasDefault() { + return this.options.default !== undefined; + } + + prepareBuffer() { + if (this.isAddressable()) { + if (this.options.scoped) { this.scopedBuffer = {}; } + this.indexedBuffer = {}; + this.iipBuffer = {}; + return; + } + if (this.options.scoped) { this.scopedBuffer = {}; } + this.iipBuffer = []; + this.buffer = []; + } + + prepareBufferForIP(ip) { + if (this.isAddressable()) { + if ((ip.scope != null) && this.options.scoped) { + if (!(ip.scope in this.scopedBuffer)) { this.scopedBuffer[ip.scope] = []; } + if (!(ip.index in this.scopedBuffer[ip.scope])) { + this.scopedBuffer[ip.scope][ip.index] = []; + } + return this.scopedBuffer[ip.scope][ip.index]; + } + if (ip.initial) { + if (!(ip.index in this.iipBuffer)) { this.iipBuffer[ip.index] = []; } + return this.iipBuffer[ip.index]; + } + if (!(ip.index in this.indexedBuffer)) { this.indexedBuffer[ip.index] = []; } + return this.indexedBuffer[ip.index]; + } + if ((ip.scope != null) && this.options.scoped) { + if (!(ip.scope in this.scopedBuffer)) { this.scopedBuffer[ip.scope] = []; } + return this.scopedBuffer[ip.scope]; + } + if (ip.initial) { + return this.iipBuffer; + } + return this.buffer; + } + + validateData(data) { + if (!this.options.values) { return; } + if (this.options.values.indexOf(data) === -1) { + throw new Error(`Invalid data='${data}' received, not in [${this.options.values}]`); + } + } + + getBuffer(scope, index, initial = false) { + if (this.isAddressable()) { + if ((scope != null) && this.options.scoped) { + if (!(scope in this.scopedBuffer)) { return undefined; } + if (!(index in this.scopedBuffer[scope])) { return undefined; } + return this.scopedBuffer[scope][index]; + } + if (initial) { + if (!(index in this.iipBuffer)) { return undefined; } + return this.iipBuffer[index]; + } + if (!(index in this.indexedBuffer)) { return undefined; } + return this.indexedBuffer[index]; + } + if ((scope != null) && this.options.scoped) { + if (!(scope in this.scopedBuffer)) { return undefined; } + return this.scopedBuffer[scope]; + } + if (initial) { + return this.iipBuffer; + } + return this.buffer; + } + + getFromBuffer(scope, index, initial = false) { + const buf = this.getBuffer(scope, index, initial); + if (!(buf != null ? buf.length : undefined)) { return undefined; } + if (this.options.control) { return buf[buf.length - 1]; } return buf.shift(); + } + + // Fetches a packet from the port + get(scope, index) { + const res = this.getFromBuffer(scope, index); + if (res !== undefined) { return res; } + // Try to find an IIP instead + return this.getFromBuffer(null, index, true); + } + + hasIPinBuffer(scope, index, validate, initial = false) { + const buf = this.getBuffer(scope, index, initial); + if (!(buf != null ? buf.length : undefined)) { return false; } + for (let i = 0; i < buf.length; i += 1) { + if (validate(buf[i])) { return true; } + } + return false; + } + + hasIIP(index, validate) { + return this.hasIPinBuffer(null, index, validate, true); + } + + // Returns true if port contains packet(s) matching the validator + has(scope, index, validate) { + let valid = validate; + let idx = index; + if (!this.isAddressable()) { + valid = idx; + idx = null; + } + if (this.hasIPinBuffer(scope, idx, valid)) { return true; } + if (this.hasIIP(idx, valid)) { return true; } + return false; + } + + // Returns the number of data packets in an inport + length(scope, index) { + const buf = this.getBuffer(scope, index); + if (!buf) { return 0; } + return buf.length; + } + + // Tells if buffer has packets or not + ready(scope) { + return this.length(scope) > 0; + } + + // Clears inport buffers + clear() { + return this.prepareBuffer(); + } +}; diff --git a/src/lib/InternalSocket.coffee b/src/lib/InternalSocket.coffee deleted file mode 100644 index 7f63e58f3..000000000 --- a/src/lib/InternalSocket.coffee +++ /dev/null @@ -1,254 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2013-2017 Flowhub UG -# (c) 2011-2012 Henri Bergius, Nemein -# NoFlo may be freely distributed under the MIT license -{EventEmitter} = require 'events' -IP = require './IP' - -# ## Internal Sockets -# -# The default communications mechanism between NoFlo processes is -# an _internal socket_, which is responsible for accepting information -# packets sent from processes' outports, and emitting corresponding -# events so that the packets can be caught to the inport of the -# connected process. -class InternalSocket extends EventEmitter - regularEmitEvent: (event, data) -> - @emit event, data - - debugEmitEvent: (event, data) -> - try - @emit event, data - catch error - if error.id and error.metadata and error.error - # Wrapped debuggable error coming from downstream, no need to wrap - throw error.error if @listeners('error').length is 0 - @emit 'error', error - return - - throw error if @listeners('error').length is 0 - - @emit 'error', - id: @to.process.id - error: error - metadata: @metadata - - constructor: (metadata = {}) -> - super() - @metadata = metadata - @brackets = [] - @connected = false - @dataDelegate = null - @debug = false - @emitEvent = @regularEmitEvent - - # ## Socket connections - # - # Sockets that are attached to the ports of processes may be - # either connected or disconnected. The semantical meaning of - # a connection is that the outport is in the process of sending - # data. Disconnecting means an end of transmission. - # - # This can be used for example to signal the beginning and end - # of information packets resulting from the reading of a single - # file or a database query. - # - # Example, disconnecting when a file has been completely read: - # - # readBuffer: (fd, position, size, buffer) -> - # fs.read fd, buffer, 0, buffer.length, position, (err, bytes, buffer) => - # # Send data. The first send will also connect if not - # # already connected. - # @outPorts.out.send buffer.slice 0, bytes - # position += buffer.length - # - # # Disconnect when the file has been completely read - # return @outPorts.out.disconnect() if position >= size - # - # # Otherwise, call same method recursively - # @readBuffer fd, position, size, buffer - connect: -> - return if @connected - @connected = true - @emitEvent 'connect', null - - disconnect: -> - return unless @connected - @connected = false - @emitEvent 'disconnect', null - - isConnected: -> @connected - - # ## Sending information packets - # - # The _send_ method is used by a processe's outport to - # send information packets. The actual packet contents are - # not defined by NoFlo, and may be any valid JavaScript data - # structure. - # - # The packet contents however should be such that may be safely - # serialized or deserialized via JSON. This way the NoFlo networks - # can be constructed with more flexibility, as file buffers or - # message queues can be used as additional packet relay mechanisms. - send: (data) -> - data = @dataDelegate() if data is undefined and typeof @dataDelegate is 'function' - @handleSocketEvent 'data', data - - # ## Sending information packets without open bracket - # - # As _connect_ event is considered as open bracket, it needs to be followed - # by a _disconnect_ event or a closing bracket. In the new simplified - # sending semantics single IP objects can be sent without open/close brackets. - post: (ip, autoDisconnect = true) -> - ip = @dataDelegate() if ip is undefined and typeof @dataDelegate is 'function' - # Send legacy connect/disconnect if needed - if not @isConnected() and @brackets.length is 0 - do @connect - @handleSocketEvent 'ip', ip, false - if autoDisconnect and @isConnected() and @brackets.length is 0 - do @disconnect - - # ## Information Packet grouping - # - # Processes sending data to sockets may also group the packets - # when necessary. This allows transmitting tree structures as - # a stream of packets. - # - # For example, an object could be split into multiple packets - # where each property is identified by a separate grouping: - # - # # Group by object ID - # @outPorts.out.beginGroup object.id - # - # for property, value of object - # @outPorts.out.beginGroup property - # @outPorts.out.send value - # @outPorts.out.endGroup() - # - # @outPorts.out.endGroup() - # - # This would cause a tree structure to be sent to the receiving - # process as a stream of packets. So, an article object may be - # as packets like: - # - # * `/
/title/Lorem ipsum` - # * `/
/author/Henri Bergius` - # - # Components are free to ignore groupings, but are recommended - # to pass received groupings onward if the data structures remain - # intact through the component's processing. - beginGroup: (group) -> - @handleSocketEvent 'begingroup', group - - endGroup: -> - @handleSocketEvent 'endgroup' - - # ## Socket data delegation - # - # Sockets have the option to receive data from a delegate function - # should the `send` method receive undefined for `data`. This - # helps in the case of defaulting values. - setDataDelegate: (delegate) -> - unless typeof delegate is 'function' - throw Error 'A data delegate must be a function.' - @dataDelegate = delegate - - # ## Socket debug mode - # - # Sockets can catch exceptions happening in processes when data is - # sent to them. These errors can then be reported to the network for - # notification to the developer. - setDebug: (active) -> - @debug = active - @emitEvent = if @debug then @debugEmitEvent else @regularEmitEvent - - # ## Socket identifiers - # - # Socket identifiers are mainly used for debugging purposes. - # Typical identifiers look like _ReadFile:OUT -> Display:IN_, - # but for sockets sending initial information packets to - # components may also loom like _DATA -> ReadFile:SOURCE_. - getId: -> - fromStr = (from) -> - "#{from.process.id}() #{from.port.toUpperCase()}" - toStr = (to) -> - "#{to.port.toUpperCase()} #{to.process.id}()" - - return "UNDEFINED" unless @from or @to - return "#{fromStr(@from)} -> ANON" if @from and not @to - return "DATA -> #{toStr(@to)}" unless @from - "#{fromStr(@from)} -> #{toStr(@to)}" - - legacyToIp: (event, payload) -> - # No need to wrap modern IP Objects - return payload if IP.isIP payload - - # Wrap legacy events into appropriate IP objects - switch event - when 'begingroup' - return new IP 'openBracket', payload - when 'endgroup' - return new IP 'closeBracket' - when 'data' - return new IP 'data', payload - else - return null - - ipToLegacy: (ip) -> - switch ip.type - when 'openBracket' - return legacy = - event: 'begingroup' - payload: ip.data - when 'data' - return legacy = - event: 'data' - payload: ip.data - when 'closeBracket' - return legacy = - event: 'endgroup' - payload: ip.data - - handleSocketEvent: (event, payload, autoConnect = true) -> - isIP = event is 'ip' and IP.isIP payload - ip = if isIP then payload else @legacyToIp event, payload - return unless ip - - if not @isConnected() and autoConnect and @brackets.length is 0 - # Connect before sending - @connect() - - if event is 'begingroup' - @brackets.push payload - if isIP and ip.type is 'openBracket' - @brackets.push ip.data - - if event is 'endgroup' - # Prevent closing already closed groups - return if @brackets.length is 0 - # Add group name to bracket - ip.data = @brackets.pop() - payload = ip.data - if isIP and payload.type is 'closeBracket' - # Prevent closing already closed brackets - return if @brackets.length is 0 - @brackets.pop() - - # Emit the IP Object - @emitEvent 'ip', ip - - # Emit the legacy event - return unless ip and ip.type - - if isIP - legacy = @ipToLegacy ip - event = legacy.event - payload = legacy.payload - - @connected = true if event is 'connect' - @connected = false if event is 'disconnect' - @emitEvent event, payload - -exports.InternalSocket = InternalSocket - -exports.createSocket = -> new InternalSocket diff --git a/src/lib/InternalSocket.js b/src/lib/InternalSocket.js new file mode 100644 index 000000000..c234e2cb7 --- /dev/null +++ b/src/lib/InternalSocket.js @@ -0,0 +1,293 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2017 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license +const { EventEmitter } = require('events'); +const IP = require('./IP'); + +function legacyToIp(event, payload) { + // No need to wrap modern IP Objects + if (IP.isIP(payload)) { return payload; } + + // Wrap legacy events into appropriate IP objects + switch (event) { + case 'begingroup': + return new IP('openBracket', payload); + case 'endgroup': + return new IP('closeBracket'); + case 'data': + return new IP('data', payload); + default: + return null; + } +} + +function ipToLegacy(ip) { + switch (ip.type) { + case 'openBracket': + return { + event: 'begingroup', + payload: ip.data, + }; + case 'data': + return { + event: 'data', + payload: ip.data, + }; + case 'closeBracket': + return { + event: 'endgroup', + payload: ip.data, + }; + default: + return null; + } +} + +// ## Internal Sockets +// +// The default communications mechanism between NoFlo processes is +// an _internal socket_, which is responsible for accepting information +// packets sent from processes' outports, and emitting corresponding +// events so that the packets can be caught to the inport of the +// connected process. +class InternalSocket extends EventEmitter { + regularEmitEvent(event, data) { + this.emit(event, data); + } + + debugEmitEvent(event, data) { + try { + this.emit(event, data); + } catch (error) { + if (error.id && error.metadata && error.error) { + // Wrapped debuggable error coming from downstream, no need to wrap + if (this.listeners('error').length === 0) { throw error.error; } + this.emit('error', error); + return; + } + + if (this.listeners('error').length === 0) { throw error; } + + this.emit('error', { + id: this.to.process.id, + error, + metadata: this.metadata, + }); + } + } + + constructor(metadata = {}) { + super(); + this.metadata = metadata; + this.brackets = []; + this.connected = false; + this.dataDelegate = null; + this.debug = false; + this.emitEvent = this.regularEmitEvent; + } + + // ## Socket connections + // + // Sockets that are attached to the ports of processes may be + // either connected or disconnected. The semantical meaning of + // a connection is that the outport is in the process of sending + // data. Disconnecting means an end of transmission. + // + // This can be used for example to signal the beginning and end + // of information packets resulting from the reading of a single + // file or a database query. + // + // Example, disconnecting when a file has been completely read: + // + // readBuffer: (fd, position, size, buffer) -> + // fs.read fd, buffer, 0, buffer.length, position, (err, bytes, buffer) => + // # Send data. The first send will also connect if not + // # already connected. + // @outPorts.out.send buffer.slice 0, bytes + // position += buffer.length + // + // # Disconnect when the file has been completely read + // return @outPorts.out.disconnect() if position >= size + // + // # Otherwise, call same method recursively + // @readBuffer fd, position, size, buffer + connect() { + if (this.connected) { return; } + this.connected = true; + this.emitEvent('connect', null); + } + + disconnect() { + if (!this.connected) { return; } + this.connected = false; + this.emitEvent('disconnect', null); + } + + isConnected() { return this.connected; } + + // ## Sending information packets + // + // The _send_ method is used by a processe's outport to + // send information packets. The actual packet contents are + // not defined by NoFlo, and may be any valid JavaScript data + // structure. + // + // The packet contents however should be such that may be safely + // serialized or deserialized via JSON. This way the NoFlo networks + // can be constructed with more flexibility, as file buffers or + // message queues can be used as additional packet relay mechanisms. + send(data) { + if ((data === undefined) && (typeof this.dataDelegate === 'function')) { + this.handleSocketEvent('data', this.dataDelegate()); + return; + } + this.handleSocketEvent('data', data); + } + + // ## Sending information packets without open bracket + // + // As _connect_ event is considered as open bracket, it needs to be followed + // by a _disconnect_ event or a closing bracket. In the new simplified + // sending semantics single IP objects can be sent without open/close brackets. + post(packet, autoDisconnect = true) { + let ip = packet; + if ((ip === undefined) && (typeof this.dataDelegate === 'function')) { + ip = this.dataDelegate(); + } + // Send legacy connect/disconnect if needed + if (!this.isConnected() && (this.brackets.length === 0)) { + (this.connect)(); + } + this.handleSocketEvent('ip', ip, false); + if (autoDisconnect && this.isConnected() && (this.brackets.length === 0)) { + (this.disconnect)(); + } + } + + // ## Information Packet grouping + // + // Processes sending data to sockets may also group the packets + // when necessary. This allows transmitting tree structures as + // a stream of packets. + // + // For example, an object could be split into multiple packets + // where each property is identified by a separate grouping: + // + // # Group by object ID + // @outPorts.out.beginGroup object.id + // + // for property, value of object + // @outPorts.out.beginGroup property + // @outPorts.out.send value + // @outPorts.out.endGroup() + // + // @outPorts.out.endGroup() + // + // This would cause a tree structure to be sent to the receiving + // process as a stream of packets. So, an article object may be + // as packets like: + // + // * `/
/title/Lorem ipsum` + // * `/
/author/Henri Bergius` + // + // Components are free to ignore groupings, but are recommended + // to pass received groupings onward if the data structures remain + // intact through the component's processing. + beginGroup(group) { + this.handleSocketEvent('begingroup', group); + } + + endGroup() { + this.handleSocketEvent('endgroup'); + } + + // ## Socket data delegation + // + // Sockets have the option to receive data from a delegate function + // should the `send` method receive undefined for `data`. This + // helps in the case of defaulting values. + setDataDelegate(delegate) { + if (typeof delegate !== 'function') { + throw Error('A data delegate must be a function.'); + } + this.dataDelegate = delegate; + } + + // ## Socket debug mode + // + // Sockets can catch exceptions happening in processes when data is + // sent to them. These errors can then be reported to the network for + // notification to the developer. + setDebug(active) { + this.debug = active; + this.emitEvent = this.debug ? this.debugEmitEvent : this.regularEmitEvent; + } + + // ## Socket identifiers + // + // Socket identifiers are mainly used for debugging purposes. + // Typical identifiers look like _ReadFile:OUT -> Display:IN_, + // but for sockets sending initial information packets to + // components may also loom like _DATA -> ReadFile:SOURCE_. + getId() { + const fromStr = (from) => `${from.process.id}() ${from.port.toUpperCase()}`; + const toStr = (to) => `${to.port.toUpperCase()} ${to.process.id}()`; + + if (!this.from && !this.to) { return 'UNDEFINED'; } + if (this.from && !this.to) { return `${fromStr(this.from)} -> ANON`; } + if (!this.from) { return `DATA -> ${toStr(this.to)}`; } + return `${fromStr(this.from)} -> ${toStr(this.to)}`; + } + + /* eslint-disable no-param-reassign */ + handleSocketEvent(event, payload, autoConnect = true) { + const isIP = (event === 'ip') && IP.isIP(payload); + const ip = isIP ? payload : legacyToIp(event, payload); + if (!ip) { return; } + + if (!this.isConnected() && autoConnect && (this.brackets.length === 0)) { + // Connect before sending + this.connect(); + } + + if (event === 'begingroup') { + this.brackets.push(payload); + } + if (isIP && (ip.type === 'openBracket')) { + this.brackets.push(ip.data); + } + + if (event === 'endgroup') { + // Prevent closing already closed groups + if (this.brackets.length === 0) { return; } + // Add group name to bracket + ip.data = this.brackets.pop(); + payload = ip.data; + } + if (isIP && (payload.type === 'closeBracket')) { + // Prevent closing already closed brackets + if (this.brackets.length === 0) { return; } + this.brackets.pop(); + } + + // Emit the IP Object + this.emitEvent('ip', ip); + + // Emit the legacy event + if (!ip || !ip.type) { return; } + + if (isIP) { + const legacy = ipToLegacy(ip); + ({ event, payload } = legacy); + } + + if (event === 'connect') { this.connected = true; } + if (event === 'disconnect') { this.connected = false; } + this.emitEvent(event, payload); + } +} + +exports.InternalSocket = InternalSocket; + +exports.createSocket = () => new InternalSocket(); diff --git a/src/lib/LegacyNetwork.coffee b/src/lib/LegacyNetwork.coffee deleted file mode 100644 index 00b8ac624..000000000 --- a/src/lib/LegacyNetwork.coffee +++ /dev/null @@ -1,89 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2013-2018 Flowhub UG -# (c) 2011-2012 Henri Bergius, Nemein -# NoFlo may be freely distributed under the MIT license -BaseNetwork = require './BaseNetwork' -{ deprecated } = require './Platform' - -# ## The NoFlo network coordinator -# -# NoFlo networks consist of processes connected to each other -# via sockets attached from outports to inports. -# -# The role of the network coordinator is to take a graph and -# instantiate all the necessary processes from the designated -# components, attach sockets between them, and handle the sending -# of Initial Information Packets. -class LegacyNetwork extends BaseNetwork - # All NoFlo networks are instantiated with a graph. Upon instantiation - # they will load all the needed components, instantiate them, and - # set up the defined connections and IIPs. - # - # The legacy network will also listen to graph changes and modify itself - # accordingly, including removing connections, adding new nodes, - # and sending new IIPs. - constructor: (graph, options = {}) -> - deprecated 'noflo.Network construction is deprecated, use noflo.createNetwork' - super graph, options - - connect: (done = ->) -> - super (err) => - return done err if err - @subscribeGraph() - done() - - # A NoFlo graph may change after network initialization. - # For this, the legacy network subscribes to the change events - # from the graph. - # - # In graph we talk about nodes and edges. Nodes correspond - # to NoFlo processes, and edges to connections between them. - subscribeGraph: -> - graphOps = [] - processing = false - registerOp = (op, details) -> - graphOps.push - op: op - details: details - processOps = (err) => - if err - throw err if @listeners('process-error').length is 0 - @bufferedEmit 'process-error', err - - unless graphOps.length - processing = false - return - processing = true - op = graphOps.shift() - cb = processOps - switch op.op - when 'renameNode' - @renameNode op.details.from, op.details.to, cb - else - @[op.op] op.details, cb - - @graph.on 'addNode', (node) -> - registerOp 'addNode', node - do processOps unless processing - @graph.on 'removeNode', (node) -> - registerOp 'removeNode', node - do processOps unless processing - @graph.on 'renameNode', (oldId, newId) -> - registerOp 'renameNode', - from: oldId - to: newId - do processOps unless processing - @graph.on 'addEdge', (edge) -> - registerOp 'addEdge', edge - do processOps unless processing - @graph.on 'removeEdge', (edge) -> - registerOp 'removeEdge', edge - do processOps unless processing - @graph.on 'addInitial', (iip) -> - registerOp 'addInitial', iip - do processOps unless processing - @graph.on 'removeInitial', (iip) -> - registerOp 'removeInitial', iip - do processOps unless processing - -exports.Network = LegacyNetwork diff --git a/src/lib/LegacyNetwork.js b/src/lib/LegacyNetwork.js new file mode 100644 index 000000000..c751ba5c4 --- /dev/null +++ b/src/lib/LegacyNetwork.js @@ -0,0 +1,112 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2018 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license +const BaseNetwork = require('./BaseNetwork'); +const { deprecated } = require('./Platform'); + +// ## The NoFlo network coordinator +// +// NoFlo networks consist of processes connected to each other +// via sockets attached from outports to inports. +// +// The role of the network coordinator is to take a graph and +// instantiate all the necessary processes from the designated +// components, attach sockets between them, and handle the sending +// of Initial Information Packets. +class LegacyNetwork extends BaseNetwork { + // All NoFlo networks are instantiated with a graph. Upon instantiation + // they will load all the needed components, instantiate them, and + // set up the defined connections and IIPs. + // + // The legacy network will also listen to graph changes and modify itself + // accordingly, including removing connections, adding new nodes, + // and sending new IIPs. + constructor(graph, options = {}) { + deprecated('noflo.Network construction is deprecated, use noflo.createNetwork'); + super(graph, options); + } + + connect(done = () => {}) { + super.connect((err) => { + if (err) { + done(err); + return; + } + this.subscribeGraph(); + done(); + }); + } + + // A NoFlo graph may change after network initialization. + // For this, the legacy network subscribes to the change events + // from the graph. + // + // In graph we talk about nodes and edges. Nodes correspond + // to NoFlo processes, and edges to connections between them. + subscribeGraph() { + const graphOps = []; + let processing = false; + const registerOp = (op, details) => { + graphOps.push({ + op, + details, + }); + }; + const processOps = (err) => { + if (err) { + if (this.listeners('process-error').length === 0) { throw err; } + this.bufferedEmit('process-error', err); + } + + if (!graphOps.length) { + processing = false; + return; + } + processing = true; + const op = graphOps.shift(); + const cb = processOps; + switch (op.op) { + case 'renameNode': + this.renameNode(op.details.from, op.details.to, cb); + break; + default: + this[op.op](op.details, cb); + } + }; + + this.graph.on('addNode', (node) => { + registerOp('addNode', node); + if (!processing) { processOps(); } + }); + this.graph.on('removeNode', (node) => { + registerOp('removeNode', node); + if (!processing) { processOps(); } + }); + this.graph.on('renameNode', (oldId, newId) => { + registerOp('renameNode', { + from: oldId, + to: newId, + }); + if (!processing) { processOps(); } + }); + this.graph.on('addEdge', (edge) => { + registerOp('addEdge', edge); + if (!processing) { processOps(); } + }); + this.graph.on('removeEdge', (edge) => { + registerOp('removeEdge', edge); + if (!processing) { processOps(); } + }); + this.graph.on('addInitial', (iip) => { + registerOp('addInitial', iip); + if (!processing) { processOps(); } + }); + return this.graph.on('removeInitial', (iip) => { + registerOp('removeInitial', iip); + if (!processing) { processOps(); } + }); + } +} + +exports.Network = LegacyNetwork; diff --git a/src/lib/Network.coffee b/src/lib/Network.coffee deleted file mode 100644 index fb996ba02..000000000 --- a/src/lib/Network.coffee +++ /dev/null @@ -1,91 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2013-2018 Flowhub UG -# (c) 2011-2012 Henri Bergius, Nemein -# NoFlo may be freely distributed under the MIT license -BaseNetwork = require './BaseNetwork' - -# ## The NoFlo network coordinator -# -# NoFlo networks consist of processes connected to each other -# via sockets attached from outports to inports. -# -# The role of the network coordinator is to take a graph and -# instantiate all the necessary processes from the designated -# components, attach sockets between them, and handle the sending -# of Initial Information Packets. -class Network extends BaseNetwork - # All NoFlo networks are instantiated with a graph. Upon instantiation - # they will load all the needed components, instantiate them, and - # set up the defined connections and IIPs. - constructor: (graph, options = {}) -> - super graph, options - - # Add a process to the network. The node will also be registered - # with the current graph. - addNode: (node, options, callback) -> - if typeof options is 'function' - callback = options - options = {} - super node, options, (err, process) => - return callback err if err - unless options.initial - @graph.addNode node.id, node.component, node.metadata - callback null, process - - # Remove a process from the network. The node will also be removed - # from the current graph. - removeNode: (node, callback) -> - super node, (err) => - return callback err if err - @graph.removeNode node.id - callback() - - # Rename a process in the network. Renaming a process also modifies - # the current graph. - renameNode: (oldId, newId, callback) -> - super oldId, newId, (err) => - return callback err if err - @graph.renameNode oldId, newId - callback() - - # Add a connection to the network. The edge will also be registered - # with the current graph. - addEdge: (edge, options, callback) -> - if typeof options is 'function' - callback = options - options = {} - super edge, options, (err) => - return callback err if err - unless options.initial - @graph.addEdgeIndex edge.from.node, edge.from.port, edge.from.index, edge.to.node, edge.to.port, edge.to.index, edge.metadata - callback() - - # Remove a connection from the network. The edge will also be removed - # from the current graph. - removeEdge: (edge, callback) -> - super edge, (err) => - return callback err if err - @graph.removeEdge edge.from.node, edge.from.port, edge.to.node, edge.to.port - callback() - - # Add an IIP to the network. The IIP will also be registered with the - # current graph. If the network is running, the IIP will be sent immediately. - addInitial: (iip, options, callback) -> - if typeof options is 'function' - callback = options - options = {} - super iip, options, (err) => - return callback err if err - unless options.initial - @graph.addInitialIndex iip.from.data, iip.to.node, iip.to.port, iip.to.index, iip.metadata - callback() - - # Remove an IIP from the network. The IIP will also be removed from the - # current graph. - removeInitial: (iip, callback) -> - super iip, (err) => - return callback err if err - @graph.removeInitial iip.to.node, iip.to.port - callback() - -exports.Network = Network diff --git a/src/lib/Network.js b/src/lib/Network.js new file mode 100644 index 000000000..6bd546539 --- /dev/null +++ b/src/lib/Network.js @@ -0,0 +1,152 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2018 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license +const BaseNetwork = require('./BaseNetwork'); + +/* eslint-disable + no-param-reassign, +*/ + +// ## The NoFlo network coordinator +// +// NoFlo networks consist of processes connected to each other +// via sockets attached from outports to inports. +// +// The role of the network coordinator is to take a graph and +// instantiate all the necessary processes from the designated +// components, attach sockets between them, and handle the sending +// of Initial Information Packets. +class Network extends BaseNetwork { + // All NoFlo networks are instantiated with a graph. Upon instantiation + // they will load all the needed components, instantiate them, and + // set up the defined connections and IIPs. + constructor(graph, options = {}) { + super(graph, options); + } + + // Add a process to the network. The node will also be registered + // with the current graph. + addNode(node, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + super.addNode(node, options, (err, process) => { + if (err) { + callback(err); + return; + } + if (!options.initial) { + this.graph.addNode(node.id, node.component, node.metadata); + } + callback(null, process); + }); + } + + // Remove a process from the network. The node will also be removed + // from the current graph. + removeNode(node, callback) { + super.removeNode(node, (err) => { + if (err) { + callback(err); + return; + } + this.graph.removeNode(node.id); + callback(); + }); + } + + // Rename a process in the network. Renaming a process also modifies + // the current graph. + renameNode(oldId, newId, callback) { + super.renameNode(oldId, newId, (err) => { + if (err) { + callback(err); + return; + } + this.graph.renameNode(oldId, newId); + callback(); + }); + } + + // Add a connection to the network. The edge will also be registered + // with the current graph. + addEdge(edge, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + super.addEdge(edge, options, (err) => { + if (err) { + callback(err); + return; + } + if (!options.initial) { + this.graph.addEdgeIndex( + edge.from.node, + edge.from.port, + edge.from.index, + edge.to.node, + edge.to.port, + edge.to.index, + edge.metadata, + ); + } + callback(); + }); + } + + // Remove a connection from the network. The edge will also be removed + // from the current graph. + removeEdge(edge, callback) { + super.removeEdge(edge, (err) => { + if (err) { + callback(err); + return; + } + this.graph.removeEdge(edge.from.node, edge.from.port, edge.to.node, edge.to.port); + callback(); + }); + } + + // Add an IIP to the network. The IIP will also be registered with the + // current graph. If the network is running, the IIP will be sent immediately. + addInitial(iip, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + super.addInitial(iip, options, (err) => { + if (err) { + callback(err); + return; + } + if (!options.initial) { + this.graph.addInitialIndex( + iip.from.data, + iip.to.node, + iip.to.port, + iip.to.index, + iip.metadata, + ); + } + callback(); + }); + } + + // Remove an IIP from the network. The IIP will also be removed from the + // current graph. + removeInitial(iip, callback) { + super.removeInitial(iip, (err) => { + if (err) { + callback(err); + return; + } + this.graph.removeInitial(iip.to.node, iip.to.port); + callback(); + }); + } +} + +exports.Network = Network; diff --git a/src/lib/NoFlo.coffee b/src/lib/NoFlo.coffee deleted file mode 100644 index 54881cbfc..000000000 --- a/src/lib/NoFlo.coffee +++ /dev/null @@ -1,226 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2013-2018 Flowhub UG -# (c) 2011-2012 Henri Bergius, Nemein -# NoFlo may be freely distributed under the MIT license -# -# NoFlo is a Flow-Based Programming environment for JavaScript. This file provides the -# main entry point to the NoFlo network. -# -# Find out more about using NoFlo from -# -# ## Main APIs -# -# ### Graph interface -# -# [fbp-graph](https://github.com/flowbased/fbp-graph) is used for instantiating FBP graph definitions. -fbpGraph = require 'fbp-graph' -exports.graph = fbpGraph.graph -exports.Graph = fbpGraph.Graph - -# ### Graph journal -# -# Journal is used for keeping track of graph changes -exports.journal = fbpGraph.journal -exports.Journal = fbpGraph.Journal - -# ## Network interface -# -# [Network](../Network/) is used for running NoFlo graphs. The direct Network inteface is only provided -# for backwards compatibility purposes. Use `createNetwork` instead. -Network = require('./Network').Network -exports.Network = require('./LegacyNetwork').Network -{ deprecated } = require './Platform' - -# ### Platform detection -# -# NoFlo works on both Node.js and the browser. Because some dependencies are different, -# we need a way to detect which we're on. -exports.isBrowser = require('./Platform').isBrowser - -# ### Component Loader -# -# The [ComponentLoader](../ComponentLoader/) is responsible for finding and loading -# NoFlo components. Component Loader uses [fbp-manifest](https://github.com/flowbased/fbp-manifest) -# to find components and graphs by traversing the NPM dependency tree from a given root -# directory on the file system. -exports.ComponentLoader = require('./ComponentLoader').ComponentLoader - -# ### Component baseclasses -# -# These baseclasses can be used for defining NoFlo components. -exports.Component = require('./Component').Component - -# ### Component helpers -# -# These helpers aid in providing specific behavior in components with minimal overhead. -exports.helpers = require './Helpers' - -# ### NoFlo ports -# -# These classes are used for instantiating ports on NoFlo components. -ports = require './Ports' -exports.InPorts = ports.InPorts -exports.OutPorts = ports.OutPorts -exports.InPort = require './InPort' -exports.OutPort = require './OutPort' - -# ### NoFlo sockets -# -# The NoFlo [internalSocket](InternalSocket.html) is used for connecting ports of -# different components together in a network. -exports.internalSocket = require('./InternalSocket') - -# ### Information Packets -# -# NoFlo Information Packets are defined as "IP" objects. -exports.IP = require './IP' - -# ## Network instantiation -# -# This function handles instantiation of NoFlo networks from a Graph object. It creates -# the network, and then starts execution by sending the Initial Information Packets. -# -# noflo.createNetwork(someGraph, function (err, network) { -# console.log('Network is now running!'); -# }); -# -# It is also possible to instantiate a Network but delay its execution by giving the -# third `delay` parameter. In this case you will have to handle connecting the graph and -# sending of IIPs manually. -# -# noflo.createNetwork(someGraph, function (err, network) { -# if (err) { -# throw err; -# } -# network.connect(function (err) { -# network.start(); -# console.log('Network is now running!'); -# }) -# }, true); -# -# ### Network options -# -# It is possible to pass some options to control the behavior of network creation: -# -# * `delay`: (default: FALSE) Whether the network should be started later. Defaults to immediate execution -# * `subscribeGraph`: (default: TRUE) Whether the network should monitor the underlying graph for changes -# -# Options can be passed as a second argument before the callback: -# -# noflo.createNetwork(someGraph, options, callback); -# -# The options object can also be used for setting ComponentLoader options in this -# network. -exports.createNetwork = (graph, options, callback) -> - if typeof options is 'function' - opts = callback - callback = options - options = opts - if typeof options is 'boolean' - options = - delay: options - unless typeof options is 'object' - options = {} - if typeof options.subscribeGraph is 'undefined' - # Default to legacy network for backwards compatibility. - options.subscribeGraph = true - unless typeof callback is 'function' - deprecated 'Calling noflo.createNetwork without a callback is deprecated' - callback = (err) -> - throw err if err - - # Choose legacy or modern network based on whether graph - # subscription is needed - NetworkType = if options.subscribeGraph then exports.Network else Network - network = new NetworkType graph, options - - networkReady = (network) -> - # Send IIPs - network.start (err) -> - return callback err if err - callback null, network - - # Ensure components are loaded before continuing - network.loader.listComponents (err) -> - return callback err if err - - # In case of delayed execution we don't wire it up - if options.delay - callback null, network - return - - # Empty network, no need to connect it up - return networkReady network if graph.nodes.length is 0 - - # Wire the network up and start execution - network.connect (err) -> - return callback err if err - networkReady network - - network - -# ### Starting a network from a file -# -# It is also possible to start a NoFlo network by giving it a path to a `.json` or `.fbp` network -# definition file. -# -# noflo.loadFile('somefile.json', function (err, network) { -# if (err) { -# throw err; -# } -# console.log('Network is now running!'); -# }) -exports.loadFile = (file, options, callback) -> - unless callback - callback = options - baseDir = null - - if callback and typeof options isnt 'object' - options = - baseDir: options - if typeof options isnt 'object' - options = {} - unless options.subscribeGraph - options.subscribeGraph = false - - exports.graph.loadFile file, (err, net) -> - return callback err if err - net.baseDir = options.baseDir if options.baseDir - exports.createNetwork net, options, callback - -# ### Saving a network definition -# -# NoFlo graph files can be saved back into the filesystem with this method. -exports.saveFile = (graph, file, callback) -> - graph.save file, callback - -# ## Embedding NoFlo in existing JavaScript code -# -# The `asCallback` helper provides an interface to wrap NoFlo components -# or graphs into existing JavaScript code. -# -# // Produce an asynchronous function wrapping a NoFlo graph -# var wrapped = noflo.asCallback('myproject/MyGraph'); -# -# // Call the function, providing input data and a callback for output data -# wrapped({ -# in: 'data' -# }, function (err, results) { -# // Do something with results -# }); -# -exports.asCallback = require('./AsCallback').asCallback - -# ## Generating components from JavaScript functions -# -# The `asComponent` helper makes it easy to expose a JavaScript function as a -# NoFlo component. All input arguments become input ports, and the function's -# result will be sent to either `out` or `error` port. -# -# exports.getComponent = function () { -# return noflo.asComponent(Math.random, { -# description: 'Generate a random number', -# }); -# }; -# -exports.asComponent = require('./AsComponent').asComponent diff --git a/src/lib/NoFlo.js b/src/lib/NoFlo.js new file mode 100644 index 000000000..e9b3b3339 --- /dev/null +++ b/src/lib/NoFlo.js @@ -0,0 +1,261 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2018 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license +// +// NoFlo is a Flow-Based Programming environment for JavaScript. This file provides the +// main entry point to the NoFlo network. +// +// Find out more about using NoFlo from + +/* eslint-disable + no-param-reassign, +*/ + +// ## Main APIs +// +// ### Graph interface +// +// [fbp-graph](https://github.com/flowbased/fbp-graph) is used for instantiating FBP graph definitions. +const fbpGraph = require('fbp-graph'); + +exports.graph = fbpGraph.graph; +exports.Graph = fbpGraph.Graph; + +// ### Graph journal +// +// Journal is used for keeping track of graph changes +exports.journal = fbpGraph.journal; +exports.Journal = fbpGraph.Journal; + +// ## Network interface +// +// [Network](../Network/) is used for running NoFlo graphs. The direct Network inteface is only +// provided for backwards compatibility purposes. Use `createNetwork` instead. +const { + Network, +} = require('./Network'); +exports.Network = require('./LegacyNetwork').Network; +const { deprecated } = require('./Platform'); + +// ### Platform detection +// +// NoFlo works on both Node.js and the browser. Because some dependencies are different, +// we need a way to detect which we're on. +exports.isBrowser = require('./Platform').isBrowser; + +// ### Component Loader +// +// The [ComponentLoader](../ComponentLoader/) is responsible for finding and loading +// NoFlo components. Component Loader uses [fbp-manifest](https://github.com/flowbased/fbp-manifest) +// to find components and graphs by traversing the NPM dependency tree from a given root +// directory on the file system. +exports.ComponentLoader = require('./ComponentLoader').ComponentLoader; + +// ### Component baseclasses +// +// These baseclasses can be used for defining NoFlo components. +exports.Component = require('./Component').Component; + +// ### NoFlo ports +// +// These classes are used for instantiating ports on NoFlo components. +const ports = require('./Ports'); + +exports.InPorts = ports.InPorts; +exports.OutPorts = ports.OutPorts; +exports.InPort = require('./InPort'); +exports.OutPort = require('./OutPort'); + +// ### NoFlo sockets +// +// The NoFlo [internalSocket](InternalSocket.html) is used for connecting ports of +// different components together in a network. +exports.internalSocket = require('./InternalSocket'); + +// ### Information Packets +// +// NoFlo Information Packets are defined as "IP" objects. +exports.IP = require('./IP'); + +// ## Network instantiation +// +// This function handles instantiation of NoFlo networks from a Graph object. It creates +// the network, and then starts execution by sending the Initial Information Packets. +// +// noflo.createNetwork(someGraph, function (err, network) { +// console.log('Network is now running!'); +// }); +// +// It is also possible to instantiate a Network but delay its execution by giving the +// third `delay` parameter. In this case you will have to handle connecting the graph and +// sending of IIPs manually. +// +// noflo.createNetwork(someGraph, function (err, network) { +// if (err) { +// throw err; +// } +// network.connect(function (err) { +// network.start(); +// console.log('Network is now running!'); +// }) +// }, true); +// +// ### Network options +// +// It is possible to pass some options to control the behavior of network creation: +// +// * `delay`: (default: FALSE) Whether the network should be started later. Defaults to +// immediate execution +// * `subscribeGraph`: (default: TRUE) Whether the network should monitor the underlying +// graph for changes +// +// Options can be passed as a second argument before the callback: +// +// noflo.createNetwork(someGraph, options, callback); +// +// The options object can also be used for setting ComponentLoader options in this +// network. +exports.createNetwork = function createNetwork(graph, options, callback) { + if (typeof options === 'function') { + const opts = callback; + callback = options; + options = opts; + } + if (typeof options === 'boolean') { + options = { delay: options }; + } + if (typeof options !== 'object') { + options = {}; + } + if (typeof options.subscribeGraph === 'undefined') { + // Default to legacy network for backwards compatibility. + options.subscribeGraph = true; + } + if (typeof callback !== 'function') { + deprecated('Calling noflo.createNetwork without a callback is deprecated'); + callback = (err) => { + if (err) { throw err; } + }; + } + + // Choose legacy or modern network based on whether graph + // subscription is needed + const NetworkType = options.subscribeGraph ? exports.Network : Network; + const network = new NetworkType(graph, options); + + const networkReady = (net) => { // Send IIPs + net.start((err) => { + if (err) { + callback(err); + return; + } + callback(null, net); + }); + }; + + // Ensure components are loaded before continuing + network.loader.listComponents((err) => { + if (err) { + callback(err); + return; + } + + // In case of delayed execution we don't wire it up + if (options.delay) { + callback(null, network); + return; + } + + // Empty network, no need to connect it up + if (graph.nodes.length === 0) { + networkReady(network); + return; + } + + // Wire the network up and start execution + network.connect((err2) => { + if (err2) { + callback(err2); + return; + } + networkReady(network); + }); + }); + return network; +}; + +// ### Starting a network from a file +// +// It is also possible to start a NoFlo network by giving it a path to a `.json` or `.fbp` network +// definition file. +// +// noflo.loadFile('somefile.json', function (err, network) { +// if (err) { +// throw err; +// } +// console.log('Network is now running!'); +// }) +exports.loadFile = function loadFile(file, options, callback) { + if (!callback) { + callback = options; + options = null; + } + + if (callback && (typeof options !== 'object')) { + options = { baseDir: options }; + } + if (typeof options !== 'object') { + options = {}; + } + if (!options.subscribeGraph) { + options.subscribeGraph = false; + } + + exports.graph.loadFile(file, (err, net) => { + if (err) { + callback(err); + return; + } + if (options.baseDir) { net.baseDir = options.baseDir; } + exports.createNetwork(net, options, callback); + }); +}; + +// ### Saving a network definition +// +// NoFlo graph files can be saved back into the filesystem with this method. +exports.saveFile = function saveFile(graph, file, callback) { + graph.save(file, callback); +}; + +// ## Embedding NoFlo in existing JavaScript code +// +// The `asCallback` helper provides an interface to wrap NoFlo components +// or graphs into existing JavaScript code. +// +// // Produce an asynchronous function wrapping a NoFlo graph +// var wrapped = noflo.asCallback('myproject/MyGraph'); +// +// // Call the function, providing input data and a callback for output data +// wrapped({ +// in: 'data' +// }, function (err, results) { +// // Do something with results +// }); +// +exports.asCallback = require('./AsCallback').asCallback; + +// ## Generating components from JavaScript functions +// +// The `asComponent` helper makes it easy to expose a JavaScript function as a +// NoFlo component. All input arguments become input ports, and the function's +// result will be sent to either `out` or `error` port. +// +// exports.getComponent = function () { +// return noflo.asComponent(Math.random, { +// description: 'Generate a random number', +// }); +// }; +// +exports.asComponent = require('./AsComponent').asComponent; diff --git a/src/lib/OutPort.coffee b/src/lib/OutPort.coffee deleted file mode 100644 index 9fc894372..000000000 --- a/src/lib/OutPort.coffee +++ /dev/null @@ -1,114 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2014-2017 Flowhub UG -# NoFlo may be freely distributed under the MIT license -BasePort = require './BasePort' -IP = require './IP' - -# ## NoFlo outport -# -# Outport Port (outport) implementation for NoFlo components. -# These ports are the way a component sends Information Packets. -class OutPort extends BasePort - constructor: (options = {}) -> - options.scoped ?= true - super options - @cache = {} - - attach: (socket, index = null) -> - super socket, index - if @isCaching() and @cache[index]? - @send @cache[index], index - - connect: (socketId = null) -> - sockets = @getSockets socketId - @checkRequired sockets - for socket in sockets - continue unless socket - socket.connect() - - beginGroup: (group, socketId = null) -> - sockets = @getSockets socketId - @checkRequired sockets - sockets.forEach (socket) -> - return unless socket - return socket.beginGroup group - - send: (data, socketId = null) -> - sockets = @getSockets socketId - @checkRequired sockets - if @isCaching() and data isnt @cache[socketId] - @cache[socketId] = data - sockets.forEach (socket) -> - return unless socket - return socket.send data - - endGroup: (socketId = null) -> - sockets = @getSockets socketId - @checkRequired sockets - for socket in sockets - continue unless socket - socket.endGroup() - - disconnect: (socketId = null) -> - sockets = @getSockets socketId - @checkRequired sockets - for socket in sockets - continue unless socket - socket.disconnect() - - sendIP: (type, data, options, socketId, autoConnect = true) -> - if IP.isIP type - ip = type - socketId = ip.index - else - ip = new IP type, data, options - sockets = @getSockets socketId - @checkRequired sockets - - if ip.datatype is 'all' - # Stamp non-specific IP objects with port datatype - ip.datatype = @getDataType() - if @getSchema() and not ip.schema - # Stamp non-specific IP objects with port schema - ip.schema = @getSchema() - - if @isCaching() and data isnt @cache[socketId]?.data - @cache[socketId] = ip - pristine = true - for socket in sockets - continue unless socket - if pristine - socket.post ip, autoConnect - pristine = false - else - ip = ip.clone() if ip.clonable - socket.post ip, autoConnect - @ - - openBracket: (data = null, options = {}, socketId = null) -> - @sendIP 'openBracket', data, options, socketId - - data: (data, options = {}, socketId = null) -> - @sendIP 'data', data, options, socketId - - closeBracket: (data = null, options = {}, socketId = null) -> - @sendIP 'closeBracket', data, options, socketId - - checkRequired: (sockets) -> - if sockets.length is 0 and @isRequired() - throw new Error "#{@getId()}: No connections available" - - getSockets: (socketId) -> - # Addressable sockets affect only one connection at time - if @isAddressable() - throw new Error "#{@getId()} Socket ID required" if socketId is null - return [] unless @sockets[socketId] - return [@sockets[socketId]] - # Regular sockets affect all outbound connections - @sockets - - isCaching: -> - return true if @options.caching - false - -module.exports = OutPort diff --git a/src/lib/OutPort.js b/src/lib/OutPort.js new file mode 100644 index 000000000..0a7050faf --- /dev/null +++ b/src/lib/OutPort.js @@ -0,0 +1,146 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2014-2017 Flowhub UG +// NoFlo may be freely distributed under the MIT license +const BasePort = require('./BasePort'); +const IP = require('./IP'); + +// ## NoFlo outport +// +// Outport Port (outport) implementation for NoFlo components. +// These ports are the way a component sends Information Packets. +module.exports = class OutPort extends BasePort { + constructor(options = {}) { + const opts = options; + if (opts.scoped == null) { opts.scoped = true; } + super(opts); + this.cache = {}; + } + + attach(socket, index = null) { + super.attach(socket, index); + if (this.isCaching() && (this.cache[index] != null)) { + this.send(this.cache[index], index); + } + } + + connect(index = null) { + const sockets = this.getSockets(index); + this.checkRequired(sockets); + sockets.forEach((socket) => { + if (!socket) { return; } + socket.connect(); + }); + } + + beginGroup(group, index = null) { + const sockets = this.getSockets(index); + this.checkRequired(sockets); + sockets.forEach((socket) => { + if (!socket) { return; } + socket.beginGroup(group); + }); + } + + send(data, index = null) { + const sockets = this.getSockets(index); + this.checkRequired(sockets); + if (this.isCaching() && (data !== this.cache[index])) { + this.cache[index] = data; + } + sockets.forEach((socket) => { + if (!socket) { return; } + socket.send(data); + }); + } + + endGroup(index = null) { + const sockets = this.getSockets(index); + this.checkRequired(sockets); + sockets.forEach((socket) => { + if (!socket) { return; } + socket.endGroup(); + }); + } + + disconnect(index = null) { + const sockets = this.getSockets(index); + this.checkRequired(sockets); + sockets.forEach((socket) => { + if (!socket) { return; } + socket.disconnect(); + }); + } + + sendIP(type, data, options, index, autoConnect = true) { + let ip; + let idx = index; + if (IP.isIP(type)) { + ip = type; + idx = ip.index; + } else { + ip = new IP(type, data, options); + } + const sockets = this.getSockets(idx); + this.checkRequired(sockets); + + if (ip.datatype === 'all') { + // Stamp non-specific IP objects with port datatype + ip.datatype = this.getDataType(); + } + if (this.getSchema() && !ip.schema) { + // Stamp non-specific IP objects with port schema + ip.schema = this.getSchema(); + } + + const cachedData = this.cache[idx] != null ? this.cache[idx].data : undefined; + if (this.isCaching() && data !== cachedData) { + this.cache[idx] = ip; + } + let pristine = true; + sockets.forEach((socket) => { + if (!socket) { return; } + if (pristine) { + socket.post(ip, autoConnect); + pristine = false; + } else { + if (ip.clonable) { ip = ip.clone(); } + socket.post(ip, autoConnect); + } + }); + return this; + } + + openBracket(data = null, options = {}, index = null) { + return this.sendIP('openBracket', data, options, index); + } + + data(data, options = {}, index = null) { + return this.sendIP('data', data, options, index); + } + + closeBracket(data = null, options = {}, index = null) { + return this.sendIP('closeBracket', data, options, index); + } + + checkRequired(sockets) { + if ((sockets.length === 0) && this.isRequired()) { + throw new Error(`${this.getId()}: No connections available`); + } + } + + getSockets(index) { + // Addressable sockets affect only one connection at time + if (this.isAddressable()) { + if (index === null) { throw new Error(`${this.getId()} Socket ID required`); } + if (!this.sockets[index]) { return []; } + return [this.sockets[index]]; + } + // Regular sockets affect all outbound connections + return this.sockets; + } + + isCaching() { + if (this.options.caching) { return true; } + return false; + } +}; diff --git a/src/lib/Platform.coffee b/src/lib/Platform.coffee deleted file mode 100644 index 481e05be4..000000000 --- a/src/lib/Platform.coffee +++ /dev/null @@ -1,21 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2014-2017 Flowhub UG -# NoFlo may be freely distributed under the MIT license -# -# Platform detection method -exports.isBrowser = -> - if typeof process isnt 'undefined' and process.execPath and process.execPath.match /node|iojs/ - return false - true - -# Mechanism for showing API deprecation warnings. By default logs the warnings -# but can also be configured to throw instead with the `NOFLO_FATAL_DEPRECATED` -# env var. -exports.deprecated = (message) -> - if exports.isBrowser() - throw new Error message if window.NOFLO_FATAL_DEPRECATED - console.warn message - return - if process.env.NOFLO_FATAL_DEPRECATED - throw new Error message - console.warn message diff --git a/src/lib/Platform.js b/src/lib/Platform.js new file mode 100644 index 000000000..19bab27e2 --- /dev/null +++ b/src/lib/Platform.js @@ -0,0 +1,32 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2014-2017 Flowhub UG +// NoFlo may be freely distributed under the MIT license +// + +/* eslint-disable + no-console, + no-undef, +*/ + +// Platform detection method +exports.isBrowser = function isBrowser() { + if ((typeof process !== 'undefined') && process.execPath && process.execPath.match(/node|iojs/)) { + return false; + } + return true; +}; + +// Mechanism for showing API deprecation warnings. By default logs the warnings +// but can also be configured to throw instead with the `NOFLO_FATAL_DEPRECATED` +// env var. +exports.deprecated = function deprecated(message) { + if (exports.isBrowser()) { + if (window.NOFLO_FATAL_DEPRECATED) { throw new Error(message); } + console.warn(message); + return; + } + if (process.env.NOFLO_FATAL_DEPRECATED) { + throw new Error(message); + } + console.warn(message); +}; diff --git a/src/lib/Ports.coffee b/src/lib/Ports.coffee deleted file mode 100644 index 683ef767f..000000000 --- a/src/lib/Ports.coffee +++ /dev/null @@ -1,90 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2014-2017 Flowhub UG -# NoFlo may be freely distributed under the MIT license -{EventEmitter} = require 'events' -InPort = require './InPort' -OutPort = require './OutPort' - -# NoFlo ports collections -# -# Ports collection classes for NoFlo components. These are -# used to hold a set of input or output ports of a component. -class Ports extends EventEmitter - model: InPort - constructor: (ports) -> - super() - @ports = {} - return unless ports - for name, options of ports - @add name, options - - add: (name, options, process) -> - if name is 'add' or name is 'remove' - throw new Error 'Add and remove are restricted port names' - - unless name.match /^[a-z0-9_\.\/]+$/ - throw new Error "Port names can only contain lowercase alphanumeric characters and underscores. '#{name}' not allowed" - - # Remove previous implementation - @remove name if @ports[name] - - if typeof options is 'object' and options.canAttach - @ports[name] = options - else - @ports[name] = new @model options, process - - @[name] = @ports[name] - - @emit 'add', name - - @ # chainable - - remove: (name) -> - throw new Error "Port #{name} not defined" unless @ports[name] - delete @ports[name] - delete @[name] - @emit 'remove', name - - @ # chainable - -exports.InPorts = class InPorts extends Ports - on: (name, event, callback) -> - throw new Error "Port #{name} not available" unless @ports[name] - @ports[name].on event, callback - once: (name, event, callback) -> - throw new Error "Port #{name} not available" unless @ports[name] - @ports[name].once event, callback - -exports.OutPorts = class OutPorts extends Ports - model: OutPort - - connect: (name, socketId) -> - throw new Error "Port #{name} not available" unless @ports[name] - @ports[name].connect socketId - beginGroup: (name, group, socketId) -> - throw new Error "Port #{name} not available" unless @ports[name] - @ports[name].beginGroup group, socketId - send: (name, data, socketId) -> - throw new Error "Port #{name} not available" unless @ports[name] - @ports[name].send data, socketId - endGroup: (name, socketId) -> - throw new Error "Port #{name} not available" unless @ports[name] - @ports[name].endGroup socketId - disconnect: (name, socketId) -> - throw new Error "Port #{name} not available" unless @ports[name] - @ports[name].disconnect socketId - -# Port name normalization: -# returns object containing keys name and index for ports names in -# format `portname` or `portname[index]`. -exports.normalizePortName = (name) -> - port = - name: name - # Regular port - return port if name.indexOf('[') is -1 - # Addressable port with index - matched = name.match /(.*)\[([0-9]+)\]/ - return name unless matched?.length - port.name = matched[1] - port.index = matched[2] - return port diff --git a/src/lib/Ports.js b/src/lib/Ports.js new file mode 100644 index 000000000..55dcd5c37 --- /dev/null +++ b/src/lib/Ports.js @@ -0,0 +1,123 @@ +/* eslint-disable max-classes-per-file */ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2014-2017 Flowhub UG +// NoFlo may be freely distributed under the MIT license +const { EventEmitter } = require('events'); +const InPort = require('./InPort'); +const OutPort = require('./OutPort'); + +// NoFlo ports collections +// +// Ports collection classes for NoFlo components. These are +// used to hold a set of input or output ports of a component. +class Ports extends EventEmitter { + constructor(ports, model) { + super(); + this.model = model; + this.ports = {}; + if (!ports) { return; } + Object.keys(ports).forEach((name) => { + const options = ports[name]; + this.add(name, options); + }); + } + + add(name, options, process) { + if ((name === 'add') || (name === 'remove')) { + throw new Error('Add and remove are restricted port names'); + } + + /* eslint-disable no-useless-escape */ + if (!name.match(/^[a-z0-9_\.\/]+$/)) { + throw new Error(`Port names can only contain lowercase alphanumeric characters and underscores. '${name}' not allowed`); + } + + // Remove previous implementation + if (this.ports[name]) { this.remove(name); } + + if ((typeof options === 'object') && options.canAttach) { + this.ports[name] = options; + } else { + const Model = this.model; + this.ports[name] = new Model(options, process); + } + + this[name] = this.ports[name]; + + this.emit('add', name); + + return this; // chainable + } + + remove(name) { + if (!this.ports[name]) { throw new Error(`Port ${name} not defined`); } + delete this.ports[name]; + delete this[name]; + this.emit('remove', name); + + return this; // chainable + } +} + +exports.InPorts = class InPorts extends Ports { + constructor(ports) { + super(ports, InPort); + } + + on(name, event, callback) { + if (!this.ports[name]) { throw new Error(`Port ${name} not available`); } + this.ports[name].on(event, callback); + } + + once(name, event, callback) { + if (!this.ports[name]) { throw new Error(`Port ${name} not available`); } + this.ports[name].once(event, callback); + } +}; + +exports.OutPorts = class OutPorts extends Ports { + constructor(ports) { + super(ports, OutPort); + } + + connect(name, socketId) { + if (!this.ports[name]) { throw new Error(`Port ${name} not available`); } + this.ports[name].connect(socketId); + } + + beginGroup(name, group, socketId) { + if (!this.ports[name]) { throw new Error(`Port ${name} not available`); } + this.ports[name].beginGroup(group, socketId); + } + + send(name, data, socketId) { + if (!this.ports[name]) { throw new Error(`Port ${name} not available`); } + this.ports[name].send(data, socketId); + } + + endGroup(name, socketId) { + if (!this.ports[name]) { throw new Error(`Port ${name} not available`); } + this.ports[name].endGroup(socketId); + } + + disconnect(name, socketId) { + if (!this.ports[name]) { throw new Error(`Port ${name} not available`); } + this.ports[name].disconnect(socketId); + } +}; + +// Port name normalization: +// returns object containing keys name and index for ports names in +// format `portname` or `portname[index]`. +exports.normalizePortName = function normalizePortName(name) { + const port = { name }; + // Regular port + if (name.indexOf('[') === -1) { return port; } + // Addressable port with index + const matched = name.match(/(.*)\[([0-9]+)\]/); + if (!(matched != null ? matched.length : undefined)) { return name; } + return { + name: matched[1], + index: matched[2], + }; +}; diff --git a/src/lib/ProcessContext.js b/src/lib/ProcessContext.js new file mode 100644 index 000000000..acc1d91b3 --- /dev/null +++ b/src/lib/ProcessContext.js @@ -0,0 +1,30 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2020 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license + +module.exports = class ProcessContext { + constructor(ip, nodeInstance, port, result) { + this.ip = ip; + this.nodeInstance = nodeInstance; + this.port = port; + this.result = result; + this.scope = this.ip.scope; + this.activated = false; + this.deactivated = false; + } + + activate() { + // Push a new result value if previous has been sent already + /* eslint-disable no-underscore-dangle */ + if (this.result.__resolved || (this.nodeInstance.outputQ.indexOf(this.result) === -1)) { + this.result = {}; + } + this.nodeInstance.activate(this); + } + + deactivate() { + if (!this.result.__resolved) { this.result.__resolved = true; } + this.nodeInstance.deactivate(this); + } +}; diff --git a/src/lib/ProcessInput.js b/src/lib/ProcessInput.js new file mode 100644 index 000000000..cb49f2c98 --- /dev/null +++ b/src/lib/ProcessInput.js @@ -0,0 +1,303 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2020 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license +/* eslint-disable no-underscore-dangle */ +const debug = require('debug')('noflo:component'); + +module.exports = class ProcessInput { + constructor(ports, context) { + this.ports = ports; + this.context = context; + this.nodeInstance = this.context.nodeInstance; + this.ip = this.context.ip; + this.port = this.context.port; + this.result = this.context.result; + this.scope = this.context.scope; + } + + // When preconditions are met, set component state to `activated` + activate() { + if (this.context.activated) { return; } + if (this.nodeInstance.isOrdered()) { + // We're handling packets in order. Set the result as non-resolved + // so that it can be send when the order comes up + this.result.__resolved = false; + } + this.nodeInstance.activate(this.context); + if (this.port.isAddressable()) { + debug(`${this.nodeInstance.nodeId} packet on '${this.port.name}[${this.ip.index}]' caused activation ${this.nodeInstance.load}: ${this.ip.type}`); + } else { + debug(`${this.nodeInstance.nodeId} packet on '${this.port.name}' caused activation ${this.nodeInstance.load}: ${this.ip.type}`); + } + } + + // ## Connection listing + // This allows components to check which input ports are attached. This is + // useful mainly for addressable ports + attached(...params) { + let args = params; + if (!args.length) { args = ['in']; } + const res = []; + args.forEach((port) => { + if (!this.ports[port]) { + throw new Error(`Node ${this.nodeInstance.nodeId} has no port '${port}'`); + } + res.push(this.ports[port].listAttached()); + }); + if (args.length === 1) { return res.pop(); } + return res; + } + + // ## Input preconditions + // When the processing function is called, it can check if input buffers + // contain the packets needed for the process to fire. + // This precondition handling is done via the `has` and `hasStream` methods. + + // Returns true if a port (or ports joined by logical AND) has a new IP + // Passing a validation callback as a last argument allows more selective + // checking of packets. + has(...params) { + let args = params; + let validate; + if (!args.length) { args = ['in']; } + if (typeof args[args.length - 1] === 'function') { + validate = args.pop(); + } else { + validate = () => true; + } + for (let i = 0; i < args.length; i += 1) { + const port = args[i]; + if (Array.isArray(port)) { + if (!this.ports[port[0]]) { + throw new Error(`Node ${this.nodeInstance.nodeId} has no port '${port[0]}'`); + } + if (!this.ports[port[0]].isAddressable()) { + throw new Error(`Non-addressable ports, access must be with string ${port[0]}`); + } + if (!this.ports[port[0]].has(this.scope, port[1], validate)) { return false; } + } else { + if (!this.ports[port]) { + throw new Error(`Node ${this.nodeInstance.nodeId} has no port '${port}'`); + } + if (this.ports[port].isAddressable()) { + throw new Error(`For addressable ports, access must be with array [${port}, idx]`); + } + if (!this.ports[port].has(this.scope, validate)) { return false; } + } + } + return true; + } + + // Returns true if the ports contain data packets + hasData(...params) { + let args = params; + if (!args.length) { args = ['in']; } + args.push((ip) => ip.type === 'data'); + return this.has(...args); + } + + // Returns true if a port has a complete stream in its input buffer. + hasStream(...params) { + let args = params; + let validateStream; + if (!args.length) { args = ['in']; } + + if (typeof args[args.length - 1] === 'function') { + validateStream = args.pop(); + } else { + validateStream = () => true; + } + + for (let i = 0; i < args.length; i += 1) { + const port = args[i]; + const portBrackets = []; + let hasData = false; + const validate = (ip) => { + if (ip.type === 'openBracket') { + portBrackets.push(ip.data); + return false; + } + if (ip.type === 'data') { + // Run the stream validation callback + hasData = validateStream(ip, portBrackets); + // Data IP on its own is a valid stream + if (!portBrackets.length) { return hasData; } + // Otherwise we need to check for complete stream + return false; + } + if (ip.type === 'closeBracket') { + portBrackets.pop(); + if (portBrackets.length) { return false; } + if (!hasData) { return false; } + return true; + } + return false; + }; + if (!this.has(port, validate)) { return false; } + } + return true; + } + + // ## Input processing + // + // Once preconditions have been met, the processing function can read from + // the input buffers. Reading packets sets the component as "activated". + // + // Fetches IP object(s) for port(s) + get(...params) { + this.activate(); + let args = params; + if (!args.length) { args = ['in']; } + const res = []; + for (let i = 0; i < args.length; i += 1) { + const port = args[i]; + let idx; + let ip; + let portname; + if (Array.isArray(port)) { + [portname, idx] = Array.from(port); + if (!this.ports[portname].isAddressable()) { + throw new Error('Non-addressable ports, access must be with string portname'); + } + } else { + portname = port; + if (this.ports[portname].isAddressable()) { + throw new Error('For addressable ports, access must be with array [portname, idx]'); + } + } + if (this.nodeInstance.isForwardingInport(portname)) { + ip = this.__getForForwarding(portname, idx); + res.push(ip); + } else { + ip = this.ports[portname].get(this.scope, idx); + res.push(ip); + } + } + + if (args.length === 1) { return res[0]; } return res; + } + + __getForForwarding(port, idx) { + const prefix = []; + let dataIp = null; + // Read IPs until we hit data + let ok = true; + while (ok) { + // Read next packet + const ip = this.ports[port].get(this.scope, idx); + // Stop at the end of the buffer + if (!ip) { break; } + if (ip.type === 'data') { + // Hit the data IP, stop here + dataIp = ip; + ok = false; + break; + } + // Keep track of bracket closings and openings before + prefix.push(ip); + } + + // Forwarding brackets that came before data packet need to manipulate context + // and be added to result so they can be forwarded correctly to ports that + // need them + for (let i = 0; i < prefix.length; i += 1) { + const ip = prefix[i]; + if (ip.type === 'closeBracket') { + // Bracket closings before data should remove bracket context + if (!this.result.__bracketClosingBefore) { this.result.__bracketClosingBefore = []; } + const context = this.nodeInstance.getBracketContext('in', port, this.scope, idx).pop(); + context.closeIp = ip; + this.result.__bracketClosingBefore.push(context); + } else if (ip.type === 'openBracket') { + // Bracket openings need to go to bracket context + this.nodeInstance.getBracketContext('in', port, this.scope, idx).push({ + ip, + ports: [], + source: port, + }); + } + } + + // Add current bracket context to the result so that when we send + // to ports we can also add the surrounding brackets + if (!this.result.__bracketContext) { this.result.__bracketContext = {}; } + this.result.__bracketContext[port] = this.nodeInstance.getBracketContext('in', port, this.scope, idx).slice(0); + // Bracket closings that were in buffer after the data packet need to + // be added to result for done() to read them from + return dataIp; + } + + // Fetches `data` property of IP object(s) for given port(s) + getData(...params) { + let args = params; + if (!args.length) { args = ['in']; } + + const datas = []; + args.forEach((port) => { + let packet = this.get(port); + if (packet == null) { + // we add the null packet to the array so when getting + // multiple ports, if one is null we still return it + // so the indexes are correct. + datas.push(packet); + return; + } + + while (packet.type !== 'data') { + packet = this.get(port); + if (!packet) { break; } + } + + datas.push(packet.data); + }); + + if (args.length === 1) { return datas.pop(); } + return datas; + } + + // Fetches a complete data stream from the buffer. + getStream(...params) { + let args = params; + if (!args.length) { args = ['in']; } + const datas = []; + for (let i = 0; i < args.length; i += 1) { + const port = args[i]; + const portBrackets = []; + let portPackets = []; + let hasData = false; + let ip = this.get(port); + if (!ip) { datas.push(undefined); } + while (ip) { + if (ip.type === 'openBracket') { + if (!portBrackets.length) { + // First openBracket in stream, drop previous + portPackets = []; + hasData = false; + } + portBrackets.push(ip.data); + portPackets.push(ip); + } + if (ip.type === 'data') { + portPackets.push(ip); + hasData = true; + // Unbracketed data packet is a valid stream + if (!portBrackets.length) { break; } + } + if (ip.type === 'closeBracket') { + portPackets.push(ip); + portBrackets.pop(); + if (hasData && !portBrackets.length) { + // Last close bracket finishes stream if there was data inside + break; + } + } + ip = this.get(port); + } + datas.push(portPackets); + } + + if (args.length === 1) { return datas.pop(); } + return datas; + } +}; diff --git a/src/lib/ProcessOutput.js b/src/lib/ProcessOutput.js new file mode 100644 index 000000000..5107d0fef --- /dev/null +++ b/src/lib/ProcessOutput.js @@ -0,0 +1,171 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2013-2020 Flowhub UG +// (c) 2011-2012 Henri Bergius, Nemein +// NoFlo may be freely distributed under the MIT license + +/* eslint-disable no-underscore-dangle */ +const debug = require('debug')('noflo:component'); + +const IP = require('./IP'); + +// Checks if a value is an Error +function isError(err) { + return err instanceof Error + || (Array.isArray(err) && (err.length > 0) && err[0] instanceof Error); +} + +module.exports = class ProcessOutput { + constructor(ports, context) { + this.ports = ports; + this.context = context; + this.nodeInstance = this.context.nodeInstance; + this.ip = this.context.ip; + this.result = this.context.result; + this.scope = this.context.scope; + } + + // Sends an error object + error(err) { + let errs = err; + const multiple = Array.isArray(err); + if (!multiple) { errs = [err]; } + if ('error' in this.ports && (this.ports.error.isAttached() || !this.ports.error.isRequired())) { + if (multiple) { this.sendIP('error', new IP('openBracket')); } + errs.forEach((e) => { this.sendIP('error', e); }); + if (multiple) { this.sendIP('error', new IP('closeBracket')); } + } else { + errs.forEach((e) => { throw e; }); + } + } + + // Sends a single IP object to a port + sendIP(port, packet) { + let ip; + if (!IP.isIP(packet)) { + ip = new IP('data', packet); + } else { + ip = packet; + } + if ((this.scope !== null) && (ip.scope === null)) { ip.scope = this.scope; } + + if (this.nodeInstance.outPorts[port].isAddressable() && (ip.index === null)) { + throw new Error('Sending packets to addressable ports requires specifying index'); + } + + if (this.nodeInstance.isOrdered()) { + this.nodeInstance.addToResult(this.result, port, ip); + return; + } + if (!this.nodeInstance.outPorts[port].options.scoped) { + ip.scope = null; + } + this.nodeInstance.outPorts[port].sendIP(ip); + } + + // Sends packets for each port as a key in the map + // or sends Error or a list of Errors if passed such + send(outputMap) { + if (isError(outputMap)) { + this.error(outputMap); + return; + } + + const componentPorts = []; + let mapIsInPorts = false; + Object.keys(this.ports.ports).forEach((port) => { + if ((port !== 'error') && (port !== 'ports') && (port !== '_callbacks')) { componentPorts.push(port); } + if (!mapIsInPorts && (outputMap != null) && (typeof outputMap === 'object') && (Object.keys(outputMap).indexOf(port) !== -1)) { + mapIsInPorts = true; + } + }); + + if ((componentPorts.length === 1) && !mapIsInPorts) { + this.sendIP(componentPorts[0], outputMap); + return; + } + + if ((componentPorts.length > 1) && !mapIsInPorts) { + throw new Error('Port must be specified for sending output'); + } + + Object.keys(outputMap).forEach((port) => { + const packet = outputMap[port]; + this.sendIP(port, packet); + }); + } + + // Sends the argument via `send()` and marks activation as `done()` + sendDone(outputMap) { + this.send(outputMap); + this.done(); + } + + // Makes a map-style component pass a result value to `out` + // keeping all IP metadata received from `in`, + // or modifying it if `options` is provided + pass(data, options = {}) { + if (!('out' in this.ports)) { + throw new Error('output.pass() requires port "out" to be present'); + } + const that = this; + Object.keys(options).forEach((key) => { + const val = options[key]; + that.ip[key] = val; + }); + this.ip.data = data; + this.sendIP('out', this.ip); + this.done(); + } + + // Finishes process activation gracefully + done(error) { + this.result.__resolved = true; + this.nodeInstance.activate(this.context); + if (error) { this.error(error); } + + const isLast = () => { + // We only care about real output sets with processing data + const resultsOnly = this.nodeInstance.outputQ.filter((q) => { + if (!q.__resolved) { return true; } + if ((Object.keys(q).length === 2) && q.__bracketClosingAfter) { + return false; + } + return true; + }); + const pos = resultsOnly.indexOf(this.result); + const len = resultsOnly.length; + const { + load, + } = this.nodeInstance; + if (pos === (len - 1)) { return true; } + if ((pos === -1) && (load === (len + 1))) { return true; } + if ((len <= 1) && (load === 1)) { return true; } + return false; + }; + if (this.nodeInstance.isOrdered() && isLast()) { + // We're doing bracket forwarding. See if there are + // dangling closeBrackets in buffer since we're the + // last running process function. + Object.keys(this.nodeInstance.bracketContext.in).forEach((port) => { + const contexts = this.nodeInstance.bracketContext.in[port]; + if (!contexts[this.scope]) { return; } + const nodeContext = contexts[this.scope]; + if (!nodeContext.length) { return; } + const context = nodeContext[nodeContext.length - 1]; + const inPorts = this.nodeInstance.inPorts[context.source]; + const buf = inPorts.getBuffer(context.ip.scope, context.ip.index); + while (buf.length > 0 && buf[0].type === 'closeBracket') { + const ip = inPorts.get(context.ip.scope, context.ip.index); + const ctx = nodeContext.pop(); + ctx.closeIp = ip; + if (!this.result.__bracketClosingAfter) { this.result.__bracketClosingAfter = []; } + this.result.__bracketClosingAfter.push(ctx); + } + }); + } + + debug(`${this.nodeInstance.nodeId} finished processing ${this.nodeInstance.load}`); + + this.nodeInstance.deactivate(this.context); + } +}; diff --git a/src/lib/Utils.coffee b/src/lib/Utils.coffee deleted file mode 100644 index 21202465a..000000000 --- a/src/lib/Utils.coffee +++ /dev/null @@ -1,104 +0,0 @@ -# NoFlo - Flow-Based Programming for JavaScript -# (c) 2014-2017 Flowhub UG -# NoFlo may be freely distributed under the MIT license - -# Guess language from filename -guessLanguageFromFilename = (filename) -> - return 'coffeescript' if /.*\.coffee$/.test filename - return 'javascript' - -isArray = (obj) -> - return Array.isArray(obj) if Array.isArray - return Object.prototype.toString.call(arg) == '[object Array]' - -# the following functions are from http://underscorejs.org/docs/underscore.html -# Underscore.js 1.8.3 http://underscorejs.org -# (c) 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors -# Underscore may be freely distributed under the MIT license. - -# Internal function that returns an efficient (for current engines) -# version of the passed-in callback, -# to be repeatedly applied in other Underscore functions. -optimizeCb = (func, context, argCount) -> - if context == undefined - return func - switch (if argCount == null then 3 else argCount) - when 1 - return (value) -> - func.call context, value - when 2 - return (value, other) -> - func.call context, value, other - when 3 - return (value, index, collection) -> - func.call context, value, index, collection - when 4 - return (accumulator, value, index, collection) -> - func.call context, accumulator, value, index, collection - -> - func.apply context, arguments - - -# Create a reducing function iterating left or right. -# Optimized iterator function as using arguments.length in the main function -# will deoptimize the, see #1991. -createReduce = (dir) -> - iterator = (obj, iteratee, memo, keys, index, length) -> - while index >= 0 and index < length - currentKey = if keys then keys[index] else index - memo = iteratee(memo, obj[currentKey], currentKey, obj) - index += dir - memo - - return (obj, iteratee, memo, context) -> - iteratee = optimizeCb(iteratee, context, 4) - keys = Object.keys obj - length = (keys or obj).length - index = if dir > 0 then 0 else length - 1 - if arguments.length < 3 - memo = obj[if keys then keys[index] else index] - index += dir - iterator obj, iteratee, memo, keys, index, length - -reduceRight = createReduce(-1) - -# Returns a function, that, as long as it continues to be invoked, -# will not be triggered. -# The function will be called after it stops being called for N milliseconds. -# If immediate is passed, trigger the function on the leading edge, -# instead of the trailing. -debounce = (func, wait, immediate) -> - timeout = undefined - args = undefined - context = undefined - timestamp = undefined - result = undefined - - later = -> - last = Date.now - timestamp - if last < wait and last >= 0 - timeout = setTimeout(later, wait - last) - else - timeout = null - if !immediate - result = func.apply(context, args) - if !timeout - context = args = null - return - - -> - context = this - args = arguments - timestamp = Date.now - callNow = immediate and !timeout - if !timeout - timeout = setTimeout(later, wait) - if callNow - result = func.apply(context, args) - context = args = null - result - -exports.guessLanguageFromFilename = guessLanguageFromFilename -exports.reduceRight = reduceRight -exports.debounce = debounce -exports.isArray = isArray diff --git a/src/lib/Utils.js b/src/lib/Utils.js new file mode 100644 index 000000000..3b501d790 --- /dev/null +++ b/src/lib/Utils.js @@ -0,0 +1,129 @@ +// NoFlo - Flow-Based Programming for JavaScript +// (c) 2014-2017 Flowhub UG +// NoFlo may be freely distributed under the MIT license + +/* eslint-disable + no-param-reassign, + prefer-rest-params, +*/ + +// Guess language from filename +function guessLanguageFromFilename(filename) { + if (/.*\.coffee$/.test(filename)) { return 'coffeescript'; } + return 'javascript'; +} + +function isArray(obj) { + if (Array.isArray) { return Array.isArray(obj); } + return Object.prototype.toString.call(obj) === '[object Array]'; +} + +// the following functions are from http://underscorejs.org/docs/underscore.html +// Underscore.js 1.8.3 http://underscorejs.org +// (c) 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors +// Underscore may be freely distributed under the MIT license. + +// Internal function that returns an efficient (for current engines) +// version of the passed-in callback, +// to be repeatedly applied in other Underscore functions. +function optimizeCb(func, context, argCount) { + if (context === undefined) { + return func; + } + switch (argCount === null ? 3 : argCount) { + case 1: + return (value) => func.call(context, value); + case 2: + return (value, other) => func.call(context, value, other); + case 3: + return (value, index, collection) => func.call(context, value, index, collection); + case 4: + return (accumulator, value, index, collection) => { + func.call(context, accumulator, value, index, collection); + }; + default: // No-op + } + return function call() { + return func.apply(context, arguments); + }; +} + +// Create a reducing function iterating left or right. +// Optimized iterator function as using arguments.length in the main function +// will deoptimize the, see #1991. +function createReduce(dir) { + function iterator(obj, iteratee, memo, keys, index, length) { + while ((index >= 0) && (index < length)) { + const currentKey = keys ? keys[index] : index; + memo = iteratee(memo, obj[currentKey], currentKey, obj); + index += dir; + } + return memo; + } + + return function reduce(obj, iteratee, memo, context) { + iteratee = optimizeCb(iteratee, context, 4); + const keys = Object.keys(obj); + const { + length, + } = keys || obj; + let index = dir > 0 ? 0 : length - 1; + if (arguments.length < 3) { + memo = obj[keys ? keys[index] : index]; + index += dir; + } + return iterator(obj, iteratee, memo, keys, index, length); + }; +} + +const reduceRight = createReduce(-1); + +// Returns a function, that, as long as it continues to be invoked, +// will not be triggered. +// The function will be called after it stops being called for N milliseconds. +// If immediate is passed, trigger the function on the leading edge, +// instead of the trailing. +function debounce(func, wait, immediate) { + let timeout; + let args; + let context; + let timestamp; + let result; + + function later() { + const last = Date.now - timestamp; + if ((last < wait) && (last >= 0)) { + timeout = setTimeout(later, wait - last); + } else { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + if (!timeout) { + context = null; + args = null; + } + } + } + } + + return function after() { + context = this; + args = arguments; + timestamp = Date.now; + const callNow = immediate && !timeout; + if (!timeout) { + timeout = setTimeout(later, wait); + } + if (callNow) { + result = func.apply(context, args); + context = null; + args = null; + } + return result; + }; +} + +exports.guessLanguageFromFilename = guessLanguageFromFilename; +exports.reduceRight = reduceRight; +exports.debounce = debounce; +exports.isArray = isArray; diff --git a/src/lib/loader/NodeJs.coffee b/src/lib/loader/NodeJs.coffee deleted file mode 100644 index abe688172..000000000 --- a/src/lib/loader/NodeJs.coffee +++ /dev/null @@ -1,197 +0,0 @@ -path = require 'path' -fs = require 'fs' -manifest = require 'fbp-manifest' -utils = require '../Utils' -fbpGraph = require 'fbp-graph' - -# We allow components to be un-compiled CoffeeScript -CoffeeScript = require 'coffeescript' -if typeof CoffeeScript.register != 'undefined' - CoffeeScript.register() - -registerCustomLoaders = (loader, componentLoaders, callback) -> - return callback null unless componentLoaders.length - customLoader = require componentLoaders.shift() - loader.registerLoader customLoader, (err) -> - return callback err if err - registerCustomLoaders loader, componentLoaders, callback - -registerModules = (loader, modules, callback) -> - compatible = modules.filter (m) -> m.runtime in ['noflo', 'noflo-nodejs'] - componentLoaders = [] - for m in compatible - loader.setLibraryIcon m.name, m.icon if m.icon - - if m.noflo?.loader - loaderPath = path.resolve loader.baseDir, m.base, m.noflo.loader - componentLoaders.push loaderPath - - for c in m.components - loader.registerComponent m.name, c.name, path.resolve loader.baseDir, c.path - - registerCustomLoaders loader, componentLoaders, callback - -manifestLoader = - writeCache: (loader, options, manifest, callback) -> - filePath = path.resolve loader.baseDir, options.manifest - fs.writeFile filePath, JSON.stringify(manifest, null, 2), - encoding: 'utf-8' - , callback - - readCache: (loader, options, callback) -> - options.discover = false - manifest.load.load loader.baseDir, options, callback - - prepareManifestOptions: (loader) -> - loader.options = {} unless loader.options - options = {} - options.runtimes = loader.options.runtimes or [] - options.runtimes.push 'noflo' if options.runtimes.indexOf('noflo') is -1 - options.recursive = if typeof loader.options.recursive is 'undefined' then true else loader.options.recursive - options.manifest = loader.options.manifest or 'fbp.json' - options - - listComponents: (loader, manifestOptions, callback) -> - @readCache loader, manifestOptions, (err, manifest) => - if err - return callback err unless loader.options.discover - dynamicLoader.listComponents loader, manifestOptions, (err, modules) => - return callback err if err - @writeCache loader, manifestOptions, - version: 1 - modules: modules - , (err) -> - return callback err if err - callback null, modules - return - registerModules loader, manifest.modules, (err) -> - return callback err if err - callback null, manifest.modules - -dynamicLoader = - listComponents: (loader, manifestOptions, callback) -> - manifestOptions.discover = true - manifest.list.list loader.baseDir, manifestOptions, (err, modules) => - return callback err if err - registerModules loader, modules, (err) -> - return callback err if err - callback null, modules - -registerSubgraph = (loader) -> - # Inject subgraph component - if path.extname(__filename) is '.js' - graphPath = path.resolve __dirname, '../../components/Graph.js' - else - graphPath = path.resolve __dirname, '../../components/Graph.coffee' - loader.registerComponent null, 'Graph', graphPath - -exports.register = (loader, callback) -> - manifestOptions = manifestLoader.prepareManifestOptions loader - - if loader.options?.cache - manifestLoader.listComponents loader, manifestOptions, (err, modules) -> - return callback err if err - registerSubgraph loader - callback null, modules - return - - dynamicLoader.listComponents loader, manifestOptions, (err, modules) -> - return callback err if err - registerSubgraph loader - callback null, modules - -exports.dynamicLoad = (name, cPath, metadata, callback) -> - try - implementation = require cPath - catch e - callback e - return - - if typeof implementation.getComponent is 'function' - try - instance = implementation.getComponent metadata - catch e - callback e - return - else if typeof implementation is 'function' - try - instance = implementation metadata - catch e - callback e - return - else - callback new Error "Unable to instantiate #{cPath}" - return - instance.componentName = name if typeof name is 'string' - callback null, instance - -exports.setSource = (loader, packageId, name, source, language, callback) -> - Module = require 'module' - if language is 'coffeescript' - try - source = CoffeeScript.compile source, - bare: true - catch e - return callback e - try - # Use the Node.js module API to evaluate in the correct directory context - modulePath = path.resolve loader.baseDir, "./components/#{name}.js" - moduleImpl = new Module modulePath, module - moduleImpl.paths = Module._nodeModulePaths path.dirname modulePath - moduleImpl.filename = modulePath - moduleImpl._compile source, modulePath - implementation = moduleImpl.exports - catch e - return callback e - unless typeof implementation is 'function' or typeof implementation.getComponent is 'function' - return callback new Error 'Provided source failed to create a runnable component' - - loader.registerComponent packageId, name, implementation, callback - -exports.getSource = (loader, name, callback) -> - component = loader.components[name] - unless component - # Try an alias - for componentName of loader.components - if componentName.split('/')[1] is name - component = loader.components[componentName] - name = componentName - break - unless component - return callback new Error "Component #{name} not installed" - - nameParts = name.split '/' - if nameParts.length is 1 - nameParts[1] = nameParts[0] - nameParts[0] = '' - - if loader.isGraph component - if typeof component is 'object' - if typeof component.toJSON is 'function' - callback null, - name: nameParts[1] - library: nameParts[0] - code: JSON.stringify component.toJSON() - language: 'json' - return - return callback new Error "Can't provide source for #{name}. Not a file" - fbpGraph.graph.loadFile component, (err, graph) -> - return callback err if err - return callback new Error 'Unable to load graph' unless graph - callback null, - name: nameParts[1] - library: nameParts[0] - code: JSON.stringify graph.toJSON() - language: 'json' - return - - if typeof component isnt 'string' - return callback new Error "Can't provide source for #{name}. Not a file" - - fs.readFile component, 'utf-8', (err, code) -> - return callback err if err - callback null, - name: nameParts[1] - library: nameParts[0] - language: utils.guessLanguageFromFilename component - code: code diff --git a/src/lib/loader/NodeJs.js b/src/lib/loader/NodeJs.js new file mode 100644 index 000000000..f98ba2910 --- /dev/null +++ b/src/lib/loader/NodeJs.js @@ -0,0 +1,307 @@ +/* eslint-disable + global-require, + import/no-dynamic-require, + no-underscore-dangle, + prefer-destructuring, +*/ +const path = require('path'); +const fs = require('fs'); +const manifest = require('fbp-manifest'); +const fbpGraph = require('fbp-graph'); + +// We allow components to be un-compiled CoffeeScript +const CoffeeScript = require('coffeescript'); +const utils = require('../Utils'); + +if (typeof CoffeeScript.register !== 'undefined') { + CoffeeScript.register(); +} + +function registerCustomLoaders(loader, componentLoaders, callback) { + if (!componentLoaders.length) { + callback(null); + return; + } + const customLoader = require(componentLoaders.shift()); + loader.registerLoader(customLoader, (err) => { + if (err) { + callback(err); + return; + } + registerCustomLoaders(loader, componentLoaders, callback); + }); +} + +function registerModules(loader, modules, callback) { + const compatible = modules.filter((m) => ['noflo', 'noflo-nodejs'].includes(m.runtime)); + const componentLoaders = []; + compatible.forEach((m) => { + if (m.icon) { loader.setLibraryIcon(m.name, m.icon); } + + if (m.noflo != null ? m.noflo.loader : undefined) { + const loaderPath = path.resolve(loader.baseDir, m.base, m.noflo.loader); + componentLoaders.push(loaderPath); + } + + m.components.forEach((c) => { + loader.registerComponent(m.name, c.name, path.resolve(loader.baseDir, c.path)); + }); + }); + + registerCustomLoaders(loader, componentLoaders, callback); +} + +const dynamicLoader = { + listComponents(loader, manifestOptions, callback) { + const opts = manifestOptions; + opts.discover = true; + manifest.list.list(loader.baseDir, opts, (err, modules) => { + if (err) { + callback(err); + return; + } + registerModules(loader, modules, (err2) => { + if (err2) { + callback(err2); + return; + } + callback(null, modules); + }); + }); + }, +}; + +const manifestLoader = { + writeCache(loader, options, manifestContents, callback) { + const filePath = path.resolve(loader.baseDir, options.manifest); + fs.writeFile(filePath, JSON.stringify(manifestContents, null, 2), + { encoding: 'utf-8' }, + callback); + }, + + readCache(loader, options, callback) { + const opts = options; + opts.discover = false; + manifest.load.load(loader.baseDir, opts, callback); + }, + + prepareManifestOptions(loader) { + const l = loader; + if (!l.options) { l.options = {}; } + const options = {}; + options.runtimes = l.options.runtimes || []; + if (options.runtimes.indexOf('noflo') === -1) { options.runtimes.push('noflo'); } + options.recursive = typeof l.options.recursive === 'undefined' ? true : l.options.recursive; + options.manifest = l.options.manifest || 'fbp.json'; + return options; + }, + + listComponents(loader, manifestOptions, callback) { + this.readCache(loader, manifestOptions, (err, manifestContents) => { + if (err) { + if (!loader.options.discover) { + callback(err); + return; + } + dynamicLoader.listComponents(loader, manifestOptions, (err2, modules) => { + if (err2) { + callback(err2); + return; + } + this.writeCache(loader, manifestOptions, { + version: 1, + modules, + }, + (err3) => { + if (err3) { + callback(err3); + return; + } + callback(null, modules); + }); + }); + return; + } + registerModules(loader, manifestContents.modules, (err2) => { + if (err2) { + callback(err2); + return; + } + callback(null, manifestContents.modules); + }); + }); + }, +}; + +function registerSubgraph(loader) { + // Inject subgraph component + const graphPath = path.resolve(__dirname, '../../components/Graph.js'); + loader.registerComponent(null, 'Graph', graphPath); +} + +exports.register = function register(loader, callback) { + const manifestOptions = manifestLoader.prepareManifestOptions(loader); + + if (loader.options != null ? loader.options.cache : undefined) { + manifestLoader.listComponents(loader, manifestOptions, (err, modules) => { + if (err) { + callback(err); + return; + } + registerSubgraph(loader); + callback(null, modules); + }); + return; + } + + dynamicLoader.listComponents(loader, manifestOptions, (err, modules) => { + if (err) { + callback(err); + return; + } + registerSubgraph(loader); + callback(null, modules); + }); +}; + +exports.dynamicLoad = function dynamicLoad(name, cPath, metadata, callback) { + let implementation; let instance; + try { + implementation = require(cPath); + } catch (err) { + callback(err); + return; + } + + if (typeof implementation.getComponent === 'function') { + try { + instance = implementation.getComponent(metadata); + } catch (err) { + callback(err); + return; + } + } else if (typeof implementation === 'function') { + try { + instance = implementation(metadata); + } catch (err) { + callback(err); + return; + } + } else { + callback(new Error(`Unable to instantiate ${cPath}`)); + return; + } + if (typeof name === 'string') { instance.componentName = name; } + callback(null, instance); +}; + +exports.setSource = function setSource(loader, packageId, name, source, language, callback) { + const Module = require('module'); + let src = source; + if (language === 'coffeescript') { + try { + src = CoffeeScript.compile(src, + { bare: true }); + } catch (err) { + callback(err); + return; + } + } + let implementation; + try { + // Use the Node.js module API to evaluate in the correct directory context + const modulePath = path.resolve(loader.baseDir, `./components/${name}.js`); + const moduleImpl = new Module(modulePath, module); + moduleImpl.paths = Module._nodeModulePaths(path.dirname(modulePath)); + moduleImpl.filename = modulePath; + moduleImpl._compile(src, modulePath); + implementation = moduleImpl.exports; + } catch (err) { + callback(err); + return; + } + if ((typeof implementation !== 'function') && (typeof implementation.getComponent !== 'function')) { + callback(new Error('Provided source failed to create a runnable component')); + return; + } + + loader.registerComponent(packageId, name, implementation, callback); +}; + +exports.getSource = function getSource(loader, name, callback) { + let componentName = name; + let component = loader.components[name]; + if (!component) { + // Try an alias + const keys = Object.keys(loader.components); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; + if (key.split('/')[1] === name) { + component = loader.components[key]; + componentName = key; + break; + } + } + if (!component) { + callback(new Error(`Component ${componentName} not installed`)); + return; + } + } + + const nameParts = componentName.split('/'); + if (nameParts.length === 1) { + nameParts[1] = nameParts[0]; + nameParts[0] = ''; + } + + if (loader.isGraph(component)) { + if (typeof component === 'object') { + if (typeof component.toJSON === 'function') { + callback(null, { + name: nameParts[1], + library: nameParts[0], + code: JSON.stringify(component.toJSON()), + language: 'json', + }); + return; + } + callback(new Error(`Can't provide source for ${componentName}. Not a file`)); + return; + } + fbpGraph.graph.loadFile(component, (err, graph) => { + if (err) { + callback(err); + return; + } + if (!graph) { + callback(new Error('Unable to load graph')); + return; + } + callback(null, { + name: nameParts[1], + library: nameParts[0], + code: JSON.stringify(graph.toJSON()), + language: 'json', + }); + }); + return; + } + + if (typeof component !== 'string') { + callback(new Error(`Can't provide source for ${componentName}. Not a file`)); + return; + } + + fs.readFile(component, 'utf-8', (err, code) => { + if (err) { + callback(err); + return; + } + callback(null, { + name: nameParts[1], + library: nameParts[0], + language: utils.guessLanguageFromFilename(component), + code, + }); + }); +}; diff --git a/src/lib/loader/register.coffee b/src/lib/loader/register.coffee deleted file mode 100644 index 19d761630..000000000 --- a/src/lib/loader/register.coffee +++ /dev/null @@ -1,5 +0,0 @@ -{isBrowser} = require '../Platform' -if isBrowser() - throw new Error 'Generate NoFlo component loader for browsers with noflo-component-loader' -else - module.exports = require './NodeJs' diff --git a/src/lib/loader/register.js b/src/lib/loader/register.js new file mode 100644 index 000000000..ea2d3e93a --- /dev/null +++ b/src/lib/loader/register.js @@ -0,0 +1,10 @@ +/* eslint-disable + global-require, +*/ +const { isBrowser } = require('../Platform'); + +if (isBrowser()) { + throw new Error('Generate NoFlo component loader for browsers with noflo-component-loader'); +} else { + module.exports = require('./NodeJs'); +}