@bonsae/nrg 0.9.1 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bonsae/nrg",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",
@@ -33,6 +33,10 @@
33
33
  "types": "./types/vite.d.ts",
34
34
  "default": "./vite/index.js"
35
35
  },
36
+ "./test": {
37
+ "types": "./types/test.d.ts",
38
+ "default": "./test/index.js"
39
+ },
36
40
  "./tsconfig/base.json": "./tsconfig/base.json",
37
41
  "./tsconfig/client.json": "./tsconfig/client.json",
38
42
  "./tsconfig/server.json": "./tsconfig/server.json"
package/test/index.js ADDED
@@ -0,0 +1,440 @@
1
+ // src/test/index.ts
2
+ import { vi as vi2 } from "vitest";
3
+
4
+ // src/test/mocks.ts
5
+ import { vi } from "vitest";
6
+ function createMockRED(options = {}) {
7
+ const { nodes = {}, settings = {} } = options;
8
+ return {
9
+ log: {
10
+ info: vi.fn(),
11
+ warn: vi.fn(),
12
+ error: vi.fn(),
13
+ debug: vi.fn()
14
+ },
15
+ nodes: {
16
+ getNode: vi.fn((id) => nodes[id]),
17
+ registerType: vi.fn(),
18
+ createNode: vi.fn()
19
+ },
20
+ httpAdmin: {
21
+ get: vi.fn(),
22
+ post: vi.fn(),
23
+ put: vi.fn(),
24
+ delete: vi.fn(),
25
+ use: vi.fn()
26
+ },
27
+ settings: { ...settings },
28
+ _: vi.fn((key, subs) => {
29
+ if (!subs) return key;
30
+ return Object.entries(subs).reduce(
31
+ (str, [k, v]) => str.replace(`__${k}__`, v),
32
+ key
33
+ );
34
+ }),
35
+ util: {
36
+ evaluateNodeProperty: vi.fn(
37
+ (value, type, _node, msg, callback) => {
38
+ try {
39
+ let result;
40
+ switch (type) {
41
+ case "str":
42
+ result = String(value);
43
+ break;
44
+ case "num":
45
+ result = Number(value);
46
+ break;
47
+ case "bool":
48
+ result = value === "true" || value === true;
49
+ break;
50
+ case "json":
51
+ result = typeof value === "string" ? JSON.parse(value) : value;
52
+ break;
53
+ case "msg":
54
+ result = msg ? getProperty(msg, value) : void 0;
55
+ break;
56
+ case "date":
57
+ result = Date.now();
58
+ break;
59
+ case "bin":
60
+ result = Buffer.from(value ?? "");
61
+ break;
62
+ case "re":
63
+ result = new RegExp(value);
64
+ break;
65
+ case "jsonata":
66
+ case "flow":
67
+ case "global":
68
+ case "env":
69
+ case "cred":
70
+ result = void 0;
71
+ break;
72
+ case "node":
73
+ result = value;
74
+ break;
75
+ default:
76
+ result = value;
77
+ }
78
+ callback(null, result);
79
+ } catch (err) {
80
+ callback(err, void 0);
81
+ }
82
+ }
83
+ )
84
+ },
85
+ events: {
86
+ on: vi.fn(),
87
+ emit: vi.fn()
88
+ },
89
+ hooks: {
90
+ add: vi.fn(),
91
+ remove: vi.fn()
92
+ },
93
+ version: vi.fn(() => "0.0.0-test")
94
+ };
95
+ }
96
+ function getProperty(obj, path) {
97
+ return path.split(".").reduce((acc, key) => acc?.[key], obj);
98
+ }
99
+ function createContextStore() {
100
+ const store = {};
101
+ return {
102
+ get: vi.fn(
103
+ (key, _store, cb) => cb(null, store[key])
104
+ ),
105
+ set: vi.fn(
106
+ (key, value, _store, cb) => {
107
+ store[key] = value;
108
+ cb(null);
109
+ }
110
+ ),
111
+ keys: vi.fn(
112
+ (_store, cb) => cb(null, Object.keys(store))
113
+ )
114
+ };
115
+ }
116
+ function createMockNodeRedNode(options = {}) {
117
+ const nodeCtx = createContextStore();
118
+ const flowCtx = createContextStore();
119
+ const globalCtx = createContextStore();
120
+ const context = {
121
+ ...nodeCtx,
122
+ flow: flowCtx,
123
+ global: globalCtx
124
+ };
125
+ return {
126
+ id: options.id ?? `test-${Math.random().toString(36).slice(2, 10)}`,
127
+ type: options.type ?? "test-node",
128
+ name: options.name ?? "",
129
+ z: options.z ?? "flow-1",
130
+ x: 100,
131
+ y: 200,
132
+ g: void 0,
133
+ wires: options.wires ?? [[]],
134
+ credentials: options.credentials ?? {},
135
+ log: vi.fn(),
136
+ warn: vi.fn(),
137
+ error: vi.fn(),
138
+ on: vi.fn(),
139
+ send: vi.fn(),
140
+ status: vi.fn(),
141
+ updateWires: vi.fn(),
142
+ receive: vi.fn(),
143
+ context: vi.fn(() => context),
144
+ ...options
145
+ };
146
+ }
147
+
148
+ // src/core/validator.ts
149
+ import Ajv from "ajv";
150
+ import addFormats from "ajv-formats";
151
+ import addErrors from "ajv-errors";
152
+ var Validator = class {
153
+ ajv;
154
+ constructor(options) {
155
+ const { customKeywords, customFormats, ...ajvOptions } = options || {};
156
+ this.ajv = new Ajv({
157
+ allErrors: true,
158
+ code: {
159
+ source: false
160
+ },
161
+ coerceTypes: true,
162
+ removeAdditional: false,
163
+ strict: false,
164
+ strictSchema: false,
165
+ useDefaults: true,
166
+ validateFormats: true,
167
+ // NOTE: typebox handles validation via typescript
168
+ // NOTE: if true, types that are not serializable JSON, like Function, would not work
169
+ validateSchema: false,
170
+ verbose: true,
171
+ ...ajvOptions
172
+ });
173
+ addFormats(this.ajv);
174
+ addErrors(this.ajv);
175
+ this.addCustomKeywords(customKeywords || []);
176
+ this.addCustomFormats(customFormats || {});
177
+ }
178
+ /**
179
+ * Add custom keywords to the validator
180
+ */
181
+ addCustomKeywords(keywords) {
182
+ if (!keywords) return;
183
+ keywords.forEach((keyword) => {
184
+ this.ajv.addKeyword(keyword);
185
+ });
186
+ }
187
+ /**
188
+ * Add custom formats to the validator
189
+ */
190
+ addCustomFormats(formats) {
191
+ if (!formats) return;
192
+ Object.entries(formats).forEach(([name, validator2]) => {
193
+ if (validator2 instanceof RegExp) {
194
+ this.ajv.addFormat(name, validator2);
195
+ } else {
196
+ this.ajv.addFormat(name, { validate: validator2 });
197
+ }
198
+ });
199
+ }
200
+ /**
201
+ * Create a validator function with caching
202
+ * @param schema - JSON Schema to validate against
203
+ * @param cacheKey - Optional cache key for reusing validators
204
+ */
205
+ createValidator(schema, cacheKey) {
206
+ if (cacheKey && !schema.$id) {
207
+ schema.$id = cacheKey;
208
+ }
209
+ if (schema.$id) {
210
+ const cached = this.ajv.getSchema(schema.$id);
211
+ if (cached) return cached;
212
+ }
213
+ const validator2 = this.ajv.compile(schema);
214
+ return validator2;
215
+ }
216
+ /**
217
+ * Validate data against a schema and return a structured result
218
+ */
219
+ validate(data, schema, options) {
220
+ const validator2 = this.createValidator(schema, options?.cacheKey);
221
+ const valid = validator2(data);
222
+ if (!valid) {
223
+ const errorMessage = this.formatErrors(validator2.errors);
224
+ if (options?.throwOnError) {
225
+ throw new ValidationError(errorMessage, validator2.errors || []);
226
+ }
227
+ return {
228
+ valid: false,
229
+ errors: validator2.errors || void 0,
230
+ errorMessage
231
+ };
232
+ }
233
+ return {
234
+ valid: true,
235
+ data
236
+ };
237
+ }
238
+ /**
239
+ * Format errors into a human-readable string
240
+ */
241
+ formatErrors(errors, options) {
242
+ if (!errors || errors.length === 0) {
243
+ return "No errors";
244
+ }
245
+ return this.ajv.errorsText(errors, {
246
+ separator: "; ",
247
+ dataVar: "data",
248
+ ...options
249
+ });
250
+ }
251
+ /**
252
+ * Get detailed error information
253
+ */
254
+ getDetailedErrors(errors) {
255
+ if (!errors || errors.length === 0) return [];
256
+ return errors.map((error) => ({
257
+ field: error.instancePath || "/",
258
+ message: error.message || "Validation failed",
259
+ keyword: error.keyword,
260
+ params: error.params,
261
+ schemaPath: error.schemaPath
262
+ }));
263
+ }
264
+ /**
265
+ * Add a schema to the validator for reference
266
+ */
267
+ addSchema(schema, key) {
268
+ this.ajv.addSchema(schema, key);
269
+ return this;
270
+ }
271
+ /**
272
+ * Remove a schema from the validator
273
+ */
274
+ removeSchema(key) {
275
+ this.ajv.removeSchema(key);
276
+ return this;
277
+ }
278
+ };
279
+ var ValidationError = class _ValidationError extends Error {
280
+ constructor(message, errors) {
281
+ super(message);
282
+ this.errors = errors;
283
+ this.name = "ValidationError";
284
+ Object.setPrototypeOf(this, _ValidationError.prototype);
285
+ }
286
+ };
287
+
288
+ // src/core/server/validation.ts
289
+ var validator = void 0;
290
+ function initValidator(RED) {
291
+ validator = new Validator({
292
+ customKeywords: [
293
+ {
294
+ keyword: "x-nrg-skip-validation",
295
+ schemaType: "boolean",
296
+ valid: true
297
+ },
298
+ {
299
+ keyword: "x-nrg-node-type",
300
+ type: "string",
301
+ validate: (schemaValue, dataValue) => {
302
+ if (!dataValue) return true;
303
+ const node = RED.nodes.getNode(dataValue);
304
+ return node?.type === schemaValue;
305
+ }
306
+ }
307
+ ],
308
+ customFormats: {
309
+ "node-id": /^[a-zA-Z0-9-_]+$/,
310
+ "flow-id": /^[a-f0-9]{16}$/,
311
+ "topic-path": (data) => /^[a-zA-Z0-9/_-]+$/.test(data)
312
+ }
313
+ });
314
+ }
315
+
316
+ // src/test/index.ts
317
+ function buildConfig(NodeClass, userConfig = {}) {
318
+ const defaults = {};
319
+ if (NodeClass.configSchema?.properties) {
320
+ for (const [key, prop] of Object.entries(
321
+ NodeClass.configSchema.properties
322
+ )) {
323
+ if (prop.default !== void 0) {
324
+ defaults[key] = prop.default;
325
+ }
326
+ }
327
+ }
328
+ return { ...defaults, ...userConfig };
329
+ }
330
+ function buildNodeRedNodes(configNodes) {
331
+ const nodes = {};
332
+ for (const [id, value] of Object.entries(configNodes)) {
333
+ if (value && typeof value === "object" && "id" in value) {
334
+ nodes[id] = { _node: value };
335
+ } else {
336
+ nodes[id] = value;
337
+ }
338
+ }
339
+ return nodes;
340
+ }
341
+ function attachHelpers(node, nodeRedNode) {
342
+ const sentMessages = [];
343
+ const statusCalls = [];
344
+ nodeRedNode.send.mockImplementation((msg) => {
345
+ sentMessages.push(msg);
346
+ });
347
+ nodeRedNode.status.mockImplementation((status) => {
348
+ statusCalls.push(status);
349
+ });
350
+ const nodeRef = node;
351
+ const helpers = {
352
+ async receive(msg) {
353
+ const sendFn = vi2.fn((outMsg) => {
354
+ nodeRedNode.send(outMsg);
355
+ });
356
+ await nodeRef._input(msg, sendFn);
357
+ },
358
+ async close(removed = false) {
359
+ await nodeRef._closed(removed);
360
+ },
361
+ reset() {
362
+ sentMessages.length = 0;
363
+ statusCalls.length = 0;
364
+ nodeRedNode.log.mockClear();
365
+ nodeRedNode.warn.mockClear();
366
+ nodeRedNode.error.mockClear();
367
+ },
368
+ sent(port) {
369
+ if (port === void 0) return [...sentMessages];
370
+ return sentMessages.map(
371
+ (msg) => Array.isArray(msg) ? msg[port] : port === 0 ? msg : void 0
372
+ ).filter((msg) => msg != null);
373
+ },
374
+ statuses() {
375
+ return [...statusCalls];
376
+ },
377
+ logged(level) {
378
+ if (level) {
379
+ return nodeRedNode[level === "info" ? "log" : level].mock.calls.map(
380
+ (c) => c[0]
381
+ );
382
+ }
383
+ return [
384
+ ...nodeRedNode.log.mock.calls.map((c) => c[0]),
385
+ ...nodeRedNode.warn.mock.calls.map((c) => c[0]),
386
+ ...nodeRedNode.error.mock.calls.map((c) => c[0])
387
+ ];
388
+ },
389
+ warned() {
390
+ return nodeRedNode.warn.mock.calls.map((c) => c[0]);
391
+ },
392
+ errored() {
393
+ return nodeRedNode.error.mock.calls.map((c) => c[0]);
394
+ }
395
+ };
396
+ return Object.assign(node, helpers);
397
+ }
398
+ function isConfigNode(NodeClass) {
399
+ return NodeClass.category === "config";
400
+ }
401
+ async function createNode(NodeClass, options = {}) {
402
+ const {
403
+ config: userConfig = {},
404
+ credentials = {},
405
+ configNodes = {},
406
+ settings = {},
407
+ overrides: overrideOpts = {}
408
+ } = options;
409
+ const redNodes = buildNodeRedNodes(configNodes);
410
+ const RED = createMockRED({ nodes: redNodes, settings });
411
+ initValidator(RED);
412
+ const configDefaults = {
413
+ id: overrideOpts.id ?? `test-${Math.random().toString(36).slice(2, 10)}`,
414
+ type: NodeClass.type
415
+ };
416
+ if (isConfigNode(NodeClass)) {
417
+ configDefaults._users = [];
418
+ }
419
+ const config = buildConfig(NodeClass, {
420
+ ...configDefaults,
421
+ ...userConfig
422
+ });
423
+ const nodeRedNode = createMockNodeRedNode({
424
+ id: config.id,
425
+ type: NodeClass.type,
426
+ name: config.name ?? "",
427
+ credentials,
428
+ ...overrideOpts
429
+ });
430
+ await Promise.resolve(
431
+ NodeClass._registered?.(RED) ?? NodeClass.registered?.(RED)
432
+ );
433
+ const node = new NodeClass(RED, nodeRedNode, config, credentials);
434
+ const augmented = attachHelpers(node, nodeRedNode);
435
+ await Promise.resolve(augmented.created?.());
436
+ return { node: augmented, RED };
437
+ }
438
+ export {
439
+ createNode
440
+ };
@@ -0,0 +1,39 @@
1
+
2
+ export interface CreateNodeOptions {
3
+ config?: Record<string, any>;
4
+ credentials?: Record<string, any>;
5
+ configNodes?: Record<string, any>;
6
+ settings?: Record<string, any>;
7
+ overrides?: Record<string, any>;
8
+ }
9
+
10
+ type ExtractInput<T> = T extends { input(msg: infer I): any } ? I : any;
11
+ type ExtractOutput<T> = T extends { send(msg: infer O): any } ? O : any;
12
+
13
+ export interface TestNodeHelpers<TInput = any, TOutput = any> {
14
+ receive(msg: TInput): Promise<void>;
15
+ close(removed?: boolean): Promise<void>;
16
+ reset(): void;
17
+ sent(): TOutput[];
18
+ sent(port: number): any[];
19
+ statuses(): any[];
20
+ logged(level?: "info" | "warn" | "error" | "debug"): string[];
21
+ warned(): string[];
22
+ errored(): string[];
23
+ }
24
+
25
+ export interface CreateNodeResult<T> {
26
+ node: T & TestNodeHelpers<ExtractInput<T>, ExtractOutput<T>>;
27
+ RED: any;
28
+ }
29
+
30
+ interface NodeClass {
31
+ readonly type: string;
32
+ readonly category?: string;
33
+ readonly configSchema?: any;
34
+ registered?(RED: any): void | Promise<void>;
35
+ _registered?(RED: any): void | Promise<void>;
36
+ new (...args: any[]): any;
37
+ }
38
+
39
+ export declare function createNode<T extends NodeClass>(NodeClass: T, options?: CreateNodeOptions): Promise<CreateNodeResult<InstanceType<T>>>;