@objectstack/service-automation 3.0.8 → 3.0.9
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 +10 -10
- package/CHANGELOG.md +8 -0
- package/dist/index.cjs +467 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +102 -2
- package/dist/index.d.ts +102 -2
- package/dist/index.js +467 -26
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/engine.test.ts +817 -0
- package/src/engine.ts +574 -24
- package/src/plugins/logic-nodes-plugin.ts +2 -13
package/src/engine.test.ts
CHANGED
|
@@ -606,3 +606,820 @@ describe('HttpConnectorPlugin', () => {
|
|
|
606
606
|
expect(types).toContain('connector_action');
|
|
607
607
|
});
|
|
608
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
|
+
});
|