@bonsae/nrg 0.19.0 → 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 +150 -4
- package/package.json +17 -11
- package/test/client/component/config.js +1 -0
- package/test/client/e2e/index.js +6 -18
- 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 -14
- package/vite/index.js +34 -87
|
@@ -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>;
|