@bonsae/nrg 0.19.1 → 0.20.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/README.md +148 -2
- package/package.json +17 -11
- package/test/client/component/config.js +1 -0
- package/test/client/e2e/index.js +2 -1
- package/test/client/unit/config.js +2 -1
- package/test/server/integration/config.js +21 -0
- package/test/server/integration/index.js +375 -0
- package/test/server/unit/config.js +5 -1
- package/test/server/unit/index.js +4 -1
- package/tsconfig/test/server/integration.json +6 -0
- package/types/test-client-e2e.d.ts +1 -0
- package/types/test-server-integration.d.ts +523 -0
- package/types/test-server-unit.d.ts +12 -0
- package/types/vite.d.ts +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
<a href="https://socket.dev/npm/package/@bonsae/nrg"><img src="https://badge.socket.dev/npm/package/@bonsae/nrg?v=1" alt="Socket Badge"></a>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
+
> [!WARNING]
|
|
13
|
+
> While **nrg** is at `v0`, breaking changes can land in any release and will **not** bump the major version. Pin an exact version and review the release notes before upgrading.
|
|
14
|
+
|
|
12
15
|
# nrg
|
|
13
16
|
|
|
14
17
|
Build Node-RED nodes with Vue 3, TypeScript, JSON Schema validations, Vite and Vitest.
|
|
@@ -129,7 +132,7 @@ See the [consumer template](https://github.com/AllanOricil/node-red-vue-template
|
|
|
129
132
|
|
|
130
133
|
## Testing
|
|
131
134
|
|
|
132
|
-
NRG provides
|
|
135
|
+
NRG provides five test libraries and bundles most test infrastructure as direct dependencies. Install `vitest` plus any optional peer dependencies you need:
|
|
133
136
|
|
|
134
137
|
```bash
|
|
135
138
|
pnpm add -D vitest
|
|
@@ -146,16 +149,35 @@ Optional peer dependencies:
|
|
|
146
149
|
| `@vitest/coverage-istanbul` | Coverage with `--coverage` (Istanbul provider) |
|
|
147
150
|
|
|
148
151
|
- `@bonsae/nrg/test/server/unit` — server-side unit tests
|
|
152
|
+
- `@bonsae/nrg/test/server/integration` — server-side integration tests (real Node-RED runtime)
|
|
149
153
|
- `@bonsae/nrg/test/client/unit` — client-side unit tests (TypeScript logic)
|
|
150
154
|
- `@bonsae/nrg/test/client/component` — client component tests (Vue + browser)
|
|
151
155
|
- `@bonsae/nrg/test/client/e2e` — browser E2E tests (Playwright)
|
|
152
156
|
|
|
153
157
|
### Server Unit Tests
|
|
154
158
|
|
|
159
|
+
Instantiate your node with mocked Node-RED internals and exercise its full lifecycle in-process:
|
|
160
|
+
|
|
155
161
|
```typescript
|
|
162
|
+
// vitest.server.unit.config.ts
|
|
163
|
+
import { defineConfig, mergeConfig } from "vitest/config";
|
|
164
|
+
import { defaultConfig } from "@bonsae/nrg/test/server/unit/config";
|
|
165
|
+
|
|
166
|
+
export default mergeConfig(
|
|
167
|
+
defaultConfig,
|
|
168
|
+
defineConfig({
|
|
169
|
+
test: {
|
|
170
|
+
include: ["tests/server/unit/**/*.test.ts"],
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// tests/server/unit/my-node.test.ts
|
|
156
178
|
import { describe, it, expect } from "vitest";
|
|
157
179
|
import { createNode } from "@bonsae/nrg/test/server/unit";
|
|
158
|
-
import MyNode from "
|
|
180
|
+
import MyNode from "../../../src/server/nodes/my-node";
|
|
159
181
|
|
|
160
182
|
describe("my-node", () => {
|
|
161
183
|
it("should process messages", async () => {
|
|
@@ -170,6 +192,58 @@ describe("my-node", () => {
|
|
|
170
192
|
});
|
|
171
193
|
```
|
|
172
194
|
|
|
195
|
+
### Server Integration Tests
|
|
196
|
+
|
|
197
|
+
Boot a real, headless Node-RED runtime, deploy your nodes, and drive them with real messages — verifying config-node resolution, credentials, wiring, and context that unit mocks can't. Integration tests live in `tests/server/integration`, separate from `tests/server/unit`. Add `node-red` as a dev dependency, then:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// vitest.server.integration.config.ts
|
|
201
|
+
import { defineConfig, mergeConfig } from "vitest/config";
|
|
202
|
+
import { defaultConfig } from "@bonsae/nrg/test/server/integration/config";
|
|
203
|
+
|
|
204
|
+
export default mergeConfig(
|
|
205
|
+
defaultConfig,
|
|
206
|
+
defineConfig({
|
|
207
|
+
test: {
|
|
208
|
+
include: ["tests/server/integration/**/*.test.ts"],
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
// tests/server/integration/my-node.test.ts
|
|
216
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
217
|
+
import {
|
|
218
|
+
startRuntime,
|
|
219
|
+
type Runtime,
|
|
220
|
+
} from "@bonsae/nrg/test/server/integration";
|
|
221
|
+
import MyNode from "../../../src/server/nodes/my-node";
|
|
222
|
+
|
|
223
|
+
describe("my-node (integration)", () => {
|
|
224
|
+
let runtime: Runtime;
|
|
225
|
+
|
|
226
|
+
beforeAll(async () => {
|
|
227
|
+
runtime = await startRuntime({ nodes: [MyNode] });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
afterAll(async () => {
|
|
231
|
+
await runtime.stop();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("processes input in a real runtime", async () => {
|
|
235
|
+
const flow = runtime.flow();
|
|
236
|
+
const node = flow.addNode(MyNode, { greeting: "hello" });
|
|
237
|
+
await flow.deploy();
|
|
238
|
+
|
|
239
|
+
await node.receive({ payload: "world" });
|
|
240
|
+
|
|
241
|
+
const out = (await node.read()) as { output: { payload: string } };
|
|
242
|
+
expect(out.output.payload).toBe("hello world");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
173
247
|
### Client Unit Tests
|
|
174
248
|
|
|
175
249
|
Test client-side TypeScript logic (validation, utilities) with mocked `RED` and `$` globals:
|
|
@@ -257,6 +331,77 @@ describe("MyForm", () => {
|
|
|
257
331
|
});
|
|
258
332
|
```
|
|
259
333
|
|
|
334
|
+
### Client E2E Tests
|
|
335
|
+
|
|
336
|
+
Drive the real editor in a live Node-RED instance with Playwright — schema-driven forms, validation, TypedInput, config selectors, and i18n. Install `playwright`, then point a global setup at a flow and walk the editor with `NodeRedEditor`:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
// vitest.client.e2e.config.ts
|
|
340
|
+
import { defineConfig } from "vitest/config";
|
|
341
|
+
import { defaultConfig } from "@bonsae/nrg/test/client/e2e";
|
|
342
|
+
|
|
343
|
+
export default defineConfig({
|
|
344
|
+
test: {
|
|
345
|
+
...defaultConfig,
|
|
346
|
+
globalSetup: "tests/client/e2e/global-setup.ts",
|
|
347
|
+
include: ["tests/client/e2e/**/*.test.ts"],
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// tests/client/e2e/global-setup.ts
|
|
354
|
+
import {
|
|
355
|
+
setup as baseSetup,
|
|
356
|
+
teardown as baseTeardown,
|
|
357
|
+
} from "@bonsae/nrg/test/client/e2e";
|
|
358
|
+
|
|
359
|
+
export async function setup() {
|
|
360
|
+
await baseSetup({
|
|
361
|
+
flow: [
|
|
362
|
+
{ id: "tab1", type: "tab", label: "E2E Tests" },
|
|
363
|
+
{ id: "n1", type: "my-node", z: "tab1", name: "", wires: [[]] },
|
|
364
|
+
],
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function teardown() {
|
|
369
|
+
await baseTeardown();
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// tests/client/e2e/my-node.test.ts
|
|
375
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
376
|
+
import { chromium, type Browser } from "playwright";
|
|
377
|
+
import { NodeRedEditor } from "@bonsae/nrg/test/client/e2e";
|
|
378
|
+
|
|
379
|
+
describe("my-node editor", () => {
|
|
380
|
+
let browser: Browser;
|
|
381
|
+
let editor: NodeRedEditor;
|
|
382
|
+
|
|
383
|
+
beforeAll(async () => {
|
|
384
|
+
browser = await chromium.launch();
|
|
385
|
+
const port = Number(process.env.NODE_RED_PORT);
|
|
386
|
+
editor = new NodeRedEditor(await browser.newPage(), port);
|
|
387
|
+
await editor.open();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
afterAll(() => browser.close());
|
|
391
|
+
|
|
392
|
+
test("name field round-trips", async () => {
|
|
393
|
+
await editor.editNode("n1");
|
|
394
|
+
const name = editor.field("Name");
|
|
395
|
+
await name.fill("Test Node");
|
|
396
|
+
await editor.clickDone();
|
|
397
|
+
|
|
398
|
+
await editor.editNode("n1");
|
|
399
|
+
expect(await name.getValue()).toBe("Test Node");
|
|
400
|
+
await editor.clickCancel();
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
260
405
|
See the [testing guide](https://bonsaedev.github.io/nrg/guide/testing) for full API reference.
|
|
261
406
|
|
|
262
407
|
## Development
|
|
@@ -270,6 +415,7 @@ pnpm validate:lint # eslint
|
|
|
270
415
|
pnpm validate:format # prettier check
|
|
271
416
|
pnpm test # run all tests
|
|
272
417
|
pnpm test:core:server:unit # server unit tests
|
|
418
|
+
pnpm test:core:server:integration # server integration tests (real Node-RED)
|
|
273
419
|
pnpm test:core:client:unit # client unit tests
|
|
274
420
|
pnpm test:core:client:component # client component tests
|
|
275
421
|
pnpm test:core:client:e2e # client E2E tests
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bonsae/nrg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "NRG framework — build Node-RED nodes with Vue 3, TypeScript, and JSON Schema",
|
|
5
5
|
"author": "Allan Oricil <allanoricil@duck.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,6 +52,11 @@
|
|
|
52
52
|
"default": "./test/server/unit/index.js"
|
|
53
53
|
},
|
|
54
54
|
"./test/server/unit/config": "./test/server/unit/config.js",
|
|
55
|
+
"./test/server/integration": {
|
|
56
|
+
"types": "./types/test-server-integration.d.ts",
|
|
57
|
+
"default": "./test/server/integration/index.js"
|
|
58
|
+
},
|
|
59
|
+
"./test/server/integration/config": "./test/server/integration/config.js",
|
|
55
60
|
"./test/client/component": {
|
|
56
61
|
"types": "./types/test-client-component.d.ts",
|
|
57
62
|
"default": "./test/client/component/index.js"
|
|
@@ -72,19 +77,20 @@
|
|
|
72
77
|
"./tsconfig/core/server.json": "./tsconfig/core/server.json",
|
|
73
78
|
"./tsconfig/core/client.json": "./tsconfig/core/client.json",
|
|
74
79
|
"./tsconfig/test/server/unit.json": "./tsconfig/test/server/unit.json",
|
|
80
|
+
"./tsconfig/test/server/integration.json": "./tsconfig/test/server/integration.json",
|
|
75
81
|
"./tsconfig/test/client/component.json": "./tsconfig/test/client/component.json",
|
|
76
82
|
"./tsconfig/test/client/unit.json": "./tsconfig/test/client/unit.json",
|
|
77
83
|
"./tsconfig/test/client/e2e.json": "./tsconfig/test/client/e2e.json"
|
|
78
84
|
},
|
|
79
85
|
"peerDependencies": {
|
|
80
|
-
"
|
|
81
|
-
"vitest": "^4.0.0",
|
|
82
|
-
"vue": "^3.5.14",
|
|
86
|
+
"@vitest/browser-playwright": "^4.0.0",
|
|
83
87
|
"@vitest/coverage-istanbul": "^4.0.0",
|
|
84
88
|
"@vitest/coverage-v8": "^4.0.0",
|
|
85
|
-
"@vitest/browser-playwright": "^4.0.0",
|
|
86
89
|
"playwright": "^1.50.0",
|
|
87
|
-
"
|
|
90
|
+
"vite": "^6.0.0",
|
|
91
|
+
"vitest": "^4.0.0",
|
|
92
|
+
"vitest-browser-vue": "^2.0.0",
|
|
93
|
+
"vue": "^3.5.14"
|
|
88
94
|
},
|
|
89
95
|
"peerDependenciesMeta": {
|
|
90
96
|
"@vitest/coverage-istanbul": {
|
|
@@ -107,6 +113,7 @@
|
|
|
107
113
|
"@clack/prompts": "^1.0.1",
|
|
108
114
|
"@sinclair/typebox": "^0.34.33",
|
|
109
115
|
"@vitejs/plugin-vue": "^5.2.3",
|
|
116
|
+
"@vitest/browser-playwright": "^4.1.5",
|
|
110
117
|
"ajv": "^8.17.1",
|
|
111
118
|
"ajv-errors": "^3.0.0",
|
|
112
119
|
"ajv-formats": "^3.0.1",
|
|
@@ -115,16 +122,15 @@
|
|
|
115
122
|
"es-toolkit": "^1.37.2",
|
|
116
123
|
"esbuild": "^0.25.4",
|
|
117
124
|
"get-port": "^7.1.0",
|
|
125
|
+
"happy-dom": "^20.10.2",
|
|
118
126
|
"jsonpointer": "^5.0.1",
|
|
119
127
|
"mime-types": "^3.0.1",
|
|
128
|
+
"playwright": "^1.60.0",
|
|
120
129
|
"tree-kill": "^1.2.2",
|
|
121
130
|
"typescript": "^5.8.3",
|
|
122
131
|
"vite-plugin-dts": "^4.5.4",
|
|
123
132
|
"vite-plugin-static-copy": "^3.1.0",
|
|
124
|
-
"vue": "^
|
|
125
|
-
"
|
|
126
|
-
"happy-dom": "^20.10.2",
|
|
127
|
-
"playwright": "^1.60.0",
|
|
128
|
-
"vitest-browser-vue": "^2.1.0"
|
|
133
|
+
"vitest-browser-vue": "^2.1.0",
|
|
134
|
+
"vue": "^3.5.14"
|
|
129
135
|
}
|
|
130
136
|
}
|
package/test/client/e2e/index.js
CHANGED
|
@@ -2667,7 +2667,8 @@ var NodeRedTestEnvironment = class {
|
|
|
2667
2667
|
var defaultConfig = {
|
|
2668
2668
|
testTimeout: 6e4,
|
|
2669
2669
|
hookTimeout: 12e4,
|
|
2670
|
-
globalSetup: ["@bonsae/nrg/test/client/e2e"]
|
|
2670
|
+
globalSetup: ["@bonsae/nrg/test/client/e2e"],
|
|
2671
|
+
include: ["tests/client/e2e/**/*.test.ts"]
|
|
2671
2672
|
};
|
|
2672
2673
|
var _env = null;
|
|
2673
2674
|
async function setup(options) {
|
|
@@ -18,7 +18,8 @@ var defaultConfig = {
|
|
|
18
18
|
test: {
|
|
19
19
|
testTimeout: 3e4,
|
|
20
20
|
environment: "happy-dom",
|
|
21
|
-
setupFiles: ["@bonsae/nrg/test/client/unit/setup"]
|
|
21
|
+
setupFiles: ["@bonsae/nrg/test/client/unit/setup"],
|
|
22
|
+
include: ["tests/client/unit/**/*.test.ts"]
|
|
22
23
|
}
|
|
23
24
|
};
|
|
24
25
|
export {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/test/server/integration/config.ts
|
|
2
|
+
import path from "path";
|
|
3
|
+
var defaultConfig = {
|
|
4
|
+
resolve: {
|
|
5
|
+
alias: {
|
|
6
|
+
"@": path.resolve(process.cwd(), "src")
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
test: {
|
|
10
|
+
testTimeout: 3e4,
|
|
11
|
+
hookTimeout: 3e4,
|
|
12
|
+
pool: "forks",
|
|
13
|
+
fileParallelism: false,
|
|
14
|
+
// integration tests live under tests/server/integration, separate from the
|
|
15
|
+
// unit tier (tests/server/unit) so the two never overlap
|
|
16
|
+
include: ["tests/server/integration/**/*.test.ts"]
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
export {
|
|
20
|
+
defaultConfig
|
|
21
|
+
};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// src/test/server/integration/runtime.ts
|
|
2
|
+
import http from "http";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { createRequire } from "module";
|
|
7
|
+
import { registerTypes } from "@bonsae/nrg/server";
|
|
8
|
+
|
|
9
|
+
// src/test/server/integration/recorder.ts
|
|
10
|
+
var Recorder = class {
|
|
11
|
+
#sent = /* @__PURE__ */ new Map();
|
|
12
|
+
#received = /* @__PURE__ */ new Map();
|
|
13
|
+
#waiters = [];
|
|
14
|
+
recordSent(id, port, msg) {
|
|
15
|
+
this.#push("sent", id, { port, msg });
|
|
16
|
+
}
|
|
17
|
+
recordReceived(id, msg) {
|
|
18
|
+
if (!id) return;
|
|
19
|
+
this.#push("received", id, { port: 0, msg });
|
|
20
|
+
}
|
|
21
|
+
/** Snapshot of all messages on a channel for a node (optionally one port). */
|
|
22
|
+
snapshot(channel, id, port) {
|
|
23
|
+
return this.#filter(channel, id, port).map((c) => c.msg);
|
|
24
|
+
}
|
|
25
|
+
/** Resolve the message at `index` on a channel, awaiting it if not yet seen. */
|
|
26
|
+
next(channel, id, port, index, timeoutMs) {
|
|
27
|
+
const existing = this.#filter(channel, id, port);
|
|
28
|
+
if (existing.length > index) return Promise.resolve(existing[index].msg);
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const waiter = {
|
|
31
|
+
channel,
|
|
32
|
+
id,
|
|
33
|
+
port,
|
|
34
|
+
index,
|
|
35
|
+
settle: (msg) => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
resolve(msg);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
this.#waiters = this.#waiters.filter((w) => w !== waiter);
|
|
42
|
+
const where = port === void 0 ? "" : ` on port ${port}`;
|
|
43
|
+
reject(
|
|
44
|
+
new Error(
|
|
45
|
+
`Timed out after ${timeoutMs}ms waiting for ${channel} message #${index} from node ${id}${where}`
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
}, timeoutMs);
|
|
49
|
+
this.#waiters.push(waiter);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
clear() {
|
|
53
|
+
this.#sent.clear();
|
|
54
|
+
this.#received.clear();
|
|
55
|
+
this.#waiters = [];
|
|
56
|
+
}
|
|
57
|
+
#map(channel) {
|
|
58
|
+
return channel === "sent" ? this.#sent : this.#received;
|
|
59
|
+
}
|
|
60
|
+
#filter(channel, id, port) {
|
|
61
|
+
const list = this.#map(channel).get(id) ?? [];
|
|
62
|
+
return port === void 0 ? list : list.filter((c) => c.port === port);
|
|
63
|
+
}
|
|
64
|
+
#push(channel, id, captured) {
|
|
65
|
+
const map = this.#map(channel);
|
|
66
|
+
const list = map.get(id) ?? [];
|
|
67
|
+
list.push(captured);
|
|
68
|
+
map.set(id, list);
|
|
69
|
+
for (let i = this.#waiters.length - 1; i >= 0; i--) {
|
|
70
|
+
const w = this.#waiters[i];
|
|
71
|
+
if (w.channel !== channel || w.id !== id) continue;
|
|
72
|
+
const filtered = this.#filter(channel, id, w.port);
|
|
73
|
+
if (filtered.length > w.index) {
|
|
74
|
+
this.#waiters.splice(i, 1);
|
|
75
|
+
w.settle(filtered[w.index].msg);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/core/server/nodes/utils.ts
|
|
82
|
+
import { Kind } from "@sinclair/typebox";
|
|
83
|
+
function setupContext(context, store) {
|
|
84
|
+
return {
|
|
85
|
+
get: (key) => new Promise(
|
|
86
|
+
(resolve, reject) => context.get(
|
|
87
|
+
key,
|
|
88
|
+
store,
|
|
89
|
+
(error, value) => error ? reject(error) : resolve(value)
|
|
90
|
+
)
|
|
91
|
+
),
|
|
92
|
+
set: (key, value) => new Promise(
|
|
93
|
+
(resolve, reject) => context.set(
|
|
94
|
+
key,
|
|
95
|
+
value,
|
|
96
|
+
store,
|
|
97
|
+
(error) => error ? reject(error) : resolve()
|
|
98
|
+
)
|
|
99
|
+
),
|
|
100
|
+
keys: () => new Promise(
|
|
101
|
+
(resolve, reject) => context.keys(store, (error, k) => error ? reject(error) : resolve(k))
|
|
102
|
+
)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/test/server/integration/flow.ts
|
|
107
|
+
var seq = 0;
|
|
108
|
+
function genId(prefix) {
|
|
109
|
+
seq += 1;
|
|
110
|
+
return `${prefix}${seq.toString(36)}`;
|
|
111
|
+
}
|
|
112
|
+
function tick() {
|
|
113
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
114
|
+
}
|
|
115
|
+
var NodeRef = class {
|
|
116
|
+
id;
|
|
117
|
+
type;
|
|
118
|
+
isConfig;
|
|
119
|
+
name;
|
|
120
|
+
config;
|
|
121
|
+
credentials;
|
|
122
|
+
wires = [];
|
|
123
|
+
#flow;
|
|
124
|
+
#readCursor = /* @__PURE__ */ new Map();
|
|
125
|
+
constructor(flow, type, isConfig, config, opts) {
|
|
126
|
+
this.#flow = flow;
|
|
127
|
+
this.type = type;
|
|
128
|
+
this.isConfig = isConfig;
|
|
129
|
+
this.config = config;
|
|
130
|
+
this.credentials = opts.credentials;
|
|
131
|
+
this.id = opts.id ?? genId("n");
|
|
132
|
+
this.name = opts.name ?? "";
|
|
133
|
+
}
|
|
134
|
+
/** Wire this node's output `port` to `target`'s input. */
|
|
135
|
+
wire(target, port = 0) {
|
|
136
|
+
while (this.wires.length <= port) this.wires.push([]);
|
|
137
|
+
this.wires[port].push(target.id);
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
/** Deliver a message to this node's input (Node-RED's upstream path). */
|
|
141
|
+
async receive(msg) {
|
|
142
|
+
const node = this.#flow.runtimeNode(this.id);
|
|
143
|
+
if (!node) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Node "${this.id}" (${this.type}) is not deployed \u2014 call flow.deploy() first`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
node.receive(msg);
|
|
149
|
+
await tick();
|
|
150
|
+
}
|
|
151
|
+
/** Snapshot of everything this node has emitted (optionally one port). */
|
|
152
|
+
sent(port) {
|
|
153
|
+
return this.#flow.recorder.snapshot("sent", this.id, port);
|
|
154
|
+
}
|
|
155
|
+
/** Snapshot of everything delivered to this node's input. */
|
|
156
|
+
received(port) {
|
|
157
|
+
return this.#flow.recorder.snapshot("received", this.id, port);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Promise-based access to this node's context stores (`node` / `flow` /
|
|
161
|
+
* `global`) — preset values before `receive`, and assert them afterward.
|
|
162
|
+
*/
|
|
163
|
+
get context() {
|
|
164
|
+
const rn = this.#flow.runtimeNode(this.id);
|
|
165
|
+
if (!rn) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Node "${this.id}" (${this.type}) is not deployed \u2014 call flow.deploy() first`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const ctx = rn.context();
|
|
171
|
+
return {
|
|
172
|
+
node: setupContext(ctx),
|
|
173
|
+
flow: setupContext(ctx.flow),
|
|
174
|
+
global: setupContext(ctx.global)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Consume the next un-read message this node emitted (FIFO cursor), awaiting
|
|
179
|
+
* it if not yet sent. Call repeatedly to walk multiple emissions.
|
|
180
|
+
*/
|
|
181
|
+
async read(port, opts = {}) {
|
|
182
|
+
const cursor = this.#readCursor.get(port) ?? 0;
|
|
183
|
+
const msg = await this.#flow.recorder.next(
|
|
184
|
+
"sent",
|
|
185
|
+
this.id,
|
|
186
|
+
port,
|
|
187
|
+
cursor,
|
|
188
|
+
opts.timeout ?? 5e3
|
|
189
|
+
);
|
|
190
|
+
this.#readCursor.set(port, cursor + 1);
|
|
191
|
+
return msg;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
var Flow = class {
|
|
195
|
+
recorder;
|
|
196
|
+
#RED;
|
|
197
|
+
#flowId = genId("flow");
|
|
198
|
+
#nodes = [];
|
|
199
|
+
constructor(RED, recorder) {
|
|
200
|
+
this.#RED = RED;
|
|
201
|
+
this.recorder = recorder;
|
|
202
|
+
}
|
|
203
|
+
/** Add any node — regular or config (detected via `category === "config"`). */
|
|
204
|
+
addNode(Cls, config = {}, opts = {}) {
|
|
205
|
+
const isConfig = Cls.category === "config";
|
|
206
|
+
const ref = new NodeRef(this, Cls.type, isConfig, config, opts);
|
|
207
|
+
this.#nodes.push(ref);
|
|
208
|
+
return ref;
|
|
209
|
+
}
|
|
210
|
+
/** Build the flow JSON and deploy it; resolves once the flow has started. */
|
|
211
|
+
async deploy() {
|
|
212
|
+
this.recorder.clear();
|
|
213
|
+
const flows = this.#buildFlows();
|
|
214
|
+
await this.#RED.runtime.flows.setFlows({
|
|
215
|
+
flows: { flows },
|
|
216
|
+
deploymentType: "full"
|
|
217
|
+
});
|
|
218
|
+
await this.#waitForDeploy();
|
|
219
|
+
}
|
|
220
|
+
/** Drop the built nodes and clear captured messages (reset between tests). */
|
|
221
|
+
async clear() {
|
|
222
|
+
this.#nodes = [];
|
|
223
|
+
this.recorder.clear();
|
|
224
|
+
await this.#RED.runtime.flows.setFlows({
|
|
225
|
+
flows: { flows: [] },
|
|
226
|
+
deploymentType: "full"
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
runtimeNode(id) {
|
|
230
|
+
return this.#RED.nodes.getNode(id);
|
|
231
|
+
}
|
|
232
|
+
#buildFlows() {
|
|
233
|
+
const flows = [
|
|
234
|
+
{ id: this.#flowId, type: "tab", label: "nrg-integration" }
|
|
235
|
+
];
|
|
236
|
+
for (const ref of this.#nodes) {
|
|
237
|
+
const base = {
|
|
238
|
+
id: ref.id,
|
|
239
|
+
type: ref.type,
|
|
240
|
+
name: ref.name,
|
|
241
|
+
...this.#serializeConfig(ref.config),
|
|
242
|
+
...ref.credentials ? { credentials: ref.credentials } : {}
|
|
243
|
+
};
|
|
244
|
+
flows.push(
|
|
245
|
+
ref.isConfig ? base : {
|
|
246
|
+
...base,
|
|
247
|
+
z: this.#flowId,
|
|
248
|
+
wires: ref.wires.length ? ref.wires : [[]]
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return flows;
|
|
253
|
+
}
|
|
254
|
+
/** NodeRef config values serialize to the referenced node's id (a real NodeRef). */
|
|
255
|
+
#serializeConfig(config) {
|
|
256
|
+
const out = {};
|
|
257
|
+
for (const [key, value] of Object.entries(config)) {
|
|
258
|
+
out[key] = value instanceof NodeRef ? value.id : value;
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
async #waitForDeploy() {
|
|
263
|
+
const target = this.#nodes.find((n) => !n.isConfig);
|
|
264
|
+
if (!target) return;
|
|
265
|
+
const deadline = Date.now() + 5e3;
|
|
266
|
+
while (Date.now() < deadline && !this.#RED.nodes.getNode(target.id)) {
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
268
|
+
}
|
|
269
|
+
if (!this.#RED.nodes.getNode(target.id)) {
|
|
270
|
+
throw new Error("Flow deploy did not complete within 5s");
|
|
271
|
+
}
|
|
272
|
+
for (const ref of this.#nodes) {
|
|
273
|
+
if (ref.isConfig) continue;
|
|
274
|
+
const rn = this.#RED.nodes.getNode(ref.id);
|
|
275
|
+
if (rn) this.#wrapSend(rn, ref.id);
|
|
276
|
+
}
|
|
277
|
+
await tick();
|
|
278
|
+
}
|
|
279
|
+
#wrapSend(rn, id) {
|
|
280
|
+
const recorder = this.recorder;
|
|
281
|
+
const original = rn.send.bind(rn);
|
|
282
|
+
rn.send = (arg) => {
|
|
283
|
+
if (Array.isArray(arg)) {
|
|
284
|
+
arg.forEach((m, port) => {
|
|
285
|
+
if (m != null) recorder.recordSent(id, port, m);
|
|
286
|
+
});
|
|
287
|
+
} else if (arg != null) {
|
|
288
|
+
recorder.recordSent(id, 0, arg);
|
|
289
|
+
}
|
|
290
|
+
return original(arg);
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// src/test/server/integration/runtime.ts
|
|
296
|
+
function requireNodeRed() {
|
|
297
|
+
const req = createRequire(path.join(process.cwd(), "package.json"));
|
|
298
|
+
let entry;
|
|
299
|
+
try {
|
|
300
|
+
entry = req.resolve("node-red");
|
|
301
|
+
} catch {
|
|
302
|
+
throw new Error(
|
|
303
|
+
"Integration tests need Node-RED installed. Add `node-red` as a devDependency."
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return req(entry);
|
|
307
|
+
}
|
|
308
|
+
function headlessSettings(userDir, overrides) {
|
|
309
|
+
return {
|
|
310
|
+
// keep RED.httpAdmin a valid router (so node registration's route setup is
|
|
311
|
+
// safe) but never serve the editor UI
|
|
312
|
+
disableEditor: true,
|
|
313
|
+
httpNodeRoot: false,
|
|
314
|
+
userDir,
|
|
315
|
+
flowFile: path.join(userDir, "flows.json"),
|
|
316
|
+
credentialSecret: "nrg-integration-test",
|
|
317
|
+
logging: { console: { level: "fatal", metrics: false, audit: false } },
|
|
318
|
+
functionGlobalContext: {},
|
|
319
|
+
...overrides
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
async function startRuntime(options) {
|
|
323
|
+
const RED = requireNodeRed();
|
|
324
|
+
const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "nrg-integration-"));
|
|
325
|
+
const server = http.createServer();
|
|
326
|
+
await new Promise(
|
|
327
|
+
(resolve) => server.listen(0, "127.0.0.1", () => resolve())
|
|
328
|
+
);
|
|
329
|
+
RED.init(server, headlessSettings(userDir, options.settings));
|
|
330
|
+
const recorder = new Recorder();
|
|
331
|
+
RED.hooks.add(
|
|
332
|
+
"onReceive",
|
|
333
|
+
(event) => recorder.recordReceived(event.destination?.id, event.msg)
|
|
334
|
+
);
|
|
335
|
+
await registerTypes(options.nodes)(
|
|
336
|
+
RED
|
|
337
|
+
);
|
|
338
|
+
await RED.start();
|
|
339
|
+
await new Flow(RED, recorder).deploy();
|
|
340
|
+
return new Runtime(RED, server, userDir, recorder);
|
|
341
|
+
}
|
|
342
|
+
var Runtime = class {
|
|
343
|
+
#RED;
|
|
344
|
+
#server;
|
|
345
|
+
#userDir;
|
|
346
|
+
#recorder;
|
|
347
|
+
constructor(RED, server, userDir, recorder) {
|
|
348
|
+
this.#RED = RED;
|
|
349
|
+
this.#server = server;
|
|
350
|
+
this.#userDir = userDir;
|
|
351
|
+
this.#recorder = recorder;
|
|
352
|
+
}
|
|
353
|
+
/** Start a fresh flow to build, deploy, drive and inspect. */
|
|
354
|
+
flow() {
|
|
355
|
+
return new Flow(this.#RED, this.#recorder);
|
|
356
|
+
}
|
|
357
|
+
/** Stop Node-RED, close the server and remove the temp user dir. */
|
|
358
|
+
async stop() {
|
|
359
|
+
try {
|
|
360
|
+
await this.#RED.stop();
|
|
361
|
+
} catch {
|
|
362
|
+
}
|
|
363
|
+
await new Promise((resolve) => this.#server.close(() => resolve()));
|
|
364
|
+
try {
|
|
365
|
+
fs.rmSync(this.#userDir, { recursive: true, force: true });
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
export {
|
|
371
|
+
Flow,
|
|
372
|
+
NodeRef,
|
|
373
|
+
Runtime,
|
|
374
|
+
startRuntime
|
|
375
|
+
};
|
|
@@ -7,7 +7,11 @@ var defaultConfig = {
|
|
|
7
7
|
}
|
|
8
8
|
},
|
|
9
9
|
test: {
|
|
10
|
-
testTimeout: 3e4
|
|
10
|
+
testTimeout: 3e4,
|
|
11
|
+
// unit tests live under tests/server/unit; integration tests live under
|
|
12
|
+
// tests/server/integration and run via their own config — the two tiers are
|
|
13
|
+
// separated by folder, so no exclude is needed
|
|
14
|
+
include: ["tests/server/unit/**/*.test.ts"]
|
|
11
15
|
}
|
|
12
16
|
};
|
|
13
17
|
export {
|
|
@@ -504,7 +504,10 @@ function attachHelpers(node, nodeRedNode, NodeClass) {
|
|
|
504
504
|
},
|
|
505
505
|
errored() {
|
|
506
506
|
return nodeRedNode.error.mock.calls.map((c) => c[0]);
|
|
507
|
-
}
|
|
507
|
+
},
|
|
508
|
+
// expose the node's own (already promise-wrapped) context stores; the
|
|
509
|
+
// node keeps using the same object internally, callable form included
|
|
510
|
+
context: node.context
|
|
508
511
|
};
|
|
509
512
|
return Object.assign(node, helpers);
|
|
510
513
|
}
|
|
@@ -89,6 +89,7 @@ export declare const defaultConfig: {
|
|
|
89
89
|
testTimeout: number;
|
|
90
90
|
hookTimeout: number;
|
|
91
91
|
globalSetup: string[];
|
|
92
|
+
include: string[];
|
|
92
93
|
};
|
|
93
94
|
export declare function setup(options?: SetupOptions): Promise<void>;
|
|
94
95
|
export declare function teardown(): Promise<void>;
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// Generated by dts-bundle-generator v9.5.1
|
|
2
|
+
|
|
3
|
+
import { SchemaOptions, Static, TObject, TOptional, TProperties, TSchema, TString } from '@sinclair/typebox';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
import { Express as Express$1 } from 'express';
|
|
6
|
+
import { Http2ServerRequest } from 'http2';
|
|
7
|
+
|
|
8
|
+
interface NodeRedRuntimeSettings {
|
|
9
|
+
userDir?: string;
|
|
10
|
+
nodesDir?: string | string[];
|
|
11
|
+
flowFile?: string;
|
|
12
|
+
flowFilePretty?: boolean;
|
|
13
|
+
credentialSecret?: string | false;
|
|
14
|
+
requireHttps?: boolean;
|
|
15
|
+
https?: {
|
|
16
|
+
key: string;
|
|
17
|
+
cert: string;
|
|
18
|
+
} | (() => Promise<{
|
|
19
|
+
key: string;
|
|
20
|
+
cert: string;
|
|
21
|
+
}> | {
|
|
22
|
+
key: string;
|
|
23
|
+
cert: string;
|
|
24
|
+
});
|
|
25
|
+
httpsRefreshInterval?: number;
|
|
26
|
+
httpAdminRoot?: string;
|
|
27
|
+
httpNodeRoot?: string;
|
|
28
|
+
httpNodeCors?: {
|
|
29
|
+
origin: string;
|
|
30
|
+
methods: string;
|
|
31
|
+
};
|
|
32
|
+
httpStatic?: string | {
|
|
33
|
+
path: string;
|
|
34
|
+
root: string;
|
|
35
|
+
}[];
|
|
36
|
+
httpStaticRoot?: string;
|
|
37
|
+
httpAdminMiddleware?: (req: unknown, res: unknown, next: () => void) => void;
|
|
38
|
+
httpNodeMiddleware?: (req: unknown, res: unknown, next: () => void) => void;
|
|
39
|
+
httpServerOptions?: Record<string, unknown>;
|
|
40
|
+
adminAuth?: {
|
|
41
|
+
type?: "credentials" | "strategy";
|
|
42
|
+
users?: {
|
|
43
|
+
username: string;
|
|
44
|
+
password: string;
|
|
45
|
+
permissions?: string | string[];
|
|
46
|
+
}[];
|
|
47
|
+
default?: {
|
|
48
|
+
permissions?: string | string[];
|
|
49
|
+
};
|
|
50
|
+
tokens?: (token: string) => Promise<{
|
|
51
|
+
user: string;
|
|
52
|
+
permissions: string | string[];
|
|
53
|
+
} | null>;
|
|
54
|
+
tokenHeader: "string";
|
|
55
|
+
sessionExpiryTime?: number;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
};
|
|
58
|
+
httpNodeAuth?: {
|
|
59
|
+
user?: string;
|
|
60
|
+
pass?: string;
|
|
61
|
+
};
|
|
62
|
+
httpStaticAuth?: {
|
|
63
|
+
user?: string;
|
|
64
|
+
pass?: string;
|
|
65
|
+
};
|
|
66
|
+
lang?: "en-US" | "de" | "es-ES" | "fr" | "ko" | "pt-BR" | "ru" | "ja" | "zh-CN" | "zh-TW";
|
|
67
|
+
diagnostics?: {
|
|
68
|
+
enabled?: boolean;
|
|
69
|
+
ui?: boolean;
|
|
70
|
+
};
|
|
71
|
+
runtimeState?: {
|
|
72
|
+
enabled?: boolean;
|
|
73
|
+
ui?: boolean;
|
|
74
|
+
};
|
|
75
|
+
disableEditor?: boolean;
|
|
76
|
+
editorTheme?: {
|
|
77
|
+
page?: {
|
|
78
|
+
title?: string;
|
|
79
|
+
favicon?: string;
|
|
80
|
+
css?: string | string[];
|
|
81
|
+
scripts?: string | string[];
|
|
82
|
+
};
|
|
83
|
+
header?: {
|
|
84
|
+
title?: string;
|
|
85
|
+
image?: string;
|
|
86
|
+
url?: string;
|
|
87
|
+
};
|
|
88
|
+
deployButton?: {
|
|
89
|
+
type?: "simple" | "default";
|
|
90
|
+
label?: string;
|
|
91
|
+
icon?: string;
|
|
92
|
+
};
|
|
93
|
+
menu?: {
|
|
94
|
+
"menu-item-import-library"?: boolean;
|
|
95
|
+
"menu-item-export-library"?: boolean;
|
|
96
|
+
"menu-item-keyboard-shortcuts"?: boolean;
|
|
97
|
+
"menu-item-help"?: {
|
|
98
|
+
label?: string;
|
|
99
|
+
url?: string;
|
|
100
|
+
};
|
|
101
|
+
[menuItem: string]: boolean | {
|
|
102
|
+
label?: string;
|
|
103
|
+
url?: string;
|
|
104
|
+
} | undefined;
|
|
105
|
+
};
|
|
106
|
+
userMenu?: boolean;
|
|
107
|
+
login?: {
|
|
108
|
+
image?: string;
|
|
109
|
+
};
|
|
110
|
+
logout?: {
|
|
111
|
+
redirect?: string;
|
|
112
|
+
};
|
|
113
|
+
palette?: {
|
|
114
|
+
catalogues?: string[];
|
|
115
|
+
categories?: string[];
|
|
116
|
+
theme?: {
|
|
117
|
+
category: string;
|
|
118
|
+
type: string;
|
|
119
|
+
color: string;
|
|
120
|
+
}[];
|
|
121
|
+
};
|
|
122
|
+
projects?: {
|
|
123
|
+
enabled?: boolean;
|
|
124
|
+
workflow?: {
|
|
125
|
+
mode: "manual" | "auto";
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
codeEditor?: {
|
|
129
|
+
lib?: "monaco" | "ace";
|
|
130
|
+
options?: Record<string, unknown>;
|
|
131
|
+
};
|
|
132
|
+
mermaid?: {
|
|
133
|
+
theme?: string;
|
|
134
|
+
};
|
|
135
|
+
tours?: boolean;
|
|
136
|
+
theme?: string;
|
|
137
|
+
[key: string]: unknown;
|
|
138
|
+
};
|
|
139
|
+
contextStorage?: {
|
|
140
|
+
default?: {
|
|
141
|
+
module?: "memory" | "localfilesystem" | object;
|
|
142
|
+
config?: Record<string, unknown>;
|
|
143
|
+
};
|
|
144
|
+
[store: string]: {
|
|
145
|
+
module?: "memory" | "localfilesystem" | object;
|
|
146
|
+
config?: Record<string, unknown>;
|
|
147
|
+
} | undefined;
|
|
148
|
+
};
|
|
149
|
+
exportGlobalContextKeys?: boolean;
|
|
150
|
+
logging?: {
|
|
151
|
+
console?: {
|
|
152
|
+
level?: "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "off";
|
|
153
|
+
metrics?: boolean;
|
|
154
|
+
audit?: boolean;
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
fileWorkingDirectory?: string;
|
|
158
|
+
functionExternalModules?: boolean;
|
|
159
|
+
functionGlobalContext?: Record<string, unknown>;
|
|
160
|
+
nodeMessageBufferMaxLength?: number;
|
|
161
|
+
functionTimeout?: number;
|
|
162
|
+
externalModules?: {
|
|
163
|
+
autoInstall?: boolean;
|
|
164
|
+
autoInstallRetry?: number;
|
|
165
|
+
palette?: {
|
|
166
|
+
allowInstall?: boolean;
|
|
167
|
+
allowUpdate?: boolean;
|
|
168
|
+
allowUpload?: boolean;
|
|
169
|
+
allowList?: string[];
|
|
170
|
+
denyList?: string[];
|
|
171
|
+
allowUpdateList?: string[];
|
|
172
|
+
denyUpdateList?: string[];
|
|
173
|
+
};
|
|
174
|
+
modules?: {
|
|
175
|
+
allowInstall?: boolean;
|
|
176
|
+
allowList?: string[];
|
|
177
|
+
denyList?: string[];
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
execMaxBufferSize?: number;
|
|
181
|
+
debugMaxLength?: number;
|
|
182
|
+
debugUseColors?: boolean;
|
|
183
|
+
httpRequestTimeout?: number;
|
|
184
|
+
mqttReconnectTime?: number;
|
|
185
|
+
serialReconnectTime?: number;
|
|
186
|
+
socketReconnectTime?: number;
|
|
187
|
+
socketTimeout?: number;
|
|
188
|
+
tcpMsgQueueSize?: number;
|
|
189
|
+
inboundWebSocketTimeout?: number;
|
|
190
|
+
tlsConfigDisableLocalFiles?: boolean;
|
|
191
|
+
webSocketNodeVerifyClient?: (info: {
|
|
192
|
+
origin: string;
|
|
193
|
+
req: Http2ServerRequest;
|
|
194
|
+
secure: boolean;
|
|
195
|
+
}) => boolean;
|
|
196
|
+
apiMaxLength?: string;
|
|
197
|
+
[key: string]: unknown;
|
|
198
|
+
}
|
|
199
|
+
declare const NodeConfigSchema: import("@sinclair/typebox").TObject<{
|
|
200
|
+
id: import("@sinclair/typebox").TString;
|
|
201
|
+
type: import("@sinclair/typebox").TString;
|
|
202
|
+
name: import("@sinclair/typebox").TString;
|
|
203
|
+
z: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
204
|
+
}>;
|
|
205
|
+
interface NodeRedLog {
|
|
206
|
+
info(msg: any): void;
|
|
207
|
+
warn(msg: any): void;
|
|
208
|
+
error(msg: any, error?: any): void;
|
|
209
|
+
debug(msg: any): void;
|
|
210
|
+
trace(msg: any): void;
|
|
211
|
+
log(msg: {
|
|
212
|
+
level: number;
|
|
213
|
+
msg: string;
|
|
214
|
+
}): void;
|
|
215
|
+
metric(): boolean;
|
|
216
|
+
audit(msg: Record<string, any>, req?: any): void;
|
|
217
|
+
addHandler(handler: (msg: any) => void): void;
|
|
218
|
+
removeHandler(handler: (msg: any) => void): void;
|
|
219
|
+
FATAL: 10;
|
|
220
|
+
ERROR: 20;
|
|
221
|
+
WARN: 30;
|
|
222
|
+
INFO: 40;
|
|
223
|
+
DEBUG: 50;
|
|
224
|
+
TRACE: 60;
|
|
225
|
+
AUDIT: 98;
|
|
226
|
+
METRIC: 99;
|
|
227
|
+
}
|
|
228
|
+
interface NodeRedNode {
|
|
229
|
+
id: string;
|
|
230
|
+
type: string;
|
|
231
|
+
name?: string;
|
|
232
|
+
z?: string;
|
|
233
|
+
x: number;
|
|
234
|
+
y: number;
|
|
235
|
+
g?: string;
|
|
236
|
+
wires: string[][];
|
|
237
|
+
credentials: any;
|
|
238
|
+
_node?: INode;
|
|
239
|
+
send(msg: any): void;
|
|
240
|
+
receive(msg: any): void;
|
|
241
|
+
status(status: string | {
|
|
242
|
+
fill?: string;
|
|
243
|
+
shape?: string;
|
|
244
|
+
text?: string;
|
|
245
|
+
}): void;
|
|
246
|
+
updateWires(wires: string[][]): void;
|
|
247
|
+
on(event: string, callback: (...args: any[]) => void): void;
|
|
248
|
+
log(msg: any): void;
|
|
249
|
+
warn(msg: any): void;
|
|
250
|
+
error(msg: any, errorMsg?: any): void;
|
|
251
|
+
context(): NodeRedNodeContext;
|
|
252
|
+
[key: string]: any;
|
|
253
|
+
}
|
|
254
|
+
interface NodeRedNodeContext extends NodeRedContextStore {
|
|
255
|
+
flow: NodeRedContextStore;
|
|
256
|
+
global: NodeRedContextStore;
|
|
257
|
+
}
|
|
258
|
+
interface NodeRedNodes {
|
|
259
|
+
registerType(type: string, constructor: any, opts?: any): void;
|
|
260
|
+
getNode(id: string): (NodeRedNode & {
|
|
261
|
+
_node?: INode;
|
|
262
|
+
}) | undefined;
|
|
263
|
+
createNode(node: NodeRedNode, config: Record<string, any>): void;
|
|
264
|
+
getCredentials(id: string): Record<string, any> | undefined;
|
|
265
|
+
/** Merge credentials into a node's stored credential set (runtime API). */
|
|
266
|
+
addCredentials(id: string, credentials: Record<string, any>): void;
|
|
267
|
+
eachNode(callback: (node: any) => void): void;
|
|
268
|
+
getType(type: string): any;
|
|
269
|
+
getNodeInfo(type: string): any;
|
|
270
|
+
getNodeList(filter?: any): any[];
|
|
271
|
+
getModuleInfo(module: string): any;
|
|
272
|
+
installModule(module: string, version?: string): Promise<any>;
|
|
273
|
+
uninstallModule(module: string): Promise<any>;
|
|
274
|
+
enableNode(id: string): Promise<any>;
|
|
275
|
+
disableNode(id: string): Promise<any>;
|
|
276
|
+
}
|
|
277
|
+
interface NodeRedUtil {
|
|
278
|
+
evaluateNodeProperty(value: any, type: string, node: any, msg: Record<string, any> | undefined, callback: (err: Error | null, result: any) => void): void;
|
|
279
|
+
generateId(): string;
|
|
280
|
+
cloneMessage<T = any>(msg: T): T;
|
|
281
|
+
ensureString(o: any): string;
|
|
282
|
+
ensureBuffer(o: any): Buffer;
|
|
283
|
+
compareObjects(obj1: any, obj2: any): boolean;
|
|
284
|
+
getMessageProperty(msg: any, expr: string): any;
|
|
285
|
+
setMessageProperty(msg: any, prop: string, value: any, createMissing?: boolean): void;
|
|
286
|
+
getObjectProperty(obj: any, expr: string): any;
|
|
287
|
+
setObjectProperty(obj: any, prop: string, value: any, createMissing?: boolean): void;
|
|
288
|
+
normalisePropertyExpression(str: string, msg?: any, toString?: boolean): string[];
|
|
289
|
+
normaliseNodeTypeName(name: string): string;
|
|
290
|
+
prepareJSONataExpression(value: string, node: any): any;
|
|
291
|
+
evaluateJSONataExpression(expr: any, msg: any, callback: (err: Error | null, result: any) => void): void;
|
|
292
|
+
parseContextStore(key: string): {
|
|
293
|
+
store: string | undefined;
|
|
294
|
+
key: string;
|
|
295
|
+
};
|
|
296
|
+
getSetting(node: any, name: string, flow?: any): any;
|
|
297
|
+
encodeObject(obj: any): any;
|
|
298
|
+
}
|
|
299
|
+
interface NodeRedHooks {
|
|
300
|
+
add(hookId: string, callback: (event: any) => void | Promise<void>): void;
|
|
301
|
+
remove(hookId: string): void;
|
|
302
|
+
trigger(hookId: string, event: any, callback?: (err?: Error) => void): void | Promise<void>;
|
|
303
|
+
has(hookId: string): boolean;
|
|
304
|
+
clear(): void;
|
|
305
|
+
}
|
|
306
|
+
type NodeRedExpressApp = Express;
|
|
307
|
+
interface RED {
|
|
308
|
+
/** Internationalization function */
|
|
309
|
+
_(key: string, substitutions?: Record<string, string>): string;
|
|
310
|
+
/** Logging API */
|
|
311
|
+
log: NodeRedLog;
|
|
312
|
+
/** Node registry and management */
|
|
313
|
+
nodes: NodeRedNodes;
|
|
314
|
+
/** Utility functions */
|
|
315
|
+
util: NodeRedUtil;
|
|
316
|
+
/** Hook system for message lifecycle and module events */
|
|
317
|
+
hooks: NodeRedHooks;
|
|
318
|
+
/** Runtime event emitter */
|
|
319
|
+
events: EventEmitter;
|
|
320
|
+
/** Express app for admin HTTP endpoints */
|
|
321
|
+
httpAdmin: NodeRedExpressApp;
|
|
322
|
+
/** Express app for node HTTP endpoints */
|
|
323
|
+
httpNode: NodeRedExpressApp;
|
|
324
|
+
/** Runtime settings (user-provided settings plus node-registered settings) */
|
|
325
|
+
settings: NodeRedRuntimeSettings & Record<string, any>;
|
|
326
|
+
/** Node-RED version string */
|
|
327
|
+
version(): string;
|
|
328
|
+
}
|
|
329
|
+
interface NodeRedContextStore {
|
|
330
|
+
get(key: string, store: string | undefined, callback: (err: Error | null, value: any) => void): void;
|
|
331
|
+
set(key: string, value: any, store: string | undefined, callback: (err: Error | null) => void): void;
|
|
332
|
+
keys(store: string | undefined, callback: (err: Error | null, keys: string[]) => void): void;
|
|
333
|
+
}
|
|
334
|
+
type Schema<T extends TProperties = TProperties> = TObject<T>;
|
|
335
|
+
interface NodeContextStore {
|
|
336
|
+
get<T = any>(key: string): Promise<T>;
|
|
337
|
+
set<T = any>(key: string, value: T): Promise<void>;
|
|
338
|
+
keys(): Promise<string[]>;
|
|
339
|
+
}
|
|
340
|
+
interface NodeConstructor<T = any, TConfig = any, TCredentials = any> {
|
|
341
|
+
readonly type: string;
|
|
342
|
+
readonly category: string;
|
|
343
|
+
readonly color?: string;
|
|
344
|
+
readonly align?: "left" | "right";
|
|
345
|
+
readonly inputs?: number;
|
|
346
|
+
readonly outputs?: number;
|
|
347
|
+
readonly configSchema?: Schema;
|
|
348
|
+
readonly credentialsSchema?: Schema;
|
|
349
|
+
readonly settingsSchema?: Schema;
|
|
350
|
+
readonly inputSchema?: Schema;
|
|
351
|
+
readonly outputsSchema?: TSchema | TSchema[] | Record<string, TSchema>;
|
|
352
|
+
readonly validateInput?: boolean;
|
|
353
|
+
readonly validateOutput?: boolean;
|
|
354
|
+
readonly name: string;
|
|
355
|
+
registered?(RED: RED): void | Promise<void>;
|
|
356
|
+
register(RED: RED): void | Promise<void>;
|
|
357
|
+
validateSettings(RED: RED): void;
|
|
358
|
+
new (RED: RED, node: NodeRedNode, config: NodeConfig<TConfig>, credentials: NodeCredentials<TCredentials>): T;
|
|
359
|
+
}
|
|
360
|
+
type NodeConfig<TConfig = any> = TConfig & Static<typeof NodeConfigSchema>;
|
|
361
|
+
type NodeCredentials<TCredentials = any> = TCredentials;
|
|
362
|
+
interface INode<TConfig = any, TCredentials = any, TSettings = any> {
|
|
363
|
+
readonly config: NodeConfig<TConfig>;
|
|
364
|
+
readonly id: string;
|
|
365
|
+
readonly name: string | undefined;
|
|
366
|
+
readonly z: string | undefined;
|
|
367
|
+
readonly credentials: NodeCredentials<TCredentials> | undefined;
|
|
368
|
+
readonly settings: TSettings;
|
|
369
|
+
i18n(key: string, substitutions?: Record<string, string>): string;
|
|
370
|
+
setTimeout(fn: () => void, ms: number): NodeJS.Timeout;
|
|
371
|
+
setInterval(fn: () => void, ms: number): NodeJS.Timeout;
|
|
372
|
+
clearTimeout(timer: NodeJS.Timeout): void;
|
|
373
|
+
clearInterval(interval: NodeJS.Timeout): void;
|
|
374
|
+
on(event: string, callback: (...args: any[]) => void): void;
|
|
375
|
+
log(msg: any): void;
|
|
376
|
+
warn(message: string): void;
|
|
377
|
+
error(message: string, msg?: any): void;
|
|
378
|
+
created?(): void | Promise<void>;
|
|
379
|
+
closed?(removed?: boolean): void | Promise<void>;
|
|
380
|
+
}
|
|
381
|
+
type Channel = "sent" | "received";
|
|
382
|
+
declare class Recorder {
|
|
383
|
+
#private;
|
|
384
|
+
recordSent(id: string, port: number, msg: unknown): void;
|
|
385
|
+
recordReceived(id: string | undefined, msg: unknown): void;
|
|
386
|
+
/** Snapshot of all messages on a channel for a node (optionally one port). */
|
|
387
|
+
snapshot(channel: Channel, id: string, port?: number): unknown[];
|
|
388
|
+
/** Resolve the message at `index` on a channel, awaiting it if not yet seen. */
|
|
389
|
+
next(channel: Channel, id: string, port: number | undefined, index: number, timeoutMs: number): Promise<unknown>;
|
|
390
|
+
clear(): void;
|
|
391
|
+
}
|
|
392
|
+
interface NodeContext {
|
|
393
|
+
node: NodeContextStore;
|
|
394
|
+
flow: NodeContextStore;
|
|
395
|
+
global: NodeContextStore;
|
|
396
|
+
}
|
|
397
|
+
export interface AddNodeOptions {
|
|
398
|
+
/** Override the generated node id. */
|
|
399
|
+
id?: string;
|
|
400
|
+
/** Node name (the `name` field). */
|
|
401
|
+
name?: string;
|
|
402
|
+
/** Credentials for the node (stored by the runtime, exposed as `this.credentials`). */
|
|
403
|
+
credentials?: Record<string, unknown>;
|
|
404
|
+
}
|
|
405
|
+
export interface ReadOptions {
|
|
406
|
+
/** Reject if no message arrives within this many ms. @default 5000 */
|
|
407
|
+
timeout?: number;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* A handle to one node in a deployed flow. Wraps a generated id; the live
|
|
411
|
+
* instance lives inside the runtime, so harness methods (`receive`/`read`/…)
|
|
412
|
+
* never collide with the node's own methods.
|
|
413
|
+
*/
|
|
414
|
+
export declare class NodeRef {
|
|
415
|
+
#private;
|
|
416
|
+
readonly id: string;
|
|
417
|
+
readonly type: string;
|
|
418
|
+
readonly isConfig: boolean;
|
|
419
|
+
readonly name: string;
|
|
420
|
+
readonly config: Record<string, unknown>;
|
|
421
|
+
readonly credentials?: Record<string, unknown>;
|
|
422
|
+
readonly wires: string[][];
|
|
423
|
+
constructor(flow: Flow, type: string, isConfig: boolean, config: Record<string, unknown>, opts: AddNodeOptions);
|
|
424
|
+
/** Wire this node's output `port` to `target`'s input. */
|
|
425
|
+
wire(target: NodeRef, port?: number): this;
|
|
426
|
+
/** Deliver a message to this node's input (Node-RED's upstream path). */
|
|
427
|
+
receive(msg: unknown): Promise<void>;
|
|
428
|
+
/** Snapshot of everything this node has emitted (optionally one port). */
|
|
429
|
+
sent(port?: number): unknown[];
|
|
430
|
+
/** Snapshot of everything delivered to this node's input. */
|
|
431
|
+
received(port?: number): unknown[];
|
|
432
|
+
/**
|
|
433
|
+
* Promise-based access to this node's context stores (`node` / `flow` /
|
|
434
|
+
* `global`) — preset values before `receive`, and assert them afterward.
|
|
435
|
+
*/
|
|
436
|
+
get context(): NodeContext;
|
|
437
|
+
/**
|
|
438
|
+
* Consume the next un-read message this node emitted (FIFO cursor), awaiting
|
|
439
|
+
* it if not yet sent. Call repeatedly to walk multiple emissions.
|
|
440
|
+
*/
|
|
441
|
+
read(port?: number, opts?: ReadOptions): Promise<unknown>;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Builds a flow from nrg node classes and deploys it into a running runtime.
|
|
445
|
+
* Use `addNode` for every node — config nodes included.
|
|
446
|
+
*/
|
|
447
|
+
export declare class Flow {
|
|
448
|
+
#private;
|
|
449
|
+
readonly recorder: Recorder;
|
|
450
|
+
constructor(RED: NodeRedApi, recorder: Recorder);
|
|
451
|
+
/** Add any node — regular or config (detected via `category === "config"`). */
|
|
452
|
+
addNode(Cls: NodeConstructor, config?: Record<string, unknown>, opts?: AddNodeOptions): NodeRef;
|
|
453
|
+
/** Build the flow JSON and deploy it; resolves once the flow has started. */
|
|
454
|
+
deploy(): Promise<void>;
|
|
455
|
+
/** Drop the built nodes and clear captured messages (reset between tests). */
|
|
456
|
+
clear(): Promise<void>;
|
|
457
|
+
runtimeNode(id: string): RuntimeNode | null;
|
|
458
|
+
}
|
|
459
|
+
export interface StartRuntimeOptions {
|
|
460
|
+
/** Node classes (IONode / ConfigNode subclasses) to register in the runtime. */
|
|
461
|
+
nodes: NodeConstructor[];
|
|
462
|
+
/** Extra Node-RED settings merged over the headless defaults. */
|
|
463
|
+
settings?: Record<string, unknown>;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Boot a real, in-process Node-RED runtime with the given node types
|
|
467
|
+
* registered. One runtime per test file (the runtime is a process-wide
|
|
468
|
+
* singleton); each test deploys a flow into it and `flow.clear()`s between.
|
|
469
|
+
*/
|
|
470
|
+
export declare function startRuntime(options: StartRuntimeOptions): Promise<Runtime>;
|
|
471
|
+
export declare class Runtime {
|
|
472
|
+
#private;
|
|
473
|
+
constructor(RED: NodeRedApi, server: http.Server, userDir: string, recorder: Recorder);
|
|
474
|
+
/** Start a fresh flow to build, deploy, drive and inspect. */
|
|
475
|
+
flow(): Flow;
|
|
476
|
+
/** Stop Node-RED, close the server and remove the temp user dir. */
|
|
477
|
+
stop(): Promise<void>;
|
|
478
|
+
}
|
|
479
|
+
interface NodeRedApi {
|
|
480
|
+
init(server: http.Server, settings: Record<string, unknown>): void;
|
|
481
|
+
start(): Promise<void>;
|
|
482
|
+
stop(): Promise<void>;
|
|
483
|
+
hooks: {
|
|
484
|
+
add(name: "onReceive", fn: (event: {
|
|
485
|
+
destination?: {
|
|
486
|
+
id?: string;
|
|
487
|
+
};
|
|
488
|
+
msg: unknown;
|
|
489
|
+
}) => void): void;
|
|
490
|
+
};
|
|
491
|
+
nodes: {
|
|
492
|
+
getNode(id: string): RuntimeNode | null;
|
|
493
|
+
};
|
|
494
|
+
events: {
|
|
495
|
+
on(event: string, fn: (...args: unknown[]) => void): void;
|
|
496
|
+
removeListener(event: string, fn: (...args: unknown[]) => void): void;
|
|
497
|
+
};
|
|
498
|
+
runtime: {
|
|
499
|
+
flows: {
|
|
500
|
+
setFlows(opts: {
|
|
501
|
+
flows: {
|
|
502
|
+
flows: unknown[];
|
|
503
|
+
};
|
|
504
|
+
deploymentType?: string;
|
|
505
|
+
}): Promise<{
|
|
506
|
+
rev: string;
|
|
507
|
+
}>;
|
|
508
|
+
};
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
interface RuntimeNodeContext extends NodeRedContextStore {
|
|
512
|
+
flow: NodeRedContextStore;
|
|
513
|
+
global: NodeRedContextStore;
|
|
514
|
+
}
|
|
515
|
+
interface RuntimeNode {
|
|
516
|
+
id: string;
|
|
517
|
+
type: string;
|
|
518
|
+
receive(msg: unknown): void;
|
|
519
|
+
send(msg: unknown): void;
|
|
520
|
+
context(): RuntimeNodeContext;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export {};
|
|
@@ -203,6 +203,11 @@ declare const NodeConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
203
203
|
z: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
204
204
|
}>;
|
|
205
205
|
type Schema<T extends TProperties = TProperties> = TObject<T>;
|
|
206
|
+
interface NodeContextStore {
|
|
207
|
+
get<T = any>(key: string): Promise<T>;
|
|
208
|
+
set<T = any>(key: string, value: T): Promise<void>;
|
|
209
|
+
keys(): Promise<string[]>;
|
|
210
|
+
}
|
|
206
211
|
interface NodeConstructor<T = any, TConfig = any, TCredentials = any> {
|
|
207
212
|
readonly type: string;
|
|
208
213
|
readonly category: string;
|
|
@@ -409,6 +414,13 @@ interface TestNodeHelpers<TInput = any, TOutput = any> {
|
|
|
409
414
|
logged(level?: "info" | "warn" | "error" | "debug"): string[];
|
|
410
415
|
warned(): string[];
|
|
411
416
|
errored(): string[];
|
|
417
|
+
/** Promise-based access to the node's context stores (node / flow / global). */
|
|
418
|
+
context: TestNodeContext;
|
|
419
|
+
}
|
|
420
|
+
interface TestNodeContext {
|
|
421
|
+
node: NodeContextStore;
|
|
422
|
+
flow?: NodeContextStore;
|
|
423
|
+
global: NodeContextStore;
|
|
412
424
|
}
|
|
413
425
|
interface CreateNodeResult<T> {
|
|
414
426
|
node: T & TestNodeHelpers<ExtractInput<T>, ExtractOutput<T>>;
|
package/types/vite.d.ts
CHANGED