node-red-contrib-event-calc 0.1.4 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +50 -51
- package/nodes/event-calc.html +9 -1
- package/nodes/event-calc.js +9 -0
- package/nodes/event-json.html +58 -0
- package/nodes/event-json.js +69 -0
- package/package.json +21 -9
- package/playwright.config.js +22 -0
- package/tests/external-trigger.spec.js +141 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Personal Use License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Holger Amort
|
|
4
|
+
|
|
5
|
+
This software is provided for personal, non-commercial use only.
|
|
6
|
+
|
|
7
|
+
You may:
|
|
8
|
+
- Use this software for personal projects
|
|
9
|
+
- Modify the software for your own use
|
|
10
|
+
- Share the software with attribution
|
|
11
|
+
|
|
12
|
+
You may not:
|
|
13
|
+
- Use this software for commercial purposes without permission
|
|
14
|
+
- Sell or sublicense this software
|
|
15
|
+
- Remove this license notice
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
24
|
+
|
|
25
|
+
For commercial licensing inquiries, contact the author.
|
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# node-red-contrib-event-calc
|
|
2
2
|
|
|
3
|
-
Node-RED nodes for event caching and calculations with
|
|
3
|
+
Node-RED nodes for event caching and streaming calculations with a local pub/sub event hub.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
This package provides a
|
|
7
|
+
This package provides a local in-memory event hub with topic-based publish/subscribe and latest-value caching for reactive data flows within Node-RED. Stream data from MQTT, OPC-UA, or any source, then perform calculations that trigger automatically when values update.
|
|
8
8
|
|
|
9
9
|
## Architecture
|
|
10
10
|
|
|
@@ -13,14 +13,14 @@ This package provides a central cache for event/streaming data values with react
|
|
|
13
13
|
│ event-cache (config node) │
|
|
14
14
|
│ • Stores: Map<topic, {value, ts, metadata}> │
|
|
15
15
|
│ • Event emitter for topic updates │
|
|
16
|
-
│ •
|
|
16
|
+
│ • LRU eviction, optional TTL │
|
|
17
17
|
└──────────────────────────────────────────────────────────────┘
|
|
18
18
|
│ │ │
|
|
19
19
|
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
|
|
20
20
|
│event-in │ │event- │ │event- │
|
|
21
21
|
│ │ │topic │ │calc │
|
|
22
22
|
│ pushes │ │subscribes│ │multi-sub│
|
|
23
|
-
│to cache │ │to
|
|
23
|
+
│to cache │ │to topic │ │+ expr │
|
|
24
24
|
└─────────┘ └─────────┘ └─────────┘
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -53,30 +53,30 @@ The original message passes through, allowing insertion into existing flows.
|
|
|
53
53
|
|
|
54
54
|
### event-topic
|
|
55
55
|
|
|
56
|
-
Subscribes to a topic
|
|
56
|
+
Subscribes to a topic and outputs when that topic updates.
|
|
57
57
|
|
|
58
58
|
**Properties:**
|
|
59
|
-
- **Topic
|
|
59
|
+
- **Topic**: Exact topic to subscribe to
|
|
60
60
|
- **Output Format**:
|
|
61
61
|
- *Value only*: `msg.payload` = value
|
|
62
62
|
- *Full entry*: `msg.payload` = `{value, ts, metadata}`
|
|
63
|
-
- *All matching*: `msg.payload` = `{topic1: value1, topic2: value2, ...}`
|
|
64
63
|
- **Output on deploy**: Emit cached values when flow starts
|
|
65
64
|
|
|
66
65
|
**Dynamic control via input:**
|
|
67
|
-
- `msg.
|
|
68
|
-
- `msg.payload = 'refresh'`: Output
|
|
66
|
+
- `msg.topic`: Change subscription topic
|
|
67
|
+
- `msg.payload = 'refresh'`: Output current cached value
|
|
69
68
|
|
|
70
69
|
### event-calc
|
|
71
70
|
|
|
72
71
|
Subscribes to multiple topics and evaluates an expression when values update.
|
|
73
72
|
|
|
74
73
|
**Properties:**
|
|
75
|
-
- **Input Variables**: Map variable names to
|
|
74
|
+
- **Input Variables**: Map variable names to topics
|
|
76
75
|
- **Expression**: JavaScript expression using the variables
|
|
77
76
|
- **Trigger**: When to calculate
|
|
78
77
|
- *Any input updates*: Calculate on every update
|
|
79
78
|
- *Only when all inputs have values*: Wait for all values
|
|
79
|
+
- **External Trigger**: When enabled, any incoming message triggers calculation using cached values
|
|
80
80
|
|
|
81
81
|
**Output:**
|
|
82
82
|
```json
|
|
@@ -92,19 +92,27 @@ Subscribes to multiple topics and evaluates an expression when values update.
|
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
### event-json
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
Bidirectional JSON envelope converter for MQTT messaging.
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
| `sensors/*` | `sensors/temp`, `sensors/room1/temp` | `sensors` (nothing after /) |
|
|
103
|
-
| `*/temp` | `room/temp`, `sensors/temp` | `temp` (nothing before /) |
|
|
104
|
-
| `*` | Any topic with 1+ chars | Empty string |
|
|
99
|
+
**Behavior:**
|
|
100
|
+
- **Unwrap**: If payload is `{value, topic?, timestamp?}`, extracts to msg properties
|
|
101
|
+
- **Wrap**: If payload is any other value, wraps as `{timestamp, topic, value}`
|
|
105
102
|
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
**Usage:**
|
|
104
|
+
```
|
|
105
|
+
[MQTT in] → [event-json] → [event-in] (unwrap JSON from broker)
|
|
106
|
+
[event-topic] → [event-json] → [MQTT out] (wrap for broker)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### event-simulator
|
|
110
|
+
|
|
111
|
+
Generates simulated data for testing. Supports sine waves, random values, and ramps.
|
|
112
|
+
|
|
113
|
+
### event-chart
|
|
114
|
+
|
|
115
|
+
Real-time charting node for visualizing cached event data.
|
|
108
116
|
|
|
109
117
|
## Examples
|
|
110
118
|
|
|
@@ -121,13 +129,21 @@ Two wildcards are supported:
|
|
|
121
129
|
trigger: all
|
|
122
130
|
```
|
|
123
131
|
|
|
124
|
-
###
|
|
132
|
+
### Time-based Calculations (External Trigger)
|
|
125
133
|
|
|
126
134
|
```
|
|
127
|
-
[
|
|
135
|
+
[inject: every 1 min] → [event-calc (external trigger)] → [MQTT out]
|
|
136
|
+
inputs: a = sensors/power
|
|
137
|
+
b = sensors/voltage
|
|
138
|
+
expression: a * b
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### MQTT Round-trip with JSON Envelope
|
|
128
142
|
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
```
|
|
144
|
+
[MQTT in] → [event-json] → [event-in] → [cache]
|
|
145
|
+
|
|
146
|
+
[event-calc] → [event-json] → [MQTT out]
|
|
131
147
|
```
|
|
132
148
|
|
|
133
149
|
### Calculate Power (Voltage × Current)
|
|
@@ -188,27 +204,6 @@ Two wildcards are supported:
|
|
|
188
204
|
| `ifelse(a > b, 'high', 'low')` | Conditional |
|
|
189
205
|
| `pctChange(a, b)` | % change from b to a |
|
|
190
206
|
|
|
191
|
-
## Scalability
|
|
192
|
-
|
|
193
|
-
The event-cache is optimized for high subscriber counts:
|
|
194
|
-
|
|
195
|
-
| Subscription Type | Lookup Complexity | Best For |
|
|
196
|
-
|-------------------|-------------------|----------|
|
|
197
|
-
| Exact topic (e.g., `sensors/room1/temp`) | O(1) | High-frequency updates, many subscribers |
|
|
198
|
-
| Wildcard pattern (e.g., `sensors/*`) | O(w) | Flexible matching, fewer patterns |
|
|
199
|
-
|
|
200
|
-
Where `w` = number of wildcard subscriptions (typically much smaller than total subscribers).
|
|
201
|
-
|
|
202
|
-
**Example performance:**
|
|
203
|
-
- 1000 exact subscriptions to different topics: O(1) per update
|
|
204
|
-
- 10 wildcard patterns + 1000 exact subscriptions: O(10) per update
|
|
205
|
-
- Pure wildcard subscriptions: O(n) per update
|
|
206
|
-
|
|
207
|
-
**Recommendations for high scale:**
|
|
208
|
-
- Prefer exact topic matches when possible
|
|
209
|
-
- Use wildcards sparingly for monitoring/logging
|
|
210
|
-
- Check stats endpoint: `GET /event-cache/:id/stats`
|
|
211
|
-
|
|
212
207
|
## API (for custom nodes)
|
|
213
208
|
|
|
214
209
|
The event-cache node exposes methods for programmatic access:
|
|
@@ -223,12 +218,8 @@ cache.setValue('topic/path', 42, { source: 'sensor' });
|
|
|
223
218
|
const entry = cache.getValue('topic/path');
|
|
224
219
|
// { value: 42, ts: 1704000000000, metadata: { source: 'sensor' } }
|
|
225
220
|
|
|
226
|
-
// Get matching values
|
|
227
|
-
const temps = cache.getMatching('sensors/*');
|
|
228
|
-
// Map { 'sensors/room1/temp' => {...}, 'sensors/room2/temp' => {...} }
|
|
229
|
-
|
|
230
221
|
// Subscribe to updates
|
|
231
|
-
const subId = cache.subscribe('sensors
|
|
222
|
+
const subId = cache.subscribe('sensors/room1/temp', (topic, entry) => {
|
|
232
223
|
console.log(`${topic} = ${entry.value}`);
|
|
233
224
|
});
|
|
234
225
|
|
|
@@ -242,6 +233,14 @@ const topics = cache.getTopics();
|
|
|
242
233
|
cache.clear();
|
|
243
234
|
```
|
|
244
235
|
|
|
236
|
+
## HTTP Admin Endpoints
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
GET /event-cache/:id/stats - Cache statistics
|
|
240
|
+
GET /event-cache/:id/topics - List all topics
|
|
241
|
+
POST /event-cache/:id/clear - Clear cache
|
|
242
|
+
```
|
|
243
|
+
|
|
245
244
|
## License
|
|
246
245
|
|
|
247
|
-
|
|
246
|
+
Personal Use License - See [LICENSE](LICENSE) file.
|
package/nodes/event-calc.html
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
inputMappings: { value: [] },
|
|
14
14
|
expression: { value: "" },
|
|
15
15
|
triggerOn: { value: "any" },
|
|
16
|
-
outputTopic: { value: "calc/result" }
|
|
16
|
+
outputTopic: { value: "calc/result" },
|
|
17
|
+
externalTrigger: { value: false }
|
|
17
18
|
},
|
|
18
19
|
inputs: 1,
|
|
19
20
|
outputs: 2,
|
|
@@ -213,6 +214,11 @@
|
|
|
213
214
|
<label for="node-input-outputTopic"><i class="fa fa-bookmark"></i> Output Topic</label>
|
|
214
215
|
<input type="text" id="node-input-outputTopic" placeholder="calc/result">
|
|
215
216
|
</div>
|
|
217
|
+
<div class="form-row">
|
|
218
|
+
<label> </label>
|
|
219
|
+
<input type="checkbox" id="node-input-externalTrigger" style="width:auto; margin-right:5px;">
|
|
220
|
+
<label for="node-input-externalTrigger" style="width:auto;"> External Trigger - calculate on any input message</label>
|
|
221
|
+
</div>
|
|
216
222
|
</script>
|
|
217
223
|
|
|
218
224
|
<script type="text/html" data-help-name="event-calc">
|
|
@@ -233,6 +239,8 @@
|
|
|
233
239
|
</dd>
|
|
234
240
|
<dt>Output Topic</dt>
|
|
235
241
|
<dd>Topic for output messages.</dd>
|
|
242
|
+
<dt>External Trigger</dt>
|
|
243
|
+
<dd>When enabled, any incoming message will trigger a calculation using the current cached values. Useful for time-based or event-driven calculations.</dd>
|
|
236
244
|
</dl>
|
|
237
245
|
|
|
238
246
|
<h3>Inputs</h3>
|
package/nodes/event-calc.js
CHANGED
|
@@ -66,6 +66,7 @@ module.exports = function(RED) {
|
|
|
66
66
|
node.expression = config.expression || '';
|
|
67
67
|
node.triggerOn = config.triggerOn || 'any';
|
|
68
68
|
node.outputTopic = config.outputTopic || 'calc/result';
|
|
69
|
+
node.externalTrigger = config.externalTrigger || false;
|
|
69
70
|
|
|
70
71
|
const subscriptionIds = [];
|
|
71
72
|
|
|
@@ -239,6 +240,14 @@ module.exports = function(RED) {
|
|
|
239
240
|
node.status({ fill: "blue", shape: "dot", text: "expr updated" });
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
// External trigger: any incoming message triggers calculation
|
|
244
|
+
if (node.externalTrigger) {
|
|
245
|
+
const triggerSource = msg.topic || '_external';
|
|
246
|
+
tryCalculate(triggerSource, latestValues);
|
|
247
|
+
done();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
242
251
|
// Force recalculation (use special topic to bypass self-output check)
|
|
243
252
|
if (msg.payload === 'recalc' || msg.topic === 'recalc') {
|
|
244
253
|
if (latestValues.size > 0) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('event-json', {
|
|
3
|
+
category: 'event calc',
|
|
4
|
+
color: '#758467',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" }
|
|
7
|
+
},
|
|
8
|
+
inputs: 1,
|
|
9
|
+
outputs: 1,
|
|
10
|
+
icon: "font-awesome/fa-exchange",
|
|
11
|
+
label: function() {
|
|
12
|
+
return this.name || "event json";
|
|
13
|
+
},
|
|
14
|
+
paletteLabel: "event json"
|
|
15
|
+
});
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script type="text/html" data-template-name="event-json">
|
|
19
|
+
<div class="form-row">
|
|
20
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
21
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
22
|
+
</div>
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script type="text/html" data-help-name="event-json">
|
|
26
|
+
<p>Bidirectional JSON envelope converter. Automatically detects direction.</p>
|
|
27
|
+
|
|
28
|
+
<h3>Behavior</h3>
|
|
29
|
+
<dl class="message-properties">
|
|
30
|
+
<dt>Unwrap (JSON to Message)</dt>
|
|
31
|
+
<dd>If payload is <code>{value, topic?, timestamp?}</code>, extracts value to <code>msg.payload</code> and copies topic/timestamp to msg.</dd>
|
|
32
|
+
<dt>Wrap (Message to JSON)</dt>
|
|
33
|
+
<dd>If payload is any other value, wraps it as <code>{timestamp, topic, value}</code>.</dd>
|
|
34
|
+
</dl>
|
|
35
|
+
|
|
36
|
+
<h3>Examples</h3>
|
|
37
|
+
<h4>Wrap (before MQTT publish)</h4>
|
|
38
|
+
<pre>
|
|
39
|
+
Input: msg.topic = "sensor/temp", msg.payload = 25.5
|
|
40
|
+
Output: msg.payload = {
|
|
41
|
+
timestamp: 1704900000000,
|
|
42
|
+
topic: "sensor/temp",
|
|
43
|
+
value: 25.5
|
|
44
|
+
}</pre>
|
|
45
|
+
|
|
46
|
+
<h4>Unwrap (after MQTT subscribe)</h4>
|
|
47
|
+
<pre>
|
|
48
|
+
Input: msg.payload = '{"timestamp":1704900000000,"topic":"sensor/temp","value":25.5}'
|
|
49
|
+
Output: msg.topic = "sensor/temp"
|
|
50
|
+
msg.payload = 25.5
|
|
51
|
+
msg.timestamp = 1704900000000</pre>
|
|
52
|
+
|
|
53
|
+
<h3>Status</h3>
|
|
54
|
+
<ul>
|
|
55
|
+
<li><b>Green "wrapped"</b> - Created JSON envelope</li>
|
|
56
|
+
<li><b>Blue "unwrapped"</b> - Extracted from JSON envelope</li>
|
|
57
|
+
</ul>
|
|
58
|
+
</script>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-json - Bidirectional JSON envelope converter
|
|
3
|
+
*
|
|
4
|
+
* Automatically detects direction:
|
|
5
|
+
* - Object with {value, topic?, timestamp?} -> extracts to msg
|
|
6
|
+
* - Any other payload -> wraps in {timestamp, topic, value}
|
|
7
|
+
*/
|
|
8
|
+
module.exports = function(RED) {
|
|
9
|
+
function EventJsonNode(config) {
|
|
10
|
+
RED.nodes.createNode(this, config);
|
|
11
|
+
const node = this;
|
|
12
|
+
|
|
13
|
+
node.on('input', function(msg, send, done) {
|
|
14
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
15
|
+
done = done || function(err) { if (err) node.error(err, msg); };
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
let data = msg.payload;
|
|
19
|
+
|
|
20
|
+
// If string, try to parse as JSON
|
|
21
|
+
if (typeof data === 'string') {
|
|
22
|
+
try {
|
|
23
|
+
data = JSON.parse(data);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
// Not JSON string - wrap it
|
|
26
|
+
msg.payload = {
|
|
27
|
+
timestamp: Date.now(),
|
|
28
|
+
topic: msg.topic,
|
|
29
|
+
value: msg.payload
|
|
30
|
+
};
|
|
31
|
+
node.status({ fill: "green", shape: "dot", text: "wrapped" });
|
|
32
|
+
send(msg);
|
|
33
|
+
done();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check if it's an envelope object (has 'value' property)
|
|
39
|
+
if (typeof data === 'object' && data !== null && 'value' in data) {
|
|
40
|
+
// Unwrap: extract from envelope
|
|
41
|
+
if (data.topic) {
|
|
42
|
+
msg.topic = data.topic;
|
|
43
|
+
}
|
|
44
|
+
if (data.timestamp) {
|
|
45
|
+
msg.timestamp = data.timestamp;
|
|
46
|
+
}
|
|
47
|
+
msg.payload = data.value;
|
|
48
|
+
node.status({ fill: "blue", shape: "dot", text: "unwrapped" });
|
|
49
|
+
} else {
|
|
50
|
+
// Wrap: create envelope
|
|
51
|
+
msg.payload = {
|
|
52
|
+
timestamp: Date.now(),
|
|
53
|
+
topic: msg.topic,
|
|
54
|
+
value: data
|
|
55
|
+
};
|
|
56
|
+
node.status({ fill: "green", shape: "dot", text: "wrapped" });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
send(msg);
|
|
60
|
+
done();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
node.status({ fill: "red", shape: "ring", text: "error" });
|
|
63
|
+
done(err);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
RED.nodes.registerType("event-json", EventJsonNode);
|
|
69
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-event-calc",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Node-RED nodes for event caching and calculations
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "Node-RED nodes for event caching and calculations",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Holger Amort"
|
|
7
7
|
},
|
|
@@ -9,19 +9,18 @@
|
|
|
9
9
|
"node-red",
|
|
10
10
|
"events",
|
|
11
11
|
"cache",
|
|
12
|
-
"wildcards",
|
|
13
12
|
"reactive",
|
|
14
13
|
"calculation",
|
|
15
14
|
"streaming"
|
|
16
15
|
],
|
|
17
|
-
"license": "
|
|
18
|
-
"homepage": "https://github.com/ErnstHolger/node-red#readme",
|
|
16
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
17
|
+
"homepage": "https://github.com/ErnstHolger/node-red-contrib-event-calc#readme",
|
|
19
18
|
"bugs": {
|
|
20
|
-
"url": "https://github.com/ErnstHolger/node-red/issues"
|
|
19
|
+
"url": "https://github.com/ErnstHolger/node-red-contrib-event-calc/issues"
|
|
21
20
|
},
|
|
22
21
|
"repository": {
|
|
23
22
|
"type": "git",
|
|
24
|
-
"url": "git+https://github.com/ErnstHolger/node-red.git"
|
|
23
|
+
"url": "git+https://github.com/ErnstHolger/node-red-contrib-event-calc.git"
|
|
25
24
|
},
|
|
26
25
|
"node-red": {
|
|
27
26
|
"version": ">=2.0.0",
|
|
@@ -30,6 +29,7 @@
|
|
|
30
29
|
"event-in": "nodes/event-in.js",
|
|
31
30
|
"event-topic": "nodes/event-topic.js",
|
|
32
31
|
"event-calc": "nodes/event-calc.js",
|
|
32
|
+
"event-json": "nodes/event-json.js",
|
|
33
33
|
"event-simulator": "nodes/event-simulator.js",
|
|
34
34
|
"event-chart": "nodes/event-chart.js"
|
|
35
35
|
}
|
|
@@ -38,7 +38,19 @@
|
|
|
38
38
|
"node": ">=18.0.0"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
|
-
"
|
|
41
|
+
"deploy": "npm link",
|
|
42
|
+
"deploy:patch": "npm version patch --no-git-tag-version && npm link",
|
|
43
|
+
"deploy:minor": "npm version minor --no-git-tag-version && npm link",
|
|
44
|
+
"deploy:major": "npm version major --no-git-tag-version && npm link",
|
|
45
|
+
"publish:dry": "npm publish --dry-run",
|
|
46
|
+
"publish:patch": "npm version patch && npm publish && start https://flows.nodered.org/add/node",
|
|
47
|
+
"publish:minor": "npm version minor && npm publish && start https://flows.nodered.org/add/node",
|
|
48
|
+
"publish:major": "npm version major && npm publish && start https://flows.nodered.org/add/node",
|
|
49
|
+
"test": "playwright test",
|
|
50
|
+
"test:ui": "playwright test --ui",
|
|
51
|
+
"test:headed": "playwright test --headed"
|
|
42
52
|
},
|
|
43
|
-
"
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@playwright/test": "^1.57.0"
|
|
55
|
+
}
|
|
44
56
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const { defineConfig } = require('@playwright/test');
|
|
3
|
+
|
|
4
|
+
module.exports = defineConfig({
|
|
5
|
+
testDir: './tests',
|
|
6
|
+
fullyParallel: false,
|
|
7
|
+
forbidOnly: !!process.env.CI,
|
|
8
|
+
retries: process.env.CI ? 2 : 0,
|
|
9
|
+
workers: 1,
|
|
10
|
+
reporter: 'html',
|
|
11
|
+
use: {
|
|
12
|
+
baseURL: 'http://localhost:1880',
|
|
13
|
+
trace: 'on-first-retry',
|
|
14
|
+
screenshot: 'only-on-failure',
|
|
15
|
+
},
|
|
16
|
+
projects: [
|
|
17
|
+
{
|
|
18
|
+
name: 'chromium',
|
|
19
|
+
use: { browserName: 'chromium' },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const { test, expect } = require('@playwright/test');
|
|
3
|
+
|
|
4
|
+
test.describe('Event Calc - External Trigger Feature', () => {
|
|
5
|
+
test.beforeEach(async ({ page }) => {
|
|
6
|
+
// Navigate to Node-RED editor
|
|
7
|
+
await page.goto('/');
|
|
8
|
+
// Wait for Node-RED to load
|
|
9
|
+
await page.waitForSelector('#red-ui-palette', { timeout: 30000 });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('should show External Trigger checkbox in event-calc node configuration', async ({ page }) => {
|
|
13
|
+
// Search for the event-calc node in the palette
|
|
14
|
+
const paletteSearch = page.locator('#red-ui-palette-search input');
|
|
15
|
+
await paletteSearch.fill('event calc');
|
|
16
|
+
|
|
17
|
+
// Wait for search results
|
|
18
|
+
await page.waitForTimeout(500);
|
|
19
|
+
|
|
20
|
+
// Find the event-calc node in the palette
|
|
21
|
+
const eventCalcNode = page.locator('.red-ui-palette-node[data-palette-type="event-calc"]');
|
|
22
|
+
await expect(eventCalcNode).toBeVisible();
|
|
23
|
+
|
|
24
|
+
// Drag the node to the workspace
|
|
25
|
+
const workspace = page.locator('#red-ui-workspace-chart');
|
|
26
|
+
await eventCalcNode.dragTo(workspace);
|
|
27
|
+
|
|
28
|
+
// Double-click on the newly created node to open the editor
|
|
29
|
+
// First find the node in the workspace
|
|
30
|
+
const nodeInWorkspace = page.locator('.red-ui-flow-node-group').last();
|
|
31
|
+
await nodeInWorkspace.dblclick();
|
|
32
|
+
|
|
33
|
+
// Wait for the edit dialog to open
|
|
34
|
+
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
35
|
+
|
|
36
|
+
// Verify the External Trigger checkbox exists
|
|
37
|
+
const externalTriggerCheckbox = page.locator('#node-input-externalTrigger');
|
|
38
|
+
await expect(externalTriggerCheckbox).toBeVisible();
|
|
39
|
+
|
|
40
|
+
// Verify it's unchecked by default
|
|
41
|
+
await expect(externalTriggerCheckbox).not.toBeChecked();
|
|
42
|
+
|
|
43
|
+
// Check the checkbox
|
|
44
|
+
await externalTriggerCheckbox.check();
|
|
45
|
+
await expect(externalTriggerCheckbox).toBeChecked();
|
|
46
|
+
|
|
47
|
+
// Verify the label is correct
|
|
48
|
+
const label = page.locator('label[for="node-input-externalTrigger"]');
|
|
49
|
+
await expect(label).toContainText('External Trigger');
|
|
50
|
+
await expect(label).toContainText('calculate on any input message');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('should toggle External Trigger checkbox', async ({ page }) => {
|
|
54
|
+
// Search for the event-calc node
|
|
55
|
+
const paletteSearch = page.locator('#red-ui-palette-search input');
|
|
56
|
+
await paletteSearch.fill('event calc');
|
|
57
|
+
await page.waitForTimeout(500);
|
|
58
|
+
|
|
59
|
+
// Find and drag the node
|
|
60
|
+
const eventCalcNode = page.locator('.red-ui-palette-node[data-palette-type="event-calc"]');
|
|
61
|
+
const workspace = page.locator('#red-ui-workspace-chart');
|
|
62
|
+
await eventCalcNode.dragTo(workspace);
|
|
63
|
+
|
|
64
|
+
// Open the node editor
|
|
65
|
+
const nodeInWorkspace = page.locator('.red-ui-flow-node-group').last();
|
|
66
|
+
await nodeInWorkspace.dblclick();
|
|
67
|
+
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
68
|
+
|
|
69
|
+
const externalTriggerCheckbox = page.locator('#node-input-externalTrigger');
|
|
70
|
+
|
|
71
|
+
// Toggle on
|
|
72
|
+
await externalTriggerCheckbox.check();
|
|
73
|
+
await expect(externalTriggerCheckbox).toBeChecked();
|
|
74
|
+
|
|
75
|
+
// Toggle off
|
|
76
|
+
await externalTriggerCheckbox.uncheck();
|
|
77
|
+
await expect(externalTriggerCheckbox).not.toBeChecked();
|
|
78
|
+
|
|
79
|
+
// Toggle on again
|
|
80
|
+
await externalTriggerCheckbox.check();
|
|
81
|
+
await expect(externalTriggerCheckbox).toBeChecked();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('should persist External Trigger setting after save', async ({ page }) => {
|
|
85
|
+
// Search for event-cache node first (required config)
|
|
86
|
+
const paletteSearch = page.locator('#red-ui-palette-search input');
|
|
87
|
+
await paletteSearch.fill('event cache');
|
|
88
|
+
await page.waitForTimeout(500);
|
|
89
|
+
|
|
90
|
+
// Add event-cache config node if needed
|
|
91
|
+
const eventCacheNode = page.locator('.red-ui-palette-node[data-palette-type="event-cache"]');
|
|
92
|
+
if (await eventCacheNode.isVisible()) {
|
|
93
|
+
const workspace = page.locator('#red-ui-workspace-chart');
|
|
94
|
+
await eventCacheNode.dragTo(workspace);
|
|
95
|
+
|
|
96
|
+
// Configure the cache node
|
|
97
|
+
const cacheNodeInWorkspace = page.locator('.red-ui-flow-node-group').last();
|
|
98
|
+
await cacheNodeInWorkspace.dblclick();
|
|
99
|
+
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
100
|
+
|
|
101
|
+
// Set a name for the cache
|
|
102
|
+
const nameInput = page.locator('#node-input-name');
|
|
103
|
+
await nameInput.fill('Test Cache');
|
|
104
|
+
|
|
105
|
+
// Save the cache node
|
|
106
|
+
const doneButton = page.locator('.red-ui-tray-footer button').filter({ hasText: 'Done' });
|
|
107
|
+
await doneButton.click();
|
|
108
|
+
await page.waitForTimeout(500);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Now add the event-calc node
|
|
112
|
+
await paletteSearch.clear();
|
|
113
|
+
await paletteSearch.fill('event calc');
|
|
114
|
+
await page.waitForTimeout(500);
|
|
115
|
+
|
|
116
|
+
const eventCalcNode = page.locator('.red-ui-palette-node[data-palette-type="event-calc"]');
|
|
117
|
+
const workspace = page.locator('#red-ui-workspace-chart');
|
|
118
|
+
await eventCalcNode.dragTo(workspace);
|
|
119
|
+
|
|
120
|
+
// Open the node editor
|
|
121
|
+
const calcNodeInWorkspace = page.locator('.red-ui-flow-node-group').last();
|
|
122
|
+
await calcNodeInWorkspace.dblclick();
|
|
123
|
+
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
124
|
+
|
|
125
|
+
// Check the External Trigger checkbox
|
|
126
|
+
const externalTriggerCheckbox = page.locator('#node-input-externalTrigger');
|
|
127
|
+
await externalTriggerCheckbox.check();
|
|
128
|
+
|
|
129
|
+
// Save the node configuration
|
|
130
|
+
const doneButton = page.locator('.red-ui-tray-footer button').filter({ hasText: 'Done' });
|
|
131
|
+
await doneButton.click();
|
|
132
|
+
await page.waitForTimeout(500);
|
|
133
|
+
|
|
134
|
+
// Reopen the node editor
|
|
135
|
+
await calcNodeInWorkspace.dblclick();
|
|
136
|
+
await page.waitForSelector('.red-ui-editor', { timeout: 5000 });
|
|
137
|
+
|
|
138
|
+
// Verify the checkbox is still checked
|
|
139
|
+
await expect(externalTriggerCheckbox).toBeChecked();
|
|
140
|
+
});
|
|
141
|
+
});
|