@bonsae/nrg 0.13.1 → 0.15.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/test/index.js CHANGED
@@ -3,19 +3,44 @@ import { vi as vi2 } from "vitest";
3
3
 
4
4
  // src/test/mocks.ts
5
5
  import { vi } from "vitest";
6
- function createMockRED(options = {}) {
7
- const { nodes = {}, settings = {} } = options;
8
- return {
6
+ function createNodeRedRuntime(options = {}) {
7
+ const { settings = {} } = options;
8
+ const nodes = {};
9
+ const red = {
9
10
  log: {
10
11
  info: vi.fn(),
11
12
  warn: vi.fn(),
12
13
  error: vi.fn(),
13
- debug: vi.fn()
14
+ debug: vi.fn(),
15
+ trace: vi.fn(),
16
+ log: vi.fn(),
17
+ metric: vi.fn(() => false),
18
+ audit: vi.fn(),
19
+ addHandler: vi.fn(),
20
+ removeHandler: vi.fn(),
21
+ FATAL: 10,
22
+ ERROR: 20,
23
+ WARN: 30,
24
+ INFO: 40,
25
+ DEBUG: 50,
26
+ TRACE: 60,
27
+ AUDIT: 98,
28
+ METRIC: 99
14
29
  },
15
30
  nodes: {
16
31
  getNode: vi.fn((id) => nodes[id]),
17
32
  registerType: vi.fn(),
18
- createNode: vi.fn()
33
+ createNode: vi.fn(),
34
+ getCredentials: vi.fn(),
35
+ eachNode: vi.fn(),
36
+ getType: vi.fn(),
37
+ getNodeInfo: vi.fn(),
38
+ getNodeList: vi.fn(() => []),
39
+ getModuleInfo: vi.fn(),
40
+ installModule: vi.fn(),
41
+ uninstallModule: vi.fn(),
42
+ enableNode: vi.fn(),
43
+ disableNode: vi.fn()
19
44
  },
20
45
  httpAdmin: {
21
46
  get: vi.fn(),
@@ -24,6 +49,25 @@ function createMockRED(options = {}) {
24
49
  delete: vi.fn(),
25
50
  use: vi.fn()
26
51
  },
52
+ httpNode: {
53
+ get: vi.fn(),
54
+ post: vi.fn(),
55
+ put: vi.fn(),
56
+ delete: vi.fn(),
57
+ use: vi.fn()
58
+ },
59
+ hooks: {
60
+ add: vi.fn(),
61
+ remove: vi.fn(),
62
+ trigger: vi.fn(),
63
+ has: vi.fn(() => false),
64
+ clear: vi.fn()
65
+ },
66
+ events: {
67
+ on: vi.fn(),
68
+ emit: vi.fn(),
69
+ removeListener: vi.fn()
70
+ },
27
71
  settings: { ...settings },
28
72
  _: vi.fn((key, subs) => {
29
73
  if (!subs) return key;
@@ -80,18 +124,34 @@ function createMockRED(options = {}) {
80
124
  callback(err, void 0);
81
125
  }
82
126
  }
83
- )
127
+ ),
128
+ generateId: vi.fn(() => "mock-id"),
129
+ cloneMessage: vi.fn((msg) => ({ ...msg })),
130
+ ensureString: vi.fn((o) => String(o)),
131
+ ensureBuffer: vi.fn(),
132
+ compareObjects: vi.fn(),
133
+ getMessageProperty: vi.fn(),
134
+ setMessageProperty: vi.fn(),
135
+ getObjectProperty: vi.fn(),
136
+ setObjectProperty: vi.fn(),
137
+ normalisePropertyExpression: vi.fn(),
138
+ normaliseNodeTypeName: vi.fn(),
139
+ prepareJSONataExpression: vi.fn(),
140
+ evaluateJSONataExpression: vi.fn(),
141
+ parseContextStore: vi.fn(),
142
+ getSetting: vi.fn(),
143
+ encodeObject: vi.fn()
84
144
  },
85
- events: {
86
- on: vi.fn(),
87
- emit: vi.fn()
145
+ version: vi.fn(() => "0.0.0-test"),
146
+ validator: void 0,
147
+ registerNode(id, nodeRedNode) {
148
+ nodes[id] = nodeRedNode;
88
149
  },
89
- hooks: {
90
- add: vi.fn(),
91
- remove: vi.fn()
92
- },
93
- version: vi.fn(() => "0.0.0-test")
150
+ registerNrgNode(id, nrgInstance) {
151
+ nodes[id] = createNodeRedNode({ id, _node: nrgInstance });
152
+ }
94
153
  };
154
+ return red;
95
155
  }
96
156
  function getProperty(obj, path) {
97
157
  return path.split(".").reduce((acc, key) => acc?.[key], obj);
@@ -113,7 +173,7 @@ function createContextStore() {
113
173
  )
114
174
  };
115
175
  }
116
- function createMockNodeRedNode(options = {}) {
176
+ function createNodeRedNode(options = {}) {
117
177
  const nodeCtx = createContextStore();
118
178
  const flowCtx = createContextStore();
119
179
  const globalCtx = createContextStore();
@@ -122,20 +182,29 @@ function createMockNodeRedNode(options = {}) {
122
182
  flow: flowCtx,
123
183
  global: globalCtx
124
184
  };
185
+ const handlers = /* @__PURE__ */ new Map();
125
186
  return {
126
- id: options.id ?? `test-${Math.random().toString(36).slice(2, 10)}`,
187
+ id: options.id ?? `node-${Math.random().toString(36).slice(2, 10)}`,
127
188
  type: options.type ?? "test-node",
128
- name: options.name ?? "",
189
+ name: options.name ?? "test-node",
129
190
  z: options.z ?? "flow-1",
130
191
  x: 100,
131
192
  y: 200,
132
- g: void 0,
133
- wires: options.wires ?? [[]],
193
+ g: "group-1",
194
+ wires: options.wires ?? [["node-2"]],
134
195
  credentials: options.credentials ?? {},
135
196
  log: vi.fn(),
136
197
  warn: vi.fn(),
137
198
  error: vi.fn(),
138
- on: vi.fn(),
199
+ on: vi.fn((event, handler) => {
200
+ if (!handlers.has(event)) handlers.set(event, []);
201
+ handlers.get(event).push(handler);
202
+ }),
203
+ emit: vi.fn(async (event, ...args) => {
204
+ for (const handler of handlers.get(event) ?? []) {
205
+ await handler(...args);
206
+ }
207
+ }),
139
208
  send: vi.fn(),
140
209
  status: vi.fn(),
141
210
  updateWires: vi.fn(),
@@ -145,32 +214,204 @@ function createMockNodeRedNode(options = {}) {
145
214
  };
146
215
  }
147
216
 
217
+ // src/core/validator.ts
218
+ import Ajv from "ajv";
219
+ import addFormats from "ajv-formats";
220
+ import addErrors from "ajv-errors";
221
+ var Validator = class {
222
+ ajv;
223
+ constructor(options) {
224
+ const { customKeywords, customFormats, ...ajvOptions } = options || {};
225
+ this.ajv = new Ajv({
226
+ allErrors: true,
227
+ code: {
228
+ source: false
229
+ },
230
+ coerceTypes: true,
231
+ removeAdditional: false,
232
+ strict: false,
233
+ strictSchema: false,
234
+ useDefaults: true,
235
+ validateFormats: true,
236
+ // NOTE: typebox handles validation via typescript
237
+ // NOTE: if true, types that are not serializable JSON, like Function, would not work
238
+ validateSchema: false,
239
+ verbose: true,
240
+ ...ajvOptions
241
+ });
242
+ addFormats(this.ajv);
243
+ addErrors(this.ajv);
244
+ this.addCustomKeywords(customKeywords || []);
245
+ this.addCustomFormats(customFormats || {});
246
+ }
247
+ /**
248
+ * Add custom keywords to the validator
249
+ */
250
+ addCustomKeywords(keywords) {
251
+ if (!keywords) return;
252
+ keywords.forEach((keyword) => {
253
+ this.ajv.addKeyword(keyword);
254
+ });
255
+ }
256
+ /**
257
+ * Add custom formats to the validator
258
+ */
259
+ addCustomFormats(formats) {
260
+ if (!formats) return;
261
+ Object.entries(formats).forEach(([name, validator]) => {
262
+ if (validator instanceof RegExp) {
263
+ this.ajv.addFormat(name, validator);
264
+ } else {
265
+ this.ajv.addFormat(name, { validate: validator });
266
+ }
267
+ });
268
+ }
269
+ /**
270
+ * Create a validator function with caching
271
+ * @param schema - JSON Schema to validate against
272
+ * @param cacheKey - Optional cache key for reusing validators
273
+ */
274
+ createValidator(schema, cacheKey) {
275
+ if (cacheKey && !schema.$id) {
276
+ schema.$id = cacheKey;
277
+ }
278
+ if (schema.$id) {
279
+ const cached = this.ajv.getSchema(schema.$id);
280
+ if (cached) return cached;
281
+ }
282
+ const validator = this.ajv.compile(schema);
283
+ return validator;
284
+ }
285
+ /**
286
+ * Validate data against a schema and return a structured result
287
+ */
288
+ validate(data, schema, options) {
289
+ const validator = this.createValidator(schema, options?.cacheKey);
290
+ const valid = validator(data);
291
+ if (!valid) {
292
+ const errorMessage = this.formatErrors(validator.errors);
293
+ if (options?.throwOnError) {
294
+ throw new ValidationError(errorMessage, validator.errors || []);
295
+ }
296
+ return {
297
+ valid: false,
298
+ errors: validator.errors || void 0,
299
+ errorMessage
300
+ };
301
+ }
302
+ return {
303
+ valid: true,
304
+ data
305
+ };
306
+ }
307
+ /**
308
+ * Format errors into a human-readable string
309
+ */
310
+ formatErrors(errors, options) {
311
+ if (!errors || errors.length === 0) {
312
+ return "No errors";
313
+ }
314
+ return this.ajv.errorsText(errors, {
315
+ separator: "; ",
316
+ dataVar: "data",
317
+ ...options
318
+ });
319
+ }
320
+ /**
321
+ * Get detailed error information
322
+ */
323
+ getDetailedErrors(errors) {
324
+ if (!errors || errors.length === 0) return [];
325
+ return errors.map((error) => ({
326
+ field: error.instancePath || "/",
327
+ message: error.message || "Validation failed",
328
+ keyword: error.keyword,
329
+ params: error.params,
330
+ schemaPath: error.schemaPath
331
+ }));
332
+ }
333
+ /**
334
+ * Add a schema to the validator for reference
335
+ */
336
+ addSchema(schema, key) {
337
+ this.ajv.addSchema(schema, key);
338
+ return this;
339
+ }
340
+ /**
341
+ * Remove a schema from the validator
342
+ */
343
+ removeSchema(key) {
344
+ this.ajv.removeSchema(key);
345
+ return this;
346
+ }
347
+ };
348
+ var ValidationError = class _ValidationError extends Error {
349
+ constructor(message, errors) {
350
+ super(message);
351
+ this.errors = errors;
352
+ this.name = "ValidationError";
353
+ Object.setPrototypeOf(this, _ValidationError.prototype);
354
+ }
355
+ };
356
+
357
+ // src/core/server/validation.ts
358
+ function initValidator(RED) {
359
+ const nrg = {
360
+ validator: new Validator({
361
+ customKeywords: [
362
+ {
363
+ keyword: "x-nrg-skip-validation",
364
+ schemaType: "boolean",
365
+ valid: true
366
+ },
367
+ {
368
+ keyword: "x-nrg-node-type",
369
+ type: "string",
370
+ validate: (schemaValue, dataValue) => {
371
+ if (!dataValue) return true;
372
+ const node = RED.nodes.getNode(dataValue);
373
+ return node?.type === schemaValue;
374
+ }
375
+ }
376
+ ],
377
+ customFormats: {
378
+ "node-id": /^[a-zA-Z0-9-_]+$/,
379
+ "flow-id": /^[a-f0-9]{16}$/,
380
+ "topic-path": (data) => /^[a-zA-Z0-9/_-]+$/.test(data)
381
+ }
382
+ })
383
+ };
384
+ Object.defineProperty(RED, "_nrg", {
385
+ value: nrg,
386
+ writable: false,
387
+ enumerable: false,
388
+ configurable: false
389
+ });
390
+ Object.defineProperty(RED, "validator", {
391
+ get: () => nrg.validator,
392
+ enumerable: false,
393
+ configurable: false
394
+ });
395
+ }
396
+
397
+ // src/core/server/nodes/symbols.ts
398
+ var WIRE_HANDLERS = Symbol("wireHandlers");
399
+
148
400
  // src/test/index.ts
149
- import { initValidator } from "@bonsae/nrg/server";
150
401
  function buildConfig(NodeClass, userConfig = {}) {
151
402
  const defaults = {};
152
403
  if (NodeClass.configSchema?.properties) {
153
404
  for (const [key, prop] of Object.entries(
154
405
  NodeClass.configSchema.properties
155
406
  )) {
156
- if (prop.default !== void 0) {
157
- defaults[key] = prop.default;
407
+ const schemaProp = prop;
408
+ if (schemaProp.default !== void 0) {
409
+ defaults[key] = schemaProp.default;
158
410
  }
159
411
  }
160
412
  }
161
413
  return { ...defaults, ...userConfig };
162
414
  }
163
- function buildNodeRedNodes(configNodes) {
164
- const nodes = {};
165
- for (const [id, value] of Object.entries(configNodes)) {
166
- if (value && typeof value === "object" && "id" in value) {
167
- nodes[id] = { _node: value };
168
- } else {
169
- nodes[id] = value;
170
- }
171
- }
172
- return nodes;
173
- }
174
415
  function attachHelpers(node, nodeRedNode) {
175
416
  const sentMessages = [];
176
417
  const statusCalls = [];
@@ -180,16 +421,23 @@ function attachHelpers(node, nodeRedNode) {
180
421
  nodeRedNode.status.mockImplementation((status) => {
181
422
  statusCalls.push(status);
182
423
  });
183
- const nodeRef = node;
184
424
  const helpers = {
185
425
  async receive(msg) {
186
426
  const sendFn = vi2.fn((outMsg) => {
187
427
  nodeRedNode.send(outMsg);
188
428
  });
189
- await nodeRef._input(msg, sendFn);
429
+ const doneFn = vi2.fn();
430
+ await nodeRedNode.emit("input", msg, sendFn, doneFn);
431
+ if (doneFn.mock.calls[0]?.[0] instanceof Error) {
432
+ throw doneFn.mock.calls[0][0];
433
+ }
190
434
  },
191
435
  async close(removed = false) {
192
- await nodeRef._closed(removed);
436
+ const doneFn = vi2.fn();
437
+ await nodeRedNode.emit("close", removed, doneFn);
438
+ if (doneFn.mock.calls[0]?.[0] instanceof Error) {
439
+ throw doneFn.mock.calls[0][0];
440
+ }
193
441
  },
194
442
  reset() {
195
443
  sentMessages.length = 0;
@@ -248,9 +496,11 @@ async function createNode(NodeClass, options = {}) {
248
496
  resolvedConfig[key] = value;
249
497
  }
250
498
  }
251
- const redNodes = buildNodeRedNodes(configNodes);
252
- const RED = createMockRED({ nodes: redNodes, settings });
499
+ const RED = createNodeRedRuntime({ settings });
253
500
  initValidator(RED);
501
+ for (const [id, value] of Object.entries(configNodes)) {
502
+ RED.registerNrgNode(id, value);
503
+ }
254
504
  const configDefaults = {
255
505
  id: overrideOpts.id ?? `test-${Math.random().toString(36).slice(2, 10)}`,
256
506
  type: NodeClass.type
@@ -262,19 +512,20 @@ async function createNode(NodeClass, options = {}) {
262
512
  ...configDefaults,
263
513
  ...resolvedConfig
264
514
  });
265
- const nodeRedNode = createMockNodeRedNode({
515
+ const nodeRedNode = createNodeRedNode({
266
516
  id: config.id,
267
517
  type: NodeClass.type,
268
518
  name: config.name ?? "",
269
519
  credentials,
270
520
  ...overrideOpts
271
521
  });
272
- await Promise.resolve(
273
- NodeClass._registered?.(RED) ?? NodeClass.registered?.(RED)
274
- );
522
+ NodeClass.validateSettings(RED);
523
+ await Promise.resolve(NodeClass.registered?.(RED));
275
524
  const node = new NodeClass(RED, nodeRedNode, config, credentials);
525
+ const createdPromise = Promise.resolve(node.created?.());
526
+ node[WIRE_HANDLERS](nodeRedNode, createdPromise);
527
+ await createdPromise;
276
528
  const augmented = attachHelpers(node, nodeRedNode);
277
- await Promise.resolve(augmented.created?.());
278
529
  return { node: augmented, RED };
279
530
  }
280
531
  export {