@allternit/workflow-engine 0.1.0 → 0.1.1

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.
@@ -1,852 +0,0 @@
1
- /**
2
- * Workflow Engine Tests
3
- *
4
- * Comprehensive tests for the WorkflowEngine class covering graph validation,
5
- * node execution, parallel execution, variable resolution, retry logic,
6
- * HTTP requests, and event system.
7
- */
8
-
9
- import { describe, it, expect, beforeEach, vi } from 'vitest';
10
- import { createWorkflowEngine, globalWorkflowEngine } from './workflow-engine';
11
- import type { Workflow, WorkflowNode, Connection, NodeType } from '../types';
12
-
13
- // Mock fetch for HTTP request tests
14
- global.fetch = vi.fn();
15
-
16
- describe('WorkflowEngine', () => {
17
- let engine: ReturnType<typeof createWorkflowEngine>;
18
-
19
- beforeEach(() => {
20
- engine = createWorkflowEngine();
21
- vi.clearAllMocks();
22
- });
23
-
24
- describe('workflow management', () => {
25
- it('should register and retrieve a workflow', () => {
26
- const workflow: Workflow = {
27
- id: 'wf-1',
28
- name: 'Test Workflow',
29
- version: '1.0.0',
30
- nodes: [],
31
- connections: [],
32
- };
33
-
34
- engine.registerWorkflow(workflow);
35
- const retrieved = engine.getWorkflow('wf-1');
36
-
37
- expect(retrieved).toEqual(workflow);
38
- });
39
-
40
- it('should list all registered workflows', () => {
41
- const wf1: Workflow = {
42
- id: 'wf-1',
43
- name: 'Workflow 1',
44
- version: '1.0.0',
45
- nodes: [],
46
- connections: [],
47
- };
48
- const wf2: Workflow = {
49
- id: 'wf-2',
50
- name: 'Workflow 2',
51
- version: '1.0.0',
52
- nodes: [],
53
- connections: [],
54
- };
55
-
56
- engine.registerWorkflow(wf1);
57
- engine.registerWorkflow(wf2);
58
- const list = engine.listWorkflows();
59
-
60
- expect(list).toHaveLength(2);
61
- expect(list.map(w => w.id)).toContain('wf-1');
62
- expect(list.map(w => w.id)).toContain('wf-2');
63
- });
64
-
65
- it('should delete a workflow', () => {
66
- const workflow: Workflow = {
67
- id: 'wf-1',
68
- name: 'Test Workflow',
69
- version: '1.0.0',
70
- nodes: [],
71
- connections: [],
72
- };
73
-
74
- engine.registerWorkflow(workflow);
75
- expect(engine.getWorkflow('wf-1')).toBeDefined();
76
-
77
- const deleted = engine.deleteWorkflow('wf-1');
78
- expect(deleted).toBe(true);
79
- expect(engine.getWorkflow('wf-1')).toBeUndefined();
80
- });
81
-
82
- it('should return undefined for non-existent workflow', () => {
83
- expect(engine.getWorkflow('non-existent')).toBeUndefined();
84
- });
85
- });
86
-
87
- describe('node execution', () => {
88
- it('should execute a simple linear workflow', async () => {
89
- const nodes: WorkflowNode[] = [
90
- { id: 'node-1', type: 'trigger:manual', name: 'Trigger' },
91
- { id: 'node-2', type: 'transform:map', name: 'Transform', config: { expression: 'data.value * 2' } },
92
- { id: 'node-3', type: 'output:result', name: 'Output' },
93
- ];
94
- const connections: Connection[] = [
95
- { id: 'conn-1', source: 'node-1', target: 'node-2' },
96
- { id: 'conn-2', source: 'node-2', target: 'node-3' },
97
- ];
98
- const workflow: Workflow = {
99
- id: 'wf-linear',
100
- name: 'Linear Workflow',
101
- version: '1.0.0',
102
- nodes,
103
- connections,
104
- };
105
-
106
- engine.registerWorkflow(workflow);
107
- const execution = await engine.execute('wf-linear', { value: 5 });
108
-
109
- expect(execution.status).toBe('completed');
110
- expect(execution.context?.state.completedNodes).toContain('node-1');
111
- expect(execution.context?.state.completedNodes).toContain('node-2');
112
- expect(execution.context?.state.completedNodes).toContain('node-3');
113
- });
114
-
115
- it('should execute nodes in topological order', async () => {
116
- const executionOrder: string[] = [];
117
-
118
- const customNodeType: NodeType = {
119
- type: 'custom:tracker',
120
- category: 'custom',
121
- displayName: 'Tracker',
122
- executor: async (node) => {
123
- executionOrder.push(node.id);
124
- return { tracked: true };
125
- },
126
- };
127
-
128
- engine.registerNodeType(customNodeType);
129
-
130
- // Create a diamond-shaped workflow
131
- // A
132
- // / \
133
- // B C
134
- // \ /
135
- // D
136
- const nodes: WorkflowNode[] = [
137
- { id: 'A', type: 'custom:tracker', name: 'Node A' },
138
- { id: 'B', type: 'custom:tracker', name: 'Node B' },
139
- { id: 'C', type: 'custom:tracker', name: 'Node C' },
140
- { id: 'D', type: 'custom:tracker', name: 'Node D' },
141
- ];
142
- const connections: Connection[] = [
143
- { id: 'c1', source: 'A', target: 'B' },
144
- { id: 'c2', source: 'A', target: 'C' },
145
- { id: 'c3', source: 'B', target: 'D' },
146
- { id: 'c4', source: 'C', target: 'D' },
147
- ];
148
- const workflow: Workflow = {
149
- id: 'wf-diamond',
150
- name: 'Diamond Workflow',
151
- version: '1.0.0',
152
- nodes,
153
- connections,
154
- };
155
-
156
- engine.registerWorkflow(workflow);
157
- await engine.execute('wf-diamond');
158
-
159
- // A must come before B and C
160
- expect(executionOrder.indexOf('A')).toBeLessThan(executionOrder.indexOf('B'));
161
- expect(executionOrder.indexOf('A')).toBeLessThan(executionOrder.indexOf('C'));
162
- // B and C must come before D
163
- expect(executionOrder.indexOf('B')).toBeLessThan(executionOrder.indexOf('D'));
164
- expect(executionOrder.indexOf('C')).toBeLessThan(executionOrder.indexOf('D'));
165
- });
166
-
167
- it('should handle unknown node types', async () => {
168
- const nodes: WorkflowNode[] = [
169
- { id: 'node-1', type: 'trigger:manual', name: 'Trigger' },
170
- { id: 'node-2', type: 'unknown:type', name: 'Unknown' },
171
- ];
172
- const connections: Connection[] = [
173
- { id: 'conn-1', source: 'node-1', target: 'node-2' },
174
- ];
175
- const workflow: Workflow = {
176
- id: 'wf-unknown',
177
- name: 'Unknown Type Workflow',
178
- version: '1.0.0',
179
- nodes,
180
- connections,
181
- };
182
-
183
- engine.registerWorkflow(workflow);
184
-
185
- // Execution fails when trying to execute the unknown node type
186
- const execution = await engine.execute('wf-unknown');
187
- expect(execution.status).toBe('failed');
188
- expect(execution.error?.message).toContain('Unknown node type');
189
- });
190
- });
191
-
192
- describe('parallel execution', () => {
193
- it('should respect maxConcurrentExecutions limit', async () => {
194
- const limitedEngine = createWorkflowEngine({ maxConcurrentExecutions: 1 });
195
-
196
- const slowNodeType: NodeType = {
197
- type: 'custom:slow',
198
- category: 'custom',
199
- displayName: 'Slow Node',
200
- executor: async () => {
201
- await new Promise(resolve => setTimeout(resolve, 100));
202
- return { done: true };
203
- },
204
- };
205
- limitedEngine.registerNodeType(slowNodeType);
206
-
207
- const nodes: WorkflowNode[] = [
208
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
209
- ];
210
- const workflow: Workflow = {
211
- id: 'wf-limit',
212
- name: 'Limit Test',
213
- version: '1.0.0',
214
- nodes,
215
- connections: [],
216
- };
217
-
218
- limitedEngine.registerWorkflow(workflow);
219
-
220
- // First execution should succeed
221
- const exec1 = limitedEngine.execute('wf-limit');
222
-
223
- // Second execution should fail due to limit
224
- await expect(limitedEngine.execute('wf-limit')).rejects.toThrow('Max concurrent executions reached');
225
-
226
- // Wait for first to complete
227
- await exec1;
228
- });
229
- });
230
-
231
- describe('variable resolution', () => {
232
- it('should resolve variables with ${} interpolation', async () => {
233
- const customNodeType: NodeType = {
234
- type: 'custom:input-test',
235
- category: 'custom',
236
- displayName: 'Input Test',
237
- inputs: [{ name: 'data', type: 'any' }],
238
- executor: async (node, context, inputs) => {
239
- return { received: inputs.data };
240
- },
241
- };
242
- engine.registerNodeType(customNodeType);
243
-
244
- const nodes: WorkflowNode[] = [
245
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
246
- {
247
- id: 'test',
248
- type: 'custom:input-test',
249
- name: 'Test Node',
250
- inputs: [{ target: 'data', source: '${user.name}' }],
251
- },
252
- ];
253
- const connections: Connection[] = [
254
- { id: 'c1', source: 'trigger', target: 'test' },
255
- ];
256
- const workflow: Workflow = {
257
- id: 'wf-vars',
258
- name: 'Variable Test',
259
- version: '1.0.0',
260
- nodes,
261
- connections,
262
- };
263
-
264
- engine.registerWorkflow(workflow);
265
- const execution = await engine.execute('wf-vars', { user: { name: 'John' } });
266
-
267
- expect(execution.status).toBe('completed');
268
- expect(execution.context?.state.nodeResults['test']).toEqual({ received: 'John' });
269
- });
270
-
271
- it('should use default values for undefined variables', async () => {
272
- const customNodeType: NodeType = {
273
- type: 'custom:input-test',
274
- category: 'custom',
275
- displayName: 'Input Test',
276
- executor: async (node, context, inputs) => {
277
- return { received: inputs.data };
278
- },
279
- };
280
- engine.registerNodeType(customNodeType);
281
-
282
- const nodes: WorkflowNode[] = [
283
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
284
- {
285
- id: 'test',
286
- type: 'custom:input-test',
287
- name: 'Test Node',
288
- inputs: [{ target: 'data', source: '${missing.path}', default: 'default-value' }],
289
- },
290
- ];
291
- const connections: Connection[] = [
292
- { id: 'c1', source: 'trigger', target: 'test' },
293
- ];
294
- const workflow: Workflow = {
295
- id: 'wf-default',
296
- name: 'Default Test',
297
- version: '1.0.0',
298
- nodes,
299
- connections,
300
- };
301
-
302
- engine.registerWorkflow(workflow);
303
- const execution = await engine.execute('wf-default');
304
-
305
- expect(execution.status).toBe('completed');
306
- expect(execution.context?.state.nodeResults['test']).toEqual({ received: 'default-value' });
307
- });
308
-
309
- it('should handle nested path resolution', async () => {
310
- const customNodeType: NodeType = {
311
- type: 'custom:input-test',
312
- category: 'custom',
313
- displayName: 'Input Test',
314
- executor: async (node, context, inputs) => {
315
- return { received: inputs.data };
316
- },
317
- };
318
- engine.registerNodeType(customNodeType);
319
-
320
- const nodes: WorkflowNode[] = [
321
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
322
- {
323
- id: 'test',
324
- type: 'custom:input-test',
325
- name: 'Test Node',
326
- inputs: [{ target: 'data', source: '${deeply.nested.value}' }],
327
- },
328
- ];
329
- const connections: Connection[] = [
330
- { id: 'c1', source: 'trigger', target: 'test' },
331
- ];
332
- const workflow: Workflow = {
333
- id: 'wf-nested',
334
- name: 'Nested Test',
335
- version: '1.0.0',
336
- nodes,
337
- connections,
338
- };
339
-
340
- engine.registerWorkflow(workflow);
341
- const execution = await engine.execute('wf-nested', {
342
- deeply: { nested: { value: 'found' } }
343
- });
344
-
345
- expect(execution.status).toBe('completed');
346
- expect(execution.context?.state.nodeResults['test']).toEqual({ received: 'found' });
347
- });
348
- });
349
-
350
- describe('retry logic', () => {
351
- it('should retry failed nodes with retry configuration', async () => {
352
- let attempts = 0;
353
- const flakyNodeType: NodeType = {
354
- type: 'custom:flaky',
355
- category: 'custom',
356
- displayName: 'Flaky Node',
357
- executor: async () => {
358
- attempts++;
359
- if (attempts < 3) {
360
- throw new Error('Temporary failure');
361
- }
362
- return { success: true };
363
- },
364
- };
365
- engine.registerNodeType(flakyNodeType);
366
-
367
- const nodes: WorkflowNode[] = [
368
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
369
- { id: 'flaky', type: 'custom:flaky', name: 'Flaky Node' },
370
- ];
371
- const connections: Connection[] = [
372
- { id: 'c1', source: 'trigger', target: 'flaky' },
373
- ];
374
- const workflow: Workflow = {
375
- id: 'wf-retry',
376
- name: 'Retry Test',
377
- version: '1.0.0',
378
- nodes,
379
- connections,
380
- };
381
-
382
- engine.registerWorkflow(workflow);
383
-
384
- // Currently the engine doesn't have built-in retry,
385
- // but we can test that the error is captured
386
- const execution = await engine.execute('wf-retry');
387
-
388
- // The workflow fails on first error
389
- expect(execution.status).toBe('failed');
390
- expect(execution.error).toBeDefined();
391
- });
392
- });
393
-
394
- describe('HTTP request node', () => {
395
- it('should execute HTTP GET request', async () => {
396
- const mockResponse = {
397
- json: vi.fn().mockResolvedValue({ data: 'test' }),
398
- headers: new Map([['content-type', 'application/json']]),
399
- status: 200,
400
- };
401
- (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
402
-
403
- const httpNodeType = engine.getNodeType('http:request');
404
- expect(httpNodeType?.executor).toBeDefined();
405
-
406
- const mockNode: WorkflowNode = {
407
- id: 'test-http',
408
- type: 'http:request',
409
- name: 'HTTP Request',
410
- config: { method: 'GET', headers: {} },
411
- };
412
-
413
- const mockContext = {
414
- variables: {},
415
- state: {
416
- activeNodes: [],
417
- completedNodes: [],
418
- failedNodes: [],
419
- nodeResults: {},
420
- executionPath: [],
421
- },
422
- };
423
-
424
- const result = await httpNodeType?.executor!(mockNode, mockContext, { url: 'https://api.example.com/data' });
425
-
426
- expect(global.fetch).toHaveBeenCalled();
427
- const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
428
- expect(fetchCall[0]).toBe('https://api.example.com/data');
429
- expect(fetchCall[1]).toMatchObject({
430
- method: 'GET',
431
- headers: {},
432
- });
433
- expect(result).toHaveProperty('response');
434
- expect(result).toHaveProperty('status', 200);
435
- });
436
-
437
- it('should handle HTTP errors', async () => {
438
- (global.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'));
439
-
440
- const nodes: WorkflowNode[] = [
441
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
442
- {
443
- id: 'http',
444
- type: 'http:request',
445
- name: 'HTTP Request',
446
- },
447
- ];
448
- const connections: Connection[] = [
449
- { id: 'c1', source: 'trigger', target: 'http', sourcePort: 'url' },
450
- ];
451
- const workflow: Workflow = {
452
- id: 'wf-http-error',
453
- name: 'HTTP Error Test',
454
- version: '1.0.0',
455
- nodes,
456
- connections,
457
- };
458
-
459
- engine.registerWorkflow(workflow);
460
- const execution = await engine.execute('wf-http-error', { url: 'https://api.example.com/data' });
461
-
462
- expect(execution.status).toBe('failed');
463
- expect(execution.error?.code).toBe('EXECUTION_ERROR');
464
- });
465
-
466
- it('should respect HTTP timeout', async () => {
467
- const mockResponse = {
468
- json: vi.fn().mockResolvedValue({ data: 'test' }),
469
- headers: new Map(),
470
- status: 200,
471
- };
472
- (global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_, options) => {
473
- // Check that signal is passed
474
- expect(options?.signal).toBeDefined();
475
- return mockResponse;
476
- });
477
-
478
- const nodes: WorkflowNode[] = [
479
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
480
- {
481
- id: 'http',
482
- type: 'http:request',
483
- name: 'HTTP Request',
484
- config: { timeout: 5000 },
485
- },
486
- ];
487
- const connections: Connection[] = [
488
- { id: 'c1', source: 'trigger', target: 'http', sourcePort: 'url' },
489
- ];
490
- const workflow: Workflow = {
491
- id: 'wf-http-timeout',
492
- name: 'HTTP Timeout Test',
493
- version: '1.0.0',
494
- nodes,
495
- connections,
496
- };
497
-
498
- engine.registerWorkflow(workflow);
499
- await engine.execute('wf-http-timeout', { url: 'https://api.example.com/data' });
500
-
501
- expect(global.fetch).toHaveBeenCalled();
502
- });
503
- });
504
-
505
- describe('condition nodes', () => {
506
- it('should evaluate if condition and route accordingly', async () => {
507
- const nodes: WorkflowNode[] = [
508
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
509
- {
510
- id: 'condition',
511
- type: 'condition:if',
512
- name: 'Condition',
513
- config: { condition: 'data.value > 10' },
514
- },
515
- { id: 'true-branch', type: 'output:result', name: 'True Branch' },
516
- { id: 'false-branch', type: 'output:result', name: 'False Branch' },
517
- ];
518
- const connections: Connection[] = [
519
- { id: 'c1', source: 'trigger', target: 'condition' },
520
- { id: 'c2', source: 'condition', target: 'true-branch', condition: 'result.true' },
521
- { id: 'c3', source: 'condition', target: 'false-branch', condition: 'result.false' },
522
- ];
523
- const workflow: Workflow = {
524
- id: 'wf-condition',
525
- name: 'Condition Test',
526
- version: '1.0.0',
527
- nodes,
528
- connections,
529
- };
530
-
531
- engine.registerWorkflow(workflow);
532
-
533
- // Test true condition
534
- const execTrue = await engine.execute('wf-condition', { value: 15 });
535
- expect(execTrue.status).toBe('completed');
536
-
537
- // Test false condition
538
- const execFalse = await engine.execute('wf-condition', { value: 5 });
539
- expect(execFalse.status).toBe('completed');
540
- });
541
- });
542
-
543
- describe('transform nodes', () => {
544
- it('should have transform:map node type registered', () => {
545
- const nodeType = engine.getNodeType('transform:map');
546
- expect(nodeType).toBeDefined();
547
- expect(nodeType?.category).toBe('transform');
548
- expect(nodeType?.displayName).toBe('Map');
549
- });
550
-
551
- it('should have transform:filter node type registered', () => {
552
- const nodeType = engine.getNodeType('transform:filter');
553
- expect(nodeType).toBeDefined();
554
- expect(nodeType?.category).toBe('transform');
555
- expect(nodeType?.displayName).toBe('Filter');
556
- });
557
-
558
- it('should execute map transform executor directly', async () => {
559
- const mapNodeType = engine.getNodeType('transform:map');
560
- expect(mapNodeType?.executor).toBeDefined();
561
-
562
- const mockNode: WorkflowNode = {
563
- id: 'test-map',
564
- type: 'transform:map',
565
- name: 'Test Map',
566
- config: { expression: 'data.value * 2' },
567
- };
568
-
569
- const mockContext = {
570
- variables: {},
571
- state: {
572
- activeNodes: [],
573
- completedNodes: [],
574
- failedNodes: [],
575
- nodeResults: {},
576
- executionPath: [],
577
- },
578
- };
579
-
580
- const result = await mapNodeType?.executor!(mockNode, mockContext, { data: { value: 5 } });
581
- expect(result).toEqual({ result: 10 });
582
- });
583
-
584
- it('should execute filter transform executor directly', async () => {
585
- const filterNodeType = engine.getNodeType('transform:filter');
586
- expect(filterNodeType?.executor).toBeDefined();
587
-
588
- const mockNode: WorkflowNode = {
589
- id: 'test-filter',
590
- type: 'transform:filter',
591
- name: 'Test Filter',
592
- config: { condition: 'item > 5' },
593
- };
594
-
595
- const mockContext = {
596
- variables: {},
597
- state: {
598
- activeNodes: [],
599
- completedNodes: [],
600
- failedNodes: [],
601
- nodeResults: {},
602
- executionPath: [],
603
- },
604
- };
605
-
606
- const result = await filterNodeType?.executor!(mockNode, mockContext, { array: [3, 7, 1, 9, 2, 8] });
607
- expect(result).toEqual({ filtered: [7, 9, 8] });
608
- });
609
-
610
- it('should require expression for map transform', async () => {
611
- const mapNodeType = engine.getNodeType('transform:map');
612
-
613
- const mockNode: WorkflowNode = {
614
- id: 'test-map',
615
- type: 'transform:map',
616
- name: 'Test Map',
617
- config: {}, // No expression
618
- };
619
-
620
- const mockContext = {
621
- variables: {},
622
- state: {
623
- activeNodes: [],
624
- completedNodes: [],
625
- failedNodes: [],
626
- nodeResults: {},
627
- executionPath: [],
628
- },
629
- };
630
-
631
- await expect(mapNodeType?.executor!(mockNode, mockContext, { data: {} }))
632
- .rejects.toThrow('Expression required');
633
- });
634
- });
635
-
636
- describe('delay nodes', () => {
637
- it('should execute wait delay', async () => {
638
- const startTime = Date.now();
639
-
640
- const nodes: WorkflowNode[] = [
641
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
642
- {
643
- id: 'delay',
644
- type: 'delay:wait',
645
- name: 'Wait',
646
- config: { delay: 50 },
647
- },
648
- ];
649
- const connections: Connection[] = [
650
- { id: 'c1', source: 'trigger', target: 'delay' },
651
- ];
652
- const workflow: Workflow = {
653
- id: 'wf-delay',
654
- name: 'Delay Test',
655
- version: '1.0.0',
656
- nodes,
657
- connections,
658
- };
659
-
660
- engine.registerWorkflow(workflow);
661
- const execution = await engine.execute('wf-delay', { data: 'test' });
662
-
663
- const endTime = Date.now();
664
- expect(execution.status).toBe('completed');
665
- expect(endTime - startTime).toBeGreaterThanOrEqual(50);
666
- });
667
- });
668
-
669
- describe('execution lifecycle', () => {
670
- it('should cancel a running execution', async () => {
671
- const nodes: WorkflowNode[] = [
672
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
673
- ];
674
- const workflow: Workflow = {
675
- id: 'wf-cancel',
676
- name: 'Cancel Test',
677
- version: '1.0.0',
678
- nodes,
679
- connections: [],
680
- };
681
-
682
- engine.registerWorkflow(workflow);
683
- const execution = await engine.execute('wf-cancel');
684
-
685
- // Cancel after execution starts
686
- const cancelled = engine.cancelExecution(execution.id);
687
-
688
- // Since execution completes quickly, it might already be done
689
- // But the cancel function should handle it gracefully
690
- expect(typeof cancelled).toBe('boolean');
691
- });
692
-
693
- it('should pause and resume execution', async () => {
694
- const nodes: WorkflowNode[] = [
695
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
696
- ];
697
- const workflow: Workflow = {
698
- id: 'wf-pause',
699
- name: 'Pause Test',
700
- version: '1.0.0',
701
- nodes,
702
- connections: [],
703
- };
704
-
705
- engine.registerWorkflow(workflow);
706
- const execution = await engine.execute('wf-pause');
707
-
708
- // These operations return boolean success
709
- const paused = engine.pauseExecution(execution.id);
710
- expect(typeof paused).toBe('boolean');
711
-
712
- const resumed = engine.resumeExecution(execution.id);
713
- expect(typeof resumed).toBe('boolean');
714
- });
715
-
716
- it('should retrieve execution by ID', async () => {
717
- const nodes: WorkflowNode[] = [
718
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
719
- ];
720
- const workflow: Workflow = {
721
- id: 'wf-get',
722
- name: 'Get Test',
723
- version: '1.0.0',
724
- nodes,
725
- connections: [],
726
- };
727
-
728
- engine.registerWorkflow(workflow);
729
- const execution = await engine.execute('wf-get');
730
-
731
- const retrieved = engine.getExecution(execution.id);
732
- expect(retrieved).toBeDefined();
733
- expect(retrieved?.id).toBe(execution.id);
734
- });
735
- });
736
-
737
- describe('hooks', () => {
738
- it('should call lifecycle hooks', async () => {
739
- const beforeExecute = vi.fn();
740
- const afterExecute = vi.fn();
741
- const beforeNodeExecute = vi.fn();
742
- const afterNodeExecute = vi.fn();
743
-
744
- const hookedEngine = createWorkflowEngine({
745
- hooks: {
746
- beforeExecute,
747
- afterExecute,
748
- beforeNodeExecute,
749
- afterNodeExecute,
750
- },
751
- });
752
-
753
- const nodes: WorkflowNode[] = [
754
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
755
- ];
756
- const workflow: Workflow = {
757
- id: 'wf-hooks',
758
- name: 'Hooks Test',
759
- version: '1.0.0',
760
- nodes,
761
- connections: [],
762
- };
763
-
764
- hookedEngine.registerWorkflow(workflow);
765
- await hookedEngine.execute('wf-hooks');
766
-
767
- expect(beforeExecute).toHaveBeenCalled();
768
- expect(afterExecute).toHaveBeenCalled();
769
- expect(beforeNodeExecute).toHaveBeenCalled();
770
- expect(afterNodeExecute).toHaveBeenCalled();
771
- });
772
-
773
- it('should call onError hook on failure', async () => {
774
- const onError = vi.fn();
775
-
776
- const errorEngine = createWorkflowEngine({
777
- hooks: {
778
- onError,
779
- },
780
- });
781
-
782
- const failingNodeType: NodeType = {
783
- type: 'custom:failing',
784
- category: 'custom',
785
- displayName: 'Failing Node',
786
- executor: async () => {
787
- throw new Error('Intentional failure');
788
- },
789
- };
790
- errorEngine.registerNodeType(failingNodeType);
791
-
792
- const nodes: WorkflowNode[] = [
793
- { id: 'trigger', type: 'trigger:manual', name: 'Trigger' },
794
- { id: 'fail', type: 'custom:failing', name: 'Failing Node' },
795
- ];
796
- const connections: Connection[] = [
797
- { id: 'c1', source: 'trigger', target: 'fail' },
798
- ];
799
- const workflow: Workflow = {
800
- id: 'wf-error-hook',
801
- name: 'Error Hook Test',
802
- version: '1.0.0',
803
- nodes,
804
- connections,
805
- };
806
-
807
- errorEngine.registerWorkflow(workflow);
808
- await errorEngine.execute('wf-error-hook');
809
-
810
- expect(onError).toHaveBeenCalled();
811
- });
812
- });
813
-
814
- describe('node type registration', () => {
815
- it('should register custom node types', () => {
816
- const customType: NodeType = {
817
- type: 'custom:test',
818
- category: 'custom',
819
- displayName: 'Test Node',
820
- description: 'A test node',
821
- executor: async () => ({ test: true }),
822
- };
823
-
824
- engine.registerNodeType(customType);
825
- const retrieved = engine.getNodeType('custom:test');
826
-
827
- expect(retrieved).toEqual(customType);
828
- });
829
-
830
- it('should have built-in node types registered', () => {
831
- expect(engine.getNodeType('trigger:manual')).toBeDefined();
832
- expect(engine.getNodeType('trigger:schedule')).toBeDefined();
833
- expect(engine.getNodeType('trigger:webhook')).toBeDefined();
834
- expect(engine.getNodeType('transform:map')).toBeDefined();
835
- expect(engine.getNodeType('transform:filter')).toBeDefined();
836
- expect(engine.getNodeType('condition:if')).toBeDefined();
837
- expect(engine.getNodeType('loop:for-each')).toBeDefined();
838
- expect(engine.getNodeType('delay:wait')).toBeDefined();
839
- expect(engine.getNodeType('http:request')).toBeDefined();
840
- expect(engine.getNodeType('output:result')).toBeDefined();
841
- expect(engine.getNodeType('output:log')).toBeDefined();
842
- });
843
- });
844
-
845
- describe('global engine instance', () => {
846
- it('should have a global workflow engine instance', () => {
847
- expect(globalWorkflowEngine).toBeDefined();
848
- expect(typeof globalWorkflowEngine.registerWorkflow).toBe('function');
849
- expect(typeof globalWorkflowEngine.execute).toBe('function');
850
- });
851
- });
852
- });