@bonsae/nrg 0.19.1 → 0.20.0

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 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 four test libraries and bundles most test infrastructure as direct dependencies. Install `vitest` plus any optional peer dependencies you need:
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 "../src/server/nodes/my-node";
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.19.1",
3
+ "version": "0.20.0",
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
- "vite": "^6.0.0",
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
- "vitest-browser-vue": "^2.0.0"
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": "^3.5.14",
125
- "@vitest/browser-playwright": "^4.1.5",
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
  }
@@ -32,6 +32,7 @@ var defaultConfig = {
32
32
  test: {
33
33
  testTimeout: 3e4,
34
34
  setupFiles: ["@bonsae/nrg/test/client/component/setup"],
35
+ include: ["tests/client/component/**/*.test.ts"],
35
36
  browser: {
36
37
  enabled: true,
37
38
  instances: [
@@ -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
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../../base.json",
3
+ "compilerOptions": {
4
+ "lib": ["ESNext"]
5
+ }
6
+ }
@@ -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>>;