@objectstack/service-automation 3.0.7
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/.turbo/turbo-build.log +22 -0
- package/LICENSE +202 -0
- package/dist/index.cjs +381 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +350 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/engine.test.ts +608 -0
- package/src/engine.ts +257 -0
- package/src/index.ts +14 -0
- package/src/plugin.ts +80 -0
- package/src/plugins/crud-nodes-plugin.ts +68 -0
- package/src/plugins/http-connector-plugin.ts +70 -0
- package/src/plugins/logic-nodes-plugin.ts +78 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
4
|
+
import { LiteKernel } from '@objectstack/core';
|
|
5
|
+
import { AutomationEngine } from './engine.js';
|
|
6
|
+
import { AutomationServicePlugin } from './plugin.js';
|
|
7
|
+
import { CrudNodesPlugin } from './plugins/crud-nodes-plugin.js';
|
|
8
|
+
import { LogicNodesPlugin } from './plugins/logic-nodes-plugin.js';
|
|
9
|
+
import { HttpConnectorPlugin } from './plugins/http-connector-plugin.js';
|
|
10
|
+
import type { NodeExecutor } from './engine.js';
|
|
11
|
+
import type { IAutomationService } from '@objectstack/spec/contracts';
|
|
12
|
+
|
|
13
|
+
// ─── Helper: Create a minimal logger for unit tests ─────────────────
|
|
14
|
+
|
|
15
|
+
function createTestLogger() {
|
|
16
|
+
return {
|
|
17
|
+
info: () => {},
|
|
18
|
+
warn: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
debug: () => {},
|
|
21
|
+
child: () => createTestLogger(),
|
|
22
|
+
} as any;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── AutomationEngine Unit Tests ─────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('AutomationEngine', () => {
|
|
28
|
+
let engine: AutomationEngine;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
engine = new AutomationEngine(createTestLogger());
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('Node Executor Registration', () => {
|
|
35
|
+
it('should register a node executor', () => {
|
|
36
|
+
const executor: NodeExecutor = {
|
|
37
|
+
type: 'test_node',
|
|
38
|
+
async execute() {
|
|
39
|
+
return { success: true };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
engine.registerNodeExecutor(executor);
|
|
43
|
+
expect(engine.getRegisteredNodeTypes()).toContain('test_node');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should replace an existing executor for the same type', () => {
|
|
47
|
+
engine.registerNodeExecutor({
|
|
48
|
+
type: 'test_node',
|
|
49
|
+
async execute() {
|
|
50
|
+
return { success: true, output: { version: 1 } };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
engine.registerNodeExecutor({
|
|
54
|
+
type: 'test_node',
|
|
55
|
+
async execute() {
|
|
56
|
+
return { success: true, output: { version: 2 } };
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
expect(engine.getRegisteredNodeTypes().filter(t => t === 'test_node')).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should unregister a node executor', () => {
|
|
63
|
+
engine.registerNodeExecutor({
|
|
64
|
+
type: 'test_node',
|
|
65
|
+
async execute() {
|
|
66
|
+
return { success: true };
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
engine.unregisterNodeExecutor('test_node');
|
|
70
|
+
expect(engine.getRegisteredNodeTypes()).not.toContain('test_node');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('Trigger Registration', () => {
|
|
75
|
+
it('should register and unregister a trigger', () => {
|
|
76
|
+
engine.registerTrigger({
|
|
77
|
+
type: 'schedule',
|
|
78
|
+
start: () => {},
|
|
79
|
+
stop: () => {},
|
|
80
|
+
});
|
|
81
|
+
expect(engine.getRegisteredTriggerTypes()).toContain('schedule');
|
|
82
|
+
|
|
83
|
+
engine.unregisterTrigger('schedule');
|
|
84
|
+
expect(engine.getRegisteredTriggerTypes()).not.toContain('schedule');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('Flow Registration', () => {
|
|
89
|
+
it('should register and list flows', async () => {
|
|
90
|
+
engine.registerFlow('test_flow', {
|
|
91
|
+
name: 'test_flow',
|
|
92
|
+
label: 'Test Flow',
|
|
93
|
+
type: 'autolaunched',
|
|
94
|
+
nodes: [
|
|
95
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
96
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
97
|
+
],
|
|
98
|
+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const flows = await engine.listFlows();
|
|
102
|
+
expect(flows).toContain('test_flow');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should unregister a flow', async () => {
|
|
106
|
+
engine.registerFlow('temp_flow', {
|
|
107
|
+
name: 'temp_flow',
|
|
108
|
+
label: 'Temp',
|
|
109
|
+
type: 'autolaunched',
|
|
110
|
+
nodes: [
|
|
111
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
112
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
113
|
+
],
|
|
114
|
+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
|
|
115
|
+
});
|
|
116
|
+
engine.unregisterFlow('temp_flow');
|
|
117
|
+
const flows = await engine.listFlows();
|
|
118
|
+
expect(flows).not.toContain('temp_flow');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should reject invalid flow definitions', () => {
|
|
122
|
+
expect(() => engine.registerFlow('bad', { invalid: true })).toThrow();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Flow Execution', () => {
|
|
127
|
+
it('should return error for non-existent flow', async () => {
|
|
128
|
+
const result = await engine.execute('nonexistent');
|
|
129
|
+
expect(result.success).toBe(false);
|
|
130
|
+
expect(result.error).toContain('not found');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should execute a simple start → end flow', async () => {
|
|
134
|
+
engine.registerFlow('simple', {
|
|
135
|
+
name: 'simple',
|
|
136
|
+
label: 'Simple',
|
|
137
|
+
type: 'autolaunched',
|
|
138
|
+
nodes: [
|
|
139
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
140
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
141
|
+
],
|
|
142
|
+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = await engine.execute('simple');
|
|
146
|
+
expect(result.success).toBe(true);
|
|
147
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should execute nodes and collect output', async () => {
|
|
151
|
+
engine.registerNodeExecutor({
|
|
152
|
+
type: 'assignment',
|
|
153
|
+
async execute(node, variables) {
|
|
154
|
+
variables.set('result', 42);
|
|
155
|
+
return { success: true };
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
engine.registerFlow('with_assignment', {
|
|
160
|
+
name: 'with_assignment',
|
|
161
|
+
label: 'With Assignment',
|
|
162
|
+
type: 'autolaunched',
|
|
163
|
+
variables: [
|
|
164
|
+
{ name: 'result', type: 'number', isOutput: true },
|
|
165
|
+
],
|
|
166
|
+
nodes: [
|
|
167
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
168
|
+
{ id: 'assign', type: 'assignment', label: 'Assign', config: { result: 42 } },
|
|
169
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
170
|
+
],
|
|
171
|
+
edges: [
|
|
172
|
+
{ id: 'e1', source: 'start', target: 'assign' },
|
|
173
|
+
{ id: 'e2', source: 'assign', target: 'end' },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const result = await engine.execute('with_assignment');
|
|
178
|
+
expect(result.success).toBe(true);
|
|
179
|
+
expect(result.output).toEqual({ result: 42 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should pass input variables from context', async () => {
|
|
183
|
+
let capturedValue: unknown;
|
|
184
|
+
|
|
185
|
+
engine.registerNodeExecutor({
|
|
186
|
+
type: 'script',
|
|
187
|
+
async execute(_node, variables) {
|
|
188
|
+
capturedValue = variables.get('input_val');
|
|
189
|
+
return { success: true };
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
engine.registerFlow('input_test', {
|
|
194
|
+
name: 'input_test',
|
|
195
|
+
label: 'Input Test',
|
|
196
|
+
type: 'autolaunched',
|
|
197
|
+
variables: [
|
|
198
|
+
{ name: 'input_val', type: 'text', isInput: true },
|
|
199
|
+
],
|
|
200
|
+
nodes: [
|
|
201
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
202
|
+
{ id: 'run', type: 'script', label: 'Run' },
|
|
203
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
204
|
+
],
|
|
205
|
+
edges: [
|
|
206
|
+
{ id: 'e1', source: 'start', target: 'run' },
|
|
207
|
+
{ id: 'e2', source: 'run', target: 'end' },
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await engine.execute('input_test', { params: { input_val: 'hello' } });
|
|
212
|
+
expect(capturedValue).toBe('hello');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should inject $record from context', async () => {
|
|
216
|
+
let capturedRecord: unknown;
|
|
217
|
+
|
|
218
|
+
engine.registerNodeExecutor({
|
|
219
|
+
type: 'script',
|
|
220
|
+
async execute(_node, variables) {
|
|
221
|
+
capturedRecord = variables.get('$record');
|
|
222
|
+
return { success: true };
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
engine.registerFlow('record_test', {
|
|
227
|
+
name: 'record_test',
|
|
228
|
+
label: 'Record Test',
|
|
229
|
+
type: 'record_change',
|
|
230
|
+
nodes: [
|
|
231
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
232
|
+
{ id: 'run', type: 'script', label: 'Run' },
|
|
233
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
234
|
+
],
|
|
235
|
+
edges: [
|
|
236
|
+
{ id: 'e1', source: 'start', target: 'run' },
|
|
237
|
+
{ id: 'e2', source: 'run', target: 'end' },
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await engine.execute('record_test', {
|
|
242
|
+
record: { id: 'rec-1', name: 'Test' },
|
|
243
|
+
object: 'account',
|
|
244
|
+
event: 'on_create',
|
|
245
|
+
});
|
|
246
|
+
expect(capturedRecord).toEqual({ id: 'rec-1', name: 'Test' });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should fail when node executor is missing', async () => {
|
|
250
|
+
engine.registerFlow('missing_executor', {
|
|
251
|
+
name: 'missing_executor',
|
|
252
|
+
label: 'Missing',
|
|
253
|
+
type: 'autolaunched',
|
|
254
|
+
nodes: [
|
|
255
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
256
|
+
{ id: 'unknown', type: 'get_record', label: 'Get' },
|
|
257
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
258
|
+
],
|
|
259
|
+
edges: [
|
|
260
|
+
{ id: 'e1', source: 'start', target: 'unknown' },
|
|
261
|
+
{ id: 'e2', source: 'unknown', target: 'end' },
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const result = await engine.execute('missing_executor');
|
|
266
|
+
expect(result.success).toBe(false);
|
|
267
|
+
expect(result.error).toContain('No executor registered');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should fail when flow has no start node', async () => {
|
|
271
|
+
engine.registerFlow('no_start', {
|
|
272
|
+
name: 'no_start',
|
|
273
|
+
label: 'No Start',
|
|
274
|
+
type: 'autolaunched',
|
|
275
|
+
nodes: [
|
|
276
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
277
|
+
],
|
|
278
|
+
edges: [],
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const result = await engine.execute('no_start');
|
|
282
|
+
expect(result.success).toBe(false);
|
|
283
|
+
expect(result.error).toContain('no start node');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should handle node execution failure', async () => {
|
|
287
|
+
engine.registerNodeExecutor({
|
|
288
|
+
type: 'script',
|
|
289
|
+
async execute() {
|
|
290
|
+
return { success: false, error: 'Script timeout' };
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
engine.registerFlow('failing_flow', {
|
|
295
|
+
name: 'failing_flow',
|
|
296
|
+
label: 'Failing',
|
|
297
|
+
type: 'autolaunched',
|
|
298
|
+
nodes: [
|
|
299
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
300
|
+
{ id: 'fail', type: 'script', label: 'Fail' },
|
|
301
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
302
|
+
],
|
|
303
|
+
edges: [
|
|
304
|
+
{ id: 'e1', source: 'start', target: 'fail' },
|
|
305
|
+
{ id: 'e2', source: 'fail', target: 'end' },
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const result = await engine.execute('failing_flow');
|
|
310
|
+
expect(result.success).toBe(false);
|
|
311
|
+
expect(result.error).toContain('Script timeout');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should follow conditional edges', async () => {
|
|
315
|
+
const executed: string[] = [];
|
|
316
|
+
|
|
317
|
+
engine.registerNodeExecutor({
|
|
318
|
+
type: 'assignment',
|
|
319
|
+
async execute(node) {
|
|
320
|
+
executed.push(node.id);
|
|
321
|
+
return { success: true };
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
engine.registerFlow('branching', {
|
|
326
|
+
name: 'branching',
|
|
327
|
+
label: 'Branching',
|
|
328
|
+
type: 'autolaunched',
|
|
329
|
+
nodes: [
|
|
330
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
331
|
+
{ id: 'yes_branch', type: 'assignment', label: 'Yes' },
|
|
332
|
+
{ id: 'no_branch', type: 'assignment', label: 'No' },
|
|
333
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
334
|
+
],
|
|
335
|
+
edges: [
|
|
336
|
+
{ id: 'e1', source: 'start', target: 'yes_branch', condition: 'true' },
|
|
337
|
+
{ id: 'e2', source: 'start', target: 'no_branch', condition: 'false' },
|
|
338
|
+
{ id: 'e3', source: 'yes_branch', target: 'end' },
|
|
339
|
+
{ id: 'e4', source: 'no_branch', target: 'end' },
|
|
340
|
+
],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await engine.execute('branching');
|
|
344
|
+
expect(executed).toContain('yes_branch');
|
|
345
|
+
expect(executed).not.toContain('no_branch');
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('IAutomationService Contract', () => {
|
|
350
|
+
it('should satisfy IAutomationService interface', () => {
|
|
351
|
+
const service: IAutomationService = engine;
|
|
352
|
+
expect(typeof service.execute).toBe('function');
|
|
353
|
+
expect(typeof service.listFlows).toBe('function');
|
|
354
|
+
expect(typeof service.registerFlow).toBe('function');
|
|
355
|
+
expect(typeof service.unregisterFlow).toBe('function');
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ─── Plugin Integration Tests ────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
describe('AutomationServicePlugin (Kernel Integration)', () => {
|
|
363
|
+
it('should register automation service via LiteKernel', async () => {
|
|
364
|
+
const kernel = new LiteKernel();
|
|
365
|
+
kernel.use(new AutomationServicePlugin());
|
|
366
|
+
await kernel.bootstrap();
|
|
367
|
+
|
|
368
|
+
const service = kernel.getService<IAutomationService>('automation');
|
|
369
|
+
expect(service).toBeDefined();
|
|
370
|
+
expect(typeof service.execute).toBe('function');
|
|
371
|
+
|
|
372
|
+
await kernel.shutdown();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should support full plugin assembly with node plugins', async () => {
|
|
376
|
+
const kernel = new LiteKernel();
|
|
377
|
+
kernel.use(new AutomationServicePlugin());
|
|
378
|
+
kernel.use(new CrudNodesPlugin());
|
|
379
|
+
kernel.use(new LogicNodesPlugin());
|
|
380
|
+
kernel.use(new HttpConnectorPlugin());
|
|
381
|
+
await kernel.bootstrap();
|
|
382
|
+
|
|
383
|
+
const engine = kernel.getService<AutomationEngine>('automation');
|
|
384
|
+
const nodeTypes = engine.getRegisteredNodeTypes();
|
|
385
|
+
|
|
386
|
+
// CRUD nodes
|
|
387
|
+
expect(nodeTypes).toContain('get_record');
|
|
388
|
+
expect(nodeTypes).toContain('create_record');
|
|
389
|
+
expect(nodeTypes).toContain('update_record');
|
|
390
|
+
expect(nodeTypes).toContain('delete_record');
|
|
391
|
+
|
|
392
|
+
// Logic nodes
|
|
393
|
+
expect(nodeTypes).toContain('decision');
|
|
394
|
+
expect(nodeTypes).toContain('assignment');
|
|
395
|
+
expect(nodeTypes).toContain('loop');
|
|
396
|
+
|
|
397
|
+
// HTTP/Connector nodes
|
|
398
|
+
expect(nodeTypes).toContain('http_request');
|
|
399
|
+
expect(nodeTypes).toContain('connector_action');
|
|
400
|
+
|
|
401
|
+
await kernel.shutdown();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('should execute a flow end-to-end through kernel', async () => {
|
|
405
|
+
const kernel = new LiteKernel();
|
|
406
|
+
kernel.use(new AutomationServicePlugin());
|
|
407
|
+
kernel.use(new CrudNodesPlugin());
|
|
408
|
+
kernel.use(new LogicNodesPlugin());
|
|
409
|
+
await kernel.bootstrap();
|
|
410
|
+
|
|
411
|
+
const automation = kernel.getService<IAutomationService>('automation');
|
|
412
|
+
|
|
413
|
+
automation.registerFlow!('approval_flow', {
|
|
414
|
+
name: 'approval_flow',
|
|
415
|
+
label: 'Approval Flow',
|
|
416
|
+
type: 'record_change',
|
|
417
|
+
variables: [
|
|
418
|
+
{ name: 'status', type: 'text', isOutput: true },
|
|
419
|
+
],
|
|
420
|
+
nodes: [
|
|
421
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
422
|
+
{ id: 'assign', type: 'assignment', label: 'Set Status', config: { status: 'approved' } },
|
|
423
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
424
|
+
],
|
|
425
|
+
edges: [
|
|
426
|
+
{ id: 'e1', source: 'start', target: 'assign' },
|
|
427
|
+
{ id: 'e2', source: 'assign', target: 'end' },
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const result = await automation.execute('approval_flow', {
|
|
432
|
+
record: { id: 'rec-1', amount: 50000 },
|
|
433
|
+
object: 'opportunity',
|
|
434
|
+
event: 'on_create',
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
expect(result.success).toBe(true);
|
|
438
|
+
expect(result.output).toEqual({ status: 'approved' });
|
|
439
|
+
|
|
440
|
+
await kernel.shutdown();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ─── Hot-plug Tests ──────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
describe('Hot-plug Node Executor', () => {
|
|
447
|
+
it('should allow adding new node types at runtime', async () => {
|
|
448
|
+
const kernel = new LiteKernel();
|
|
449
|
+
kernel.use(new AutomationServicePlugin());
|
|
450
|
+
await kernel.bootstrap();
|
|
451
|
+
|
|
452
|
+
const engine = kernel.getService<AutomationEngine>('automation');
|
|
453
|
+
expect(engine.getRegisteredNodeTypes()).toHaveLength(0);
|
|
454
|
+
|
|
455
|
+
// Hot-plug a script node executor (valid FlowNodeAction, no built-in executor)
|
|
456
|
+
engine.registerNodeExecutor({
|
|
457
|
+
type: 'script',
|
|
458
|
+
async execute() {
|
|
459
|
+
return { success: true, output: { result: 'custom_script' } };
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(engine.getRegisteredNodeTypes()).toContain('script');
|
|
464
|
+
|
|
465
|
+
// Use it in a flow immediately — no restart needed
|
|
466
|
+
engine.registerFlow('hotplug_flow', {
|
|
467
|
+
name: 'hotplug_flow',
|
|
468
|
+
label: 'Hot-plug Flow',
|
|
469
|
+
type: 'autolaunched',
|
|
470
|
+
nodes: [
|
|
471
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
472
|
+
{ id: 'run_script', type: 'script', label: 'Run Script' },
|
|
473
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
474
|
+
],
|
|
475
|
+
edges: [
|
|
476
|
+
{ id: 'e1', source: 'start', target: 'run_script' },
|
|
477
|
+
{ id: 'e2', source: 'run_script', target: 'end' },
|
|
478
|
+
],
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const result = await engine.execute('hotplug_flow');
|
|
482
|
+
expect(result.success).toBe(true);
|
|
483
|
+
|
|
484
|
+
await kernel.shutdown();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should allow removing node types at runtime', async () => {
|
|
488
|
+
const engine = new AutomationEngine(createTestLogger());
|
|
489
|
+
|
|
490
|
+
engine.registerNodeExecutor({
|
|
491
|
+
type: 'temp_node',
|
|
492
|
+
async execute() {
|
|
493
|
+
return { success: true };
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
expect(engine.getRegisteredNodeTypes()).toContain('temp_node');
|
|
497
|
+
|
|
498
|
+
engine.unregisterNodeExecutor('temp_node');
|
|
499
|
+
expect(engine.getRegisteredNodeTypes()).not.toContain('temp_node');
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ─── CRUD Nodes Plugin Tests ─────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
describe('CrudNodesPlugin', () => {
|
|
506
|
+
let engine: AutomationEngine;
|
|
507
|
+
|
|
508
|
+
beforeEach(async () => {
|
|
509
|
+
const kernel = new LiteKernel();
|
|
510
|
+
kernel.use(new AutomationServicePlugin());
|
|
511
|
+
kernel.use(new CrudNodesPlugin());
|
|
512
|
+
await kernel.bootstrap();
|
|
513
|
+
engine = kernel.getService<AutomationEngine>('automation');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('should register all CRUD node types', () => {
|
|
517
|
+
const types = engine.getRegisteredNodeTypes();
|
|
518
|
+
expect(types).toContain('get_record');
|
|
519
|
+
expect(types).toContain('create_record');
|
|
520
|
+
expect(types).toContain('update_record');
|
|
521
|
+
expect(types).toContain('delete_record');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('should execute get_record node successfully', async () => {
|
|
525
|
+
engine.registerFlow('get_test', {
|
|
526
|
+
name: 'get_test',
|
|
527
|
+
label: 'Get Test',
|
|
528
|
+
type: 'autolaunched',
|
|
529
|
+
nodes: [
|
|
530
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
531
|
+
{ id: 'get', type: 'get_record', label: 'Get', config: { object: 'account' } },
|
|
532
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
533
|
+
],
|
|
534
|
+
edges: [
|
|
535
|
+
{ id: 'e1', source: 'start', target: 'get' },
|
|
536
|
+
{ id: 'e2', source: 'get', target: 'end' },
|
|
537
|
+
],
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const result = await engine.execute('get_test');
|
|
541
|
+
expect(result.success).toBe(true);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// ─── Logic Nodes Plugin Tests ────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
describe('LogicNodesPlugin', () => {
|
|
548
|
+
let engine: AutomationEngine;
|
|
549
|
+
|
|
550
|
+
beforeEach(async () => {
|
|
551
|
+
const kernel = new LiteKernel();
|
|
552
|
+
kernel.use(new AutomationServicePlugin());
|
|
553
|
+
kernel.use(new LogicNodesPlugin());
|
|
554
|
+
await kernel.bootstrap();
|
|
555
|
+
engine = kernel.getService<AutomationEngine>('automation');
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('should register all logic node types', () => {
|
|
559
|
+
const types = engine.getRegisteredNodeTypes();
|
|
560
|
+
expect(types).toContain('decision');
|
|
561
|
+
expect(types).toContain('assignment');
|
|
562
|
+
expect(types).toContain('loop');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('should execute assignment node and set variables', async () => {
|
|
566
|
+
engine.registerFlow('assign_test', {
|
|
567
|
+
name: 'assign_test',
|
|
568
|
+
label: 'Assign Test',
|
|
569
|
+
type: 'autolaunched',
|
|
570
|
+
variables: [
|
|
571
|
+
{ name: 'greeting', type: 'text', isOutput: true },
|
|
572
|
+
],
|
|
573
|
+
nodes: [
|
|
574
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
575
|
+
{ id: 'set', type: 'assignment', label: 'Set', config: { greeting: 'Hello World' } },
|
|
576
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
577
|
+
],
|
|
578
|
+
edges: [
|
|
579
|
+
{ id: 'e1', source: 'start', target: 'set' },
|
|
580
|
+
{ id: 'e2', source: 'set', target: 'end' },
|
|
581
|
+
],
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const result = await engine.execute('assign_test');
|
|
585
|
+
expect(result.success).toBe(true);
|
|
586
|
+
expect(result.output).toEqual({ greeting: 'Hello World' });
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ─── HttpConnectorPlugin Tests ───────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
describe('HttpConnectorPlugin', () => {
|
|
593
|
+
let engine: AutomationEngine;
|
|
594
|
+
|
|
595
|
+
beforeEach(async () => {
|
|
596
|
+
const kernel = new LiteKernel();
|
|
597
|
+
kernel.use(new AutomationServicePlugin());
|
|
598
|
+
kernel.use(new HttpConnectorPlugin());
|
|
599
|
+
await kernel.bootstrap();
|
|
600
|
+
engine = kernel.getService<AutomationEngine>('automation');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('should register http_request and connector_action node types', () => {
|
|
604
|
+
const types = engine.getRegisteredNodeTypes();
|
|
605
|
+
expect(types).toContain('http_request');
|
|
606
|
+
expect(types).toContain('connector_action');
|
|
607
|
+
});
|
|
608
|
+
});
|