@objectstack/service-automation 4.0.4 → 4.1.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/dist/index.cjs +296 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -7
- package/dist/index.d.ts +41 -7
- package/dist/index.js +301 -20
- package/dist/index.js.map +1 -1
- package/package.json +32 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -168
- package/src/engine.test.ts +0 -1425
- package/src/engine.ts +0 -807
- package/src/index.ts +0 -14
- package/src/plugin.ts +0 -80
- package/src/plugins/crud-nodes-plugin.ts +0 -68
- package/src/plugins/http-connector-plugin.ts +0 -70
- package/src/plugins/logic-nodes-plugin.ts +0 -67
- package/tsconfig.json +0 -17
package/src/engine.test.ts
DELETED
|
@@ -1,1425 +0,0 @@
|
|
|
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
|
-
});
|
|
609
|
-
|
|
610
|
-
// ─── Execution History & Flow Management Tests ──────────────────────
|
|
611
|
-
|
|
612
|
-
describe('AutomationEngine - Execution History', () => {
|
|
613
|
-
let engine: AutomationEngine;
|
|
614
|
-
|
|
615
|
-
const simpleFlow = {
|
|
616
|
-
name: 'test_flow',
|
|
617
|
-
label: 'Test Flow',
|
|
618
|
-
type: 'api' as const,
|
|
619
|
-
nodes: [
|
|
620
|
-
{ id: 'start', type: 'start' as const, label: 'Start' },
|
|
621
|
-
{ id: 'end', type: 'end' as const, label: 'End' },
|
|
622
|
-
],
|
|
623
|
-
edges: [{ id: 'e1', source: 'start', target: 'end' }],
|
|
624
|
-
};
|
|
625
|
-
|
|
626
|
-
beforeEach(() => {
|
|
627
|
-
engine = new AutomationEngine(createTestLogger());
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
describe('getFlow', () => {
|
|
631
|
-
it('should return the flow definition for a registered flow', async () => {
|
|
632
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
633
|
-
const flow = await engine.getFlow('test_flow');
|
|
634
|
-
expect(flow).not.toBeNull();
|
|
635
|
-
expect(flow!.name).toBe('test_flow');
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it('should return null for a non-existent flow', async () => {
|
|
639
|
-
const flow = await engine.getFlow('non_existent');
|
|
640
|
-
expect(flow).toBeNull();
|
|
641
|
-
});
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
describe('toggleFlow', () => {
|
|
645
|
-
it('should disable a flow', async () => {
|
|
646
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
647
|
-
await engine.toggleFlow('test_flow', false);
|
|
648
|
-
|
|
649
|
-
const result = await engine.execute('test_flow');
|
|
650
|
-
expect(result.success).toBe(false);
|
|
651
|
-
expect(result.error).toContain('disabled');
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
it('should enable a disabled flow', async () => {
|
|
655
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
656
|
-
await engine.toggleFlow('test_flow', false);
|
|
657
|
-
await engine.toggleFlow('test_flow', true);
|
|
658
|
-
|
|
659
|
-
const result = await engine.execute('test_flow');
|
|
660
|
-
expect(result.success).toBe(true);
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
it('should throw for non-existent flow', async () => {
|
|
664
|
-
await expect(engine.toggleFlow('missing', true)).rejects.toThrow('not found');
|
|
665
|
-
});
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
describe('listRuns', () => {
|
|
669
|
-
it('should return empty array when no runs exist', async () => {
|
|
670
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
671
|
-
const runs = await engine.listRuns('test_flow');
|
|
672
|
-
expect(runs).toHaveLength(0);
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
it('should return execution logs after running a flow', async () => {
|
|
676
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
677
|
-
await engine.execute('test_flow');
|
|
678
|
-
await engine.execute('test_flow');
|
|
679
|
-
|
|
680
|
-
const runs = await engine.listRuns('test_flow');
|
|
681
|
-
expect(runs).toHaveLength(2);
|
|
682
|
-
expect(runs[0].status).toBe('completed');
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
it('should filter runs by flow name', async () => {
|
|
686
|
-
engine.registerFlow('flow_a', { ...simpleFlow, name: 'flow_a' });
|
|
687
|
-
engine.registerFlow('flow_b', { ...simpleFlow, name: 'flow_b' });
|
|
688
|
-
await engine.execute('flow_a');
|
|
689
|
-
await engine.execute('flow_b');
|
|
690
|
-
await engine.execute('flow_a');
|
|
691
|
-
|
|
692
|
-
const runsA = await engine.listRuns('flow_a');
|
|
693
|
-
const runsB = await engine.listRuns('flow_b');
|
|
694
|
-
expect(runsA).toHaveLength(2);
|
|
695
|
-
expect(runsB).toHaveLength(1);
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
it('should respect limit option', async () => {
|
|
699
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
700
|
-
for (let i = 0; i < 5; i++) {
|
|
701
|
-
await engine.execute('test_flow');
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
const runs = await engine.listRuns('test_flow', { limit: 3 });
|
|
705
|
-
expect(runs).toHaveLength(3);
|
|
706
|
-
});
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
describe('getRun', () => {
|
|
710
|
-
it('should return null for non-existent run', async () => {
|
|
711
|
-
const run = await engine.getRun('non_existent');
|
|
712
|
-
expect(run).toBeNull();
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
it('should return an execution log by run ID', async () => {
|
|
716
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
717
|
-
await engine.execute('test_flow');
|
|
718
|
-
|
|
719
|
-
const runs = await engine.listRuns('test_flow');
|
|
720
|
-
const run = await engine.getRun(runs[0].id);
|
|
721
|
-
expect(run).not.toBeNull();
|
|
722
|
-
expect(run!.flowName).toBe('test_flow');
|
|
723
|
-
expect(run!.status).toBe('completed');
|
|
724
|
-
});
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
describe('execution log recording', () => {
|
|
728
|
-
it('should record run ID and timing', async () => {
|
|
729
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
730
|
-
await engine.execute('test_flow');
|
|
731
|
-
|
|
732
|
-
const runs = await engine.listRuns('test_flow');
|
|
733
|
-
expect(runs[0].id).toMatch(/^run_/);
|
|
734
|
-
expect(runs[0].startedAt).toBeTruthy();
|
|
735
|
-
expect(runs[0].completedAt).toBeTruthy();
|
|
736
|
-
expect(typeof runs[0].durationMs).toBe('number');
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
it('should record failed executions', async () => {
|
|
740
|
-
const failingFlow = {
|
|
741
|
-
...simpleFlow,
|
|
742
|
-
name: 'failing_flow',
|
|
743
|
-
nodes: [
|
|
744
|
-
{ id: 'start', type: 'start' as const, label: 'Start' },
|
|
745
|
-
{ id: 'bad', type: 'script' as const, label: 'Bad' },
|
|
746
|
-
{ id: 'end', type: 'end' as const, label: 'End' },
|
|
747
|
-
],
|
|
748
|
-
edges: [
|
|
749
|
-
{ id: 'e1', source: 'start', target: 'bad' },
|
|
750
|
-
{ id: 'e2', source: 'bad', target: 'end' },
|
|
751
|
-
],
|
|
752
|
-
};
|
|
753
|
-
engine.registerFlow('failing_flow', failingFlow);
|
|
754
|
-
await engine.execute('failing_flow');
|
|
755
|
-
|
|
756
|
-
const runs = await engine.listRuns('failing_flow');
|
|
757
|
-
expect(runs).toHaveLength(1);
|
|
758
|
-
expect(runs[0].status).toBe('failed');
|
|
759
|
-
expect(runs[0].error).toBeTruthy();
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
it('should record trigger context', async () => {
|
|
763
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
764
|
-
await engine.execute('test_flow', {
|
|
765
|
-
event: 'on_create',
|
|
766
|
-
userId: 'user_1',
|
|
767
|
-
object: 'account',
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
const runs = await engine.listRuns('test_flow');
|
|
771
|
-
expect(runs[0].trigger.type).toBe('on_create');
|
|
772
|
-
expect(runs[0].trigger.userId).toBe('user_1');
|
|
773
|
-
expect(runs[0].trigger.object).toBe('account');
|
|
774
|
-
});
|
|
775
|
-
});
|
|
776
|
-
|
|
777
|
-
describe('unregisterFlow cleans up enabled state', () => {
|
|
778
|
-
it('should remove enabled state on unregister', async () => {
|
|
779
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
780
|
-
await engine.toggleFlow('test_flow', false);
|
|
781
|
-
engine.unregisterFlow('test_flow');
|
|
782
|
-
|
|
783
|
-
// Re-register should default to enabled
|
|
784
|
-
engine.registerFlow('test_flow', simpleFlow);
|
|
785
|
-
const result = await engine.execute('test_flow');
|
|
786
|
-
expect(result.success).toBe(true);
|
|
787
|
-
});
|
|
788
|
-
});
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
// ─── Fault Edge Tests ────────────────────────────────────────────────
|
|
792
|
-
|
|
793
|
-
describe('AutomationEngine - Fault Edge Support', () => {
|
|
794
|
-
let engine: AutomationEngine;
|
|
795
|
-
|
|
796
|
-
beforeEach(() => {
|
|
797
|
-
engine = new AutomationEngine(createTestLogger());
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
it('should follow fault edge when node fails', async () => {
|
|
801
|
-
const executed: string[] = [];
|
|
802
|
-
|
|
803
|
-
engine.registerNodeExecutor({
|
|
804
|
-
type: 'script',
|
|
805
|
-
async execute(node) {
|
|
806
|
-
if (node.id === 'risky') {
|
|
807
|
-
return { success: false, error: 'Script crashed' };
|
|
808
|
-
}
|
|
809
|
-
executed.push(node.id);
|
|
810
|
-
return { success: true };
|
|
811
|
-
},
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
engine.registerFlow('fault_flow', {
|
|
815
|
-
name: 'fault_flow',
|
|
816
|
-
label: 'Fault Flow',
|
|
817
|
-
type: 'autolaunched',
|
|
818
|
-
variables: [{ name: 'status', type: 'text', isOutput: true }],
|
|
819
|
-
nodes: [
|
|
820
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
821
|
-
{ id: 'risky', type: 'script', label: 'Risky' },
|
|
822
|
-
{ id: 'handler', type: 'script', label: 'Error Handler' },
|
|
823
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
824
|
-
],
|
|
825
|
-
edges: [
|
|
826
|
-
{ id: 'e1', source: 'start', target: 'risky' },
|
|
827
|
-
{ id: 'e2', source: 'risky', target: 'end' },
|
|
828
|
-
{ id: 'e_fault', source: 'risky', target: 'handler', type: 'fault' },
|
|
829
|
-
{ id: 'e3', source: 'handler', target: 'end' },
|
|
830
|
-
],
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
const result = await engine.execute('fault_flow');
|
|
834
|
-
expect(result.success).toBe(true);
|
|
835
|
-
expect(executed).toContain('handler');
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
it('should write error info to $error variable on fault path', async () => {
|
|
839
|
-
let capturedError: unknown;
|
|
840
|
-
|
|
841
|
-
engine.registerNodeExecutor({
|
|
842
|
-
type: 'script',
|
|
843
|
-
async execute(node, variables) {
|
|
844
|
-
if (node.id === 'risky') {
|
|
845
|
-
return { success: false, error: 'Something went wrong' };
|
|
846
|
-
}
|
|
847
|
-
capturedError = variables.get('$error');
|
|
848
|
-
return { success: true };
|
|
849
|
-
},
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
engine.registerFlow('fault_error_ctx', {
|
|
853
|
-
name: 'fault_error_ctx',
|
|
854
|
-
label: 'Fault Error Context',
|
|
855
|
-
type: 'autolaunched',
|
|
856
|
-
nodes: [
|
|
857
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
858
|
-
{ id: 'risky', type: 'script', label: 'Risky' },
|
|
859
|
-
{ id: 'handler', type: 'script', label: 'Handler' },
|
|
860
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
861
|
-
],
|
|
862
|
-
edges: [
|
|
863
|
-
{ id: 'e1', source: 'start', target: 'risky' },
|
|
864
|
-
{ id: 'e2', source: 'risky', target: 'end' },
|
|
865
|
-
{ id: 'e_fault', source: 'risky', target: 'handler', type: 'fault' },
|
|
866
|
-
{ id: 'e3', source: 'handler', target: 'end' },
|
|
867
|
-
],
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
await engine.execute('fault_error_ctx');
|
|
871
|
-
expect(capturedError).toBeDefined();
|
|
872
|
-
expect((capturedError as any).message).toBe('Something went wrong');
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
it('should throw when no fault edge and node fails', async () => {
|
|
876
|
-
engine.registerNodeExecutor({
|
|
877
|
-
type: 'script',
|
|
878
|
-
async execute() {
|
|
879
|
-
return { success: false, error: 'Fatal error' };
|
|
880
|
-
},
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
engine.registerFlow('no_fault', {
|
|
884
|
-
name: 'no_fault',
|
|
885
|
-
label: 'No Fault',
|
|
886
|
-
type: 'autolaunched',
|
|
887
|
-
nodes: [
|
|
888
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
889
|
-
{ id: 'fail', type: 'script', label: 'Fail' },
|
|
890
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
891
|
-
],
|
|
892
|
-
edges: [
|
|
893
|
-
{ id: 'e1', source: 'start', target: 'fail' },
|
|
894
|
-
{ id: 'e2', source: 'fail', target: 'end' },
|
|
895
|
-
],
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
const result = await engine.execute('no_fault');
|
|
899
|
-
expect(result.success).toBe(false);
|
|
900
|
-
expect(result.error).toContain('Fatal error');
|
|
901
|
-
});
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
// ─── Step-Level Execution Log Tests ──────────────────────────────────
|
|
905
|
-
|
|
906
|
-
describe('AutomationEngine - Step-Level Execution Logs', () => {
|
|
907
|
-
let engine: AutomationEngine;
|
|
908
|
-
|
|
909
|
-
beforeEach(() => {
|
|
910
|
-
engine = new AutomationEngine(createTestLogger());
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
it('should record step logs with timing for each node', async () => {
|
|
914
|
-
engine.registerNodeExecutor({
|
|
915
|
-
type: 'assignment',
|
|
916
|
-
async execute(node, variables) {
|
|
917
|
-
const config = (node.config ?? {}) as Record<string, unknown>;
|
|
918
|
-
for (const [key, value] of Object.entries(config)) {
|
|
919
|
-
variables.set(key, value);
|
|
920
|
-
}
|
|
921
|
-
return { success: true };
|
|
922
|
-
},
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
engine.registerFlow('step_log_flow', {
|
|
926
|
-
name: 'step_log_flow',
|
|
927
|
-
label: 'Step Log Flow',
|
|
928
|
-
type: 'autolaunched',
|
|
929
|
-
nodes: [
|
|
930
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
931
|
-
{ id: 'assign', type: 'assignment', label: 'Assign', config: { x: 1 } },
|
|
932
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
933
|
-
],
|
|
934
|
-
edges: [
|
|
935
|
-
{ id: 'e1', source: 'start', target: 'assign' },
|
|
936
|
-
{ id: 'e2', source: 'assign', target: 'end' },
|
|
937
|
-
],
|
|
938
|
-
});
|
|
939
|
-
|
|
940
|
-
await engine.execute('step_log_flow');
|
|
941
|
-
const runs = await engine.listRuns('step_log_flow');
|
|
942
|
-
expect(runs).toHaveLength(1);
|
|
943
|
-
expect(runs[0].steps.length).toBeGreaterThanOrEqual(2); // start + assign
|
|
944
|
-
expect(runs[0].steps[0].status).toBe('success');
|
|
945
|
-
expect(runs[0].steps[0].startedAt).toBeTruthy();
|
|
946
|
-
expect(typeof runs[0].steps[0].durationMs).toBe('number');
|
|
947
|
-
});
|
|
948
|
-
|
|
949
|
-
it('should record failure step in logs when node fails', async () => {
|
|
950
|
-
engine.registerNodeExecutor({
|
|
951
|
-
type: 'script',
|
|
952
|
-
async execute() {
|
|
953
|
-
return { success: false, error: 'Bad script' };
|
|
954
|
-
},
|
|
955
|
-
});
|
|
956
|
-
|
|
957
|
-
engine.registerFlow('fail_step_log', {
|
|
958
|
-
name: 'fail_step_log',
|
|
959
|
-
label: 'Fail Step Log',
|
|
960
|
-
type: 'autolaunched',
|
|
961
|
-
nodes: [
|
|
962
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
963
|
-
{ id: 'bad', type: 'script', label: 'Bad' },
|
|
964
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
965
|
-
],
|
|
966
|
-
edges: [
|
|
967
|
-
{ id: 'e1', source: 'start', target: 'bad' },
|
|
968
|
-
{ id: 'e2', source: 'bad', target: 'end' },
|
|
969
|
-
],
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
await engine.execute('fail_step_log');
|
|
973
|
-
const runs = await engine.listRuns('fail_step_log');
|
|
974
|
-
expect(runs).toHaveLength(1);
|
|
975
|
-
const failStep = runs[0].steps.find(s => s.nodeId === 'bad');
|
|
976
|
-
expect(failStep).toBeDefined();
|
|
977
|
-
expect(failStep!.status).toBe('failure');
|
|
978
|
-
expect(failStep!.error).toBeDefined();
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
it('should record flowVersion in execution log', async () => {
|
|
982
|
-
engine.registerFlow('versioned_flow', {
|
|
983
|
-
name: 'versioned_flow',
|
|
984
|
-
label: 'Versioned',
|
|
985
|
-
type: 'autolaunched',
|
|
986
|
-
version: 5,
|
|
987
|
-
nodes: [
|
|
988
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
989
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
990
|
-
],
|
|
991
|
-
edges: [{ id: 'e1', source: 'start', target: 'end' }],
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
await engine.execute('versioned_flow');
|
|
995
|
-
const runs = await engine.listRuns('versioned_flow');
|
|
996
|
-
expect(runs[0].flowVersion).toBe(5);
|
|
997
|
-
});
|
|
998
|
-
});
|
|
999
|
-
|
|
1000
|
-
// ─── DAG Cycle Detection Tests ───────────────────────────────────────
|
|
1001
|
-
|
|
1002
|
-
describe('AutomationEngine - DAG Cycle Detection', () => {
|
|
1003
|
-
let engine: AutomationEngine;
|
|
1004
|
-
|
|
1005
|
-
beforeEach(() => {
|
|
1006
|
-
engine = new AutomationEngine(createTestLogger());
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
it('should reject flows with cycles', () => {
|
|
1010
|
-
expect(() => engine.registerFlow('cyclic_flow', {
|
|
1011
|
-
name: 'cyclic_flow',
|
|
1012
|
-
label: 'Cyclic Flow',
|
|
1013
|
-
type: 'autolaunched',
|
|
1014
|
-
nodes: [
|
|
1015
|
-
{ id: 'a', type: 'start', label: 'A' },
|
|
1016
|
-
{ id: 'b', type: 'assignment', label: 'B' },
|
|
1017
|
-
{ id: 'c', type: 'assignment', label: 'C' },
|
|
1018
|
-
],
|
|
1019
|
-
edges: [
|
|
1020
|
-
{ id: 'e1', source: 'a', target: 'b' },
|
|
1021
|
-
{ id: 'e2', source: 'b', target: 'c' },
|
|
1022
|
-
{ id: 'e3', source: 'c', target: 'b' }, // cycle: b → c → b
|
|
1023
|
-
],
|
|
1024
|
-
})).toThrow(/cycle/i);
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
it('should accept valid DAG flows', () => {
|
|
1028
|
-
expect(() => engine.registerFlow('valid_dag', {
|
|
1029
|
-
name: 'valid_dag',
|
|
1030
|
-
label: 'Valid DAG',
|
|
1031
|
-
type: 'autolaunched',
|
|
1032
|
-
nodes: [
|
|
1033
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1034
|
-
{ id: 'a', type: 'assignment', label: 'A' },
|
|
1035
|
-
{ id: 'b', type: 'assignment', label: 'B' },
|
|
1036
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1037
|
-
],
|
|
1038
|
-
edges: [
|
|
1039
|
-
{ id: 'e1', source: 'start', target: 'a' },
|
|
1040
|
-
{ id: 'e2', source: 'start', target: 'b' },
|
|
1041
|
-
{ id: 'e3', source: 'a', target: 'end' },
|
|
1042
|
-
{ id: 'e4', source: 'b', target: 'end' },
|
|
1043
|
-
],
|
|
1044
|
-
})).not.toThrow();
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
it('should provide cycle details in error message', () => {
|
|
1048
|
-
try {
|
|
1049
|
-
engine.registerFlow('detailed_cycle', {
|
|
1050
|
-
name: 'detailed_cycle',
|
|
1051
|
-
label: 'Detailed Cycle',
|
|
1052
|
-
type: 'autolaunched',
|
|
1053
|
-
nodes: [
|
|
1054
|
-
{ id: 'x', type: 'start', label: 'X' },
|
|
1055
|
-
{ id: 'y', type: 'assignment', label: 'Y' },
|
|
1056
|
-
{ id: 'z', type: 'assignment', label: 'Z' },
|
|
1057
|
-
],
|
|
1058
|
-
edges: [
|
|
1059
|
-
{ id: 'e1', source: 'x', target: 'y' },
|
|
1060
|
-
{ id: 'e2', source: 'y', target: 'z' },
|
|
1061
|
-
{ id: 'e3', source: 'z', target: 'y' },
|
|
1062
|
-
],
|
|
1063
|
-
});
|
|
1064
|
-
expect.fail('Should have thrown');
|
|
1065
|
-
} catch (err: any) {
|
|
1066
|
-
expect(err.message).toContain('→');
|
|
1067
|
-
expect(err.message).toContain('DAG');
|
|
1068
|
-
}
|
|
1069
|
-
});
|
|
1070
|
-
});
|
|
1071
|
-
|
|
1072
|
-
// ─── Node Timeout Tests ──────────────────────────────────────────────
|
|
1073
|
-
|
|
1074
|
-
describe('AutomationEngine - Node Timeout', () => {
|
|
1075
|
-
let engine: AutomationEngine;
|
|
1076
|
-
|
|
1077
|
-
beforeEach(() => {
|
|
1078
|
-
engine = new AutomationEngine(createTestLogger());
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
it('should timeout a slow node', async () => {
|
|
1082
|
-
engine.registerNodeExecutor({
|
|
1083
|
-
type: 'script',
|
|
1084
|
-
async execute() {
|
|
1085
|
-
await new Promise(r => setTimeout(r, 5000)); // 5 seconds
|
|
1086
|
-
return { success: true };
|
|
1087
|
-
},
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
engine.registerFlow('timeout_flow', {
|
|
1091
|
-
name: 'timeout_flow',
|
|
1092
|
-
label: 'Timeout Flow',
|
|
1093
|
-
type: 'autolaunched',
|
|
1094
|
-
nodes: [
|
|
1095
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1096
|
-
{ id: 'slow', type: 'script', label: 'Slow', timeoutMs: 50 },
|
|
1097
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1098
|
-
],
|
|
1099
|
-
edges: [
|
|
1100
|
-
{ id: 'e1', source: 'start', target: 'slow' },
|
|
1101
|
-
{ id: 'e2', source: 'slow', target: 'end' },
|
|
1102
|
-
],
|
|
1103
|
-
});
|
|
1104
|
-
|
|
1105
|
-
const result = await engine.execute('timeout_flow');
|
|
1106
|
-
expect(result.success).toBe(false);
|
|
1107
|
-
expect(result.error).toContain('timed out');
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
it('should succeed when node completes within timeout', async () => {
|
|
1111
|
-
engine.registerNodeExecutor({
|
|
1112
|
-
type: 'script',
|
|
1113
|
-
async execute() {
|
|
1114
|
-
return { success: true };
|
|
1115
|
-
},
|
|
1116
|
-
});
|
|
1117
|
-
|
|
1118
|
-
engine.registerFlow('fast_flow', {
|
|
1119
|
-
name: 'fast_flow',
|
|
1120
|
-
label: 'Fast Flow',
|
|
1121
|
-
type: 'autolaunched',
|
|
1122
|
-
nodes: [
|
|
1123
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1124
|
-
{ id: 'fast', type: 'script', label: 'Fast', timeoutMs: 5000 },
|
|
1125
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1126
|
-
],
|
|
1127
|
-
edges: [
|
|
1128
|
-
{ id: 'e1', source: 'start', target: 'fast' },
|
|
1129
|
-
{ id: 'e2', source: 'fast', target: 'end' },
|
|
1130
|
-
],
|
|
1131
|
-
});
|
|
1132
|
-
|
|
1133
|
-
const result = await engine.execute('fast_flow');
|
|
1134
|
-
expect(result.success).toBe(true);
|
|
1135
|
-
});
|
|
1136
|
-
});
|
|
1137
|
-
|
|
1138
|
-
// ─── Safe Expression Evaluation Tests ────────────────────────────────
|
|
1139
|
-
|
|
1140
|
-
describe('AutomationEngine - Safe Expression Evaluation', () => {
|
|
1141
|
-
let engine: AutomationEngine;
|
|
1142
|
-
|
|
1143
|
-
beforeEach(() => {
|
|
1144
|
-
engine = new AutomationEngine(createTestLogger());
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
it('should evaluate simple comparisons', () => {
|
|
1148
|
-
const vars = new Map<string, unknown>();
|
|
1149
|
-
vars.set('amount', 500);
|
|
1150
|
-
|
|
1151
|
-
expect(engine.evaluateCondition('{amount} > 100', vars)).toBe(true);
|
|
1152
|
-
expect(engine.evaluateCondition('{amount} < 100', vars)).toBe(false);
|
|
1153
|
-
expect(engine.evaluateCondition('{amount} == 500', vars)).toBe(true);
|
|
1154
|
-
expect(engine.evaluateCondition('{amount} >= 500', vars)).toBe(true);
|
|
1155
|
-
expect(engine.evaluateCondition('{amount} <= 500', vars)).toBe(true);
|
|
1156
|
-
expect(engine.evaluateCondition('{amount} != 100', vars)).toBe(true);
|
|
1157
|
-
});
|
|
1158
|
-
|
|
1159
|
-
it('should evaluate boolean literals', () => {
|
|
1160
|
-
const vars = new Map<string, unknown>();
|
|
1161
|
-
expect(engine.evaluateCondition('true', vars)).toBe(true);
|
|
1162
|
-
expect(engine.evaluateCondition('false', vars)).toBe(false);
|
|
1163
|
-
});
|
|
1164
|
-
|
|
1165
|
-
it('should not execute malicious code', () => {
|
|
1166
|
-
const vars = new Map<string, unknown>();
|
|
1167
|
-
// These should all return false safely
|
|
1168
|
-
expect(engine.evaluateCondition('process.exit(1)', vars)).toBe(false);
|
|
1169
|
-
expect(engine.evaluateCondition('require("fs").readFileSync("/etc/passwd")', vars)).toBe(false);
|
|
1170
|
-
expect(engine.evaluateCondition('(() => { while(true) {} })()', vars)).toBe(false);
|
|
1171
|
-
});
|
|
1172
|
-
|
|
1173
|
-
it('should handle string comparisons', () => {
|
|
1174
|
-
const vars = new Map<string, unknown>();
|
|
1175
|
-
vars.set('status', 'active');
|
|
1176
|
-
|
|
1177
|
-
expect(engine.evaluateCondition('{status} == active', vars)).toBe(true);
|
|
1178
|
-
expect(engine.evaluateCondition('{status} != inactive', vars)).toBe(true);
|
|
1179
|
-
});
|
|
1180
|
-
});
|
|
1181
|
-
|
|
1182
|
-
// ─── Parallel Branch Execution Tests ─────────────────────────────────
|
|
1183
|
-
|
|
1184
|
-
describe('AutomationEngine - Parallel Branch Execution', () => {
|
|
1185
|
-
let engine: AutomationEngine;
|
|
1186
|
-
|
|
1187
|
-
beforeEach(() => {
|
|
1188
|
-
engine = new AutomationEngine(createTestLogger());
|
|
1189
|
-
});
|
|
1190
|
-
|
|
1191
|
-
it('should execute unconditional branches in parallel', async () => {
|
|
1192
|
-
const executionOrder: string[] = [];
|
|
1193
|
-
|
|
1194
|
-
engine.registerNodeExecutor({
|
|
1195
|
-
type: 'script',
|
|
1196
|
-
async execute(node) {
|
|
1197
|
-
const delay = (node.config as any)?.delay ?? 0;
|
|
1198
|
-
await new Promise(r => setTimeout(r, delay));
|
|
1199
|
-
executionOrder.push(node.id);
|
|
1200
|
-
return { success: true };
|
|
1201
|
-
},
|
|
1202
|
-
});
|
|
1203
|
-
|
|
1204
|
-
engine.registerFlow('parallel_flow', {
|
|
1205
|
-
name: 'parallel_flow',
|
|
1206
|
-
label: 'Parallel Flow',
|
|
1207
|
-
type: 'autolaunched',
|
|
1208
|
-
nodes: [
|
|
1209
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1210
|
-
{ id: 'branch_a', type: 'script', label: 'Branch A', config: { delay: 10 } },
|
|
1211
|
-
{ id: 'branch_b', type: 'script', label: 'Branch B', config: { delay: 10 } },
|
|
1212
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1213
|
-
],
|
|
1214
|
-
edges: [
|
|
1215
|
-
{ id: 'e1', source: 'start', target: 'branch_a' },
|
|
1216
|
-
{ id: 'e2', source: 'start', target: 'branch_b' },
|
|
1217
|
-
{ id: 'e3', source: 'branch_a', target: 'end' },
|
|
1218
|
-
{ id: 'e4', source: 'branch_b', target: 'end' },
|
|
1219
|
-
],
|
|
1220
|
-
});
|
|
1221
|
-
|
|
1222
|
-
const start = Date.now();
|
|
1223
|
-
const result = await engine.execute('parallel_flow');
|
|
1224
|
-
const elapsed = Date.now() - start;
|
|
1225
|
-
|
|
1226
|
-
expect(result.success).toBe(true);
|
|
1227
|
-
// Both branches should execute (order may vary in parallel)
|
|
1228
|
-
expect(executionOrder).toContain('branch_a');
|
|
1229
|
-
expect(executionOrder).toContain('branch_b');
|
|
1230
|
-
// Parallel execution should be faster than sequential (10+10=20ms)
|
|
1231
|
-
// Allow generous margin but expect it's faster than fully sequential
|
|
1232
|
-
expect(elapsed).toBeLessThan(100); // generous but parallel should be ~15ms
|
|
1233
|
-
});
|
|
1234
|
-
});
|
|
1235
|
-
|
|
1236
|
-
// ─── Input Schema Validation Tests ───────────────────────────────────
|
|
1237
|
-
|
|
1238
|
-
describe('AutomationEngine - Node Input Schema Validation', () => {
|
|
1239
|
-
let engine: AutomationEngine;
|
|
1240
|
-
|
|
1241
|
-
beforeEach(() => {
|
|
1242
|
-
engine = new AutomationEngine(createTestLogger());
|
|
1243
|
-
});
|
|
1244
|
-
|
|
1245
|
-
it('should fail when required input parameter is missing', async () => {
|
|
1246
|
-
engine.registerNodeExecutor({
|
|
1247
|
-
type: 'script',
|
|
1248
|
-
async execute() {
|
|
1249
|
-
return { success: true };
|
|
1250
|
-
},
|
|
1251
|
-
});
|
|
1252
|
-
|
|
1253
|
-
engine.registerFlow('schema_fail', {
|
|
1254
|
-
name: 'schema_fail',
|
|
1255
|
-
label: 'Schema Fail',
|
|
1256
|
-
type: 'autolaunched',
|
|
1257
|
-
nodes: [
|
|
1258
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1259
|
-
{
|
|
1260
|
-
id: 'validated',
|
|
1261
|
-
type: 'script',
|
|
1262
|
-
label: 'Validated',
|
|
1263
|
-
config: {},
|
|
1264
|
-
inputSchema: {
|
|
1265
|
-
url: { type: 'string', required: true, description: 'URL to call' },
|
|
1266
|
-
},
|
|
1267
|
-
},
|
|
1268
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1269
|
-
],
|
|
1270
|
-
edges: [
|
|
1271
|
-
{ id: 'e1', source: 'start', target: 'validated' },
|
|
1272
|
-
{ id: 'e2', source: 'validated', target: 'end' },
|
|
1273
|
-
],
|
|
1274
|
-
});
|
|
1275
|
-
|
|
1276
|
-
const result = await engine.execute('schema_fail');
|
|
1277
|
-
expect(result.success).toBe(false);
|
|
1278
|
-
expect(result.error).toContain('missing required');
|
|
1279
|
-
});
|
|
1280
|
-
|
|
1281
|
-
it('should fail when parameter type is wrong', async () => {
|
|
1282
|
-
engine.registerNodeExecutor({
|
|
1283
|
-
type: 'script',
|
|
1284
|
-
async execute() {
|
|
1285
|
-
return { success: true };
|
|
1286
|
-
},
|
|
1287
|
-
});
|
|
1288
|
-
|
|
1289
|
-
engine.registerFlow('type_fail', {
|
|
1290
|
-
name: 'type_fail',
|
|
1291
|
-
label: 'Type Fail',
|
|
1292
|
-
type: 'autolaunched',
|
|
1293
|
-
nodes: [
|
|
1294
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1295
|
-
{
|
|
1296
|
-
id: 'validated',
|
|
1297
|
-
type: 'script',
|
|
1298
|
-
label: 'Validated',
|
|
1299
|
-
config: { count: 'not_a_number' },
|
|
1300
|
-
inputSchema: {
|
|
1301
|
-
count: { type: 'number', required: true },
|
|
1302
|
-
},
|
|
1303
|
-
},
|
|
1304
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1305
|
-
],
|
|
1306
|
-
edges: [
|
|
1307
|
-
{ id: 'e1', source: 'start', target: 'validated' },
|
|
1308
|
-
{ id: 'e2', source: 'validated', target: 'end' },
|
|
1309
|
-
],
|
|
1310
|
-
});
|
|
1311
|
-
|
|
1312
|
-
const result = await engine.execute('type_fail');
|
|
1313
|
-
expect(result.success).toBe(false);
|
|
1314
|
-
expect(result.error).toContain('expected type');
|
|
1315
|
-
});
|
|
1316
|
-
});
|
|
1317
|
-
|
|
1318
|
-
// ─── Flow Version Management Tests ───────────────────────────────────
|
|
1319
|
-
|
|
1320
|
-
describe('AutomationEngine - Flow Version Management', () => {
|
|
1321
|
-
let engine: AutomationEngine;
|
|
1322
|
-
|
|
1323
|
-
const makeFlow = (version: number, label: string) => ({
|
|
1324
|
-
name: 'versioned_flow',
|
|
1325
|
-
label,
|
|
1326
|
-
type: 'autolaunched' as const,
|
|
1327
|
-
version,
|
|
1328
|
-
nodes: [
|
|
1329
|
-
{ id: 'start', type: 'start' as const, label: 'Start' },
|
|
1330
|
-
{ id: 'end', type: 'end' as const, label: 'End' },
|
|
1331
|
-
],
|
|
1332
|
-
edges: [{ id: 'e1', source: 'start', target: 'end' }],
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
beforeEach(() => {
|
|
1336
|
-
engine = new AutomationEngine(createTestLogger());
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
it('should keep version history on registerFlow', () => {
|
|
1340
|
-
engine.registerFlow('versioned_flow', makeFlow(1, 'V1'));
|
|
1341
|
-
engine.registerFlow('versioned_flow', makeFlow(2, 'V2'));
|
|
1342
|
-
engine.registerFlow('versioned_flow', makeFlow(3, 'V3'));
|
|
1343
|
-
|
|
1344
|
-
const history = engine.getFlowVersionHistory('versioned_flow');
|
|
1345
|
-
expect(history).toHaveLength(3);
|
|
1346
|
-
expect(history[0].version).toBe(1);
|
|
1347
|
-
expect(history[2].version).toBe(3);
|
|
1348
|
-
});
|
|
1349
|
-
|
|
1350
|
-
it('should rollback to a previous version', async () => {
|
|
1351
|
-
engine.registerFlow('versioned_flow', makeFlow(1, 'V1'));
|
|
1352
|
-
engine.registerFlow('versioned_flow', makeFlow(2, 'V2'));
|
|
1353
|
-
|
|
1354
|
-
const current = await engine.getFlow('versioned_flow');
|
|
1355
|
-
expect(current!.label).toBe('V2');
|
|
1356
|
-
|
|
1357
|
-
engine.rollbackFlow('versioned_flow', 1);
|
|
1358
|
-
const rolledBack = await engine.getFlow('versioned_flow');
|
|
1359
|
-
expect(rolledBack!.label).toBe('V1');
|
|
1360
|
-
});
|
|
1361
|
-
|
|
1362
|
-
it('should throw when rolling back to non-existent version', () => {
|
|
1363
|
-
engine.registerFlow('versioned_flow', makeFlow(1, 'V1'));
|
|
1364
|
-
expect(() => engine.rollbackFlow('versioned_flow', 99)).toThrow('Version 99 not found');
|
|
1365
|
-
});
|
|
1366
|
-
|
|
1367
|
-
it('should throw when rolling back non-existent flow', () => {
|
|
1368
|
-
expect(() => engine.rollbackFlow('nonexistent', 1)).toThrow('no version history');
|
|
1369
|
-
});
|
|
1370
|
-
|
|
1371
|
-
it('should clean up version history on unregister', () => {
|
|
1372
|
-
engine.registerFlow('versioned_flow', makeFlow(1, 'V1'));
|
|
1373
|
-
engine.unregisterFlow('versioned_flow');
|
|
1374
|
-
const history = engine.getFlowVersionHistory('versioned_flow');
|
|
1375
|
-
expect(history).toHaveLength(0);
|
|
1376
|
-
});
|
|
1377
|
-
});
|
|
1378
|
-
|
|
1379
|
-
// ─── Execution Status Expansion Tests ────────────────────────────────
|
|
1380
|
-
|
|
1381
|
-
describe('AutomationEngine - Execution Status', () => {
|
|
1382
|
-
let engine: AutomationEngine;
|
|
1383
|
-
|
|
1384
|
-
beforeEach(() => {
|
|
1385
|
-
engine = new AutomationEngine(createTestLogger());
|
|
1386
|
-
});
|
|
1387
|
-
|
|
1388
|
-
it('should record completed status for successful execution', async () => {
|
|
1389
|
-
engine.registerFlow('status_flow', {
|
|
1390
|
-
name: 'status_flow',
|
|
1391
|
-
label: 'Status Flow',
|
|
1392
|
-
type: 'autolaunched',
|
|
1393
|
-
nodes: [
|
|
1394
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1395
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1396
|
-
],
|
|
1397
|
-
edges: [{ id: 'e1', source: 'start', target: 'end' }],
|
|
1398
|
-
});
|
|
1399
|
-
|
|
1400
|
-
await engine.execute('status_flow');
|
|
1401
|
-
const runs = await engine.listRuns('status_flow');
|
|
1402
|
-
expect(runs[0].status).toBe('completed');
|
|
1403
|
-
});
|
|
1404
|
-
|
|
1405
|
-
it('should record failed status for failed execution', async () => {
|
|
1406
|
-
engine.registerFlow('fail_status', {
|
|
1407
|
-
name: 'fail_status',
|
|
1408
|
-
label: 'Fail Status',
|
|
1409
|
-
type: 'autolaunched',
|
|
1410
|
-
nodes: [
|
|
1411
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
1412
|
-
{ id: 'bad', type: 'script', label: 'Bad' },
|
|
1413
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
1414
|
-
],
|
|
1415
|
-
edges: [
|
|
1416
|
-
{ id: 'e1', source: 'start', target: 'bad' },
|
|
1417
|
-
{ id: 'e2', source: 'bad', target: 'end' },
|
|
1418
|
-
],
|
|
1419
|
-
});
|
|
1420
|
-
|
|
1421
|
-
await engine.execute('fail_status');
|
|
1422
|
-
const runs = await engine.listRuns('fail_status');
|
|
1423
|
-
expect(runs[0].status).toBe('failed');
|
|
1424
|
-
});
|
|
1425
|
-
});
|