import { mock } from 'vitest-mock-extended'; import { type INodeParameters, type IWorkflowBase } from '../src'; import { compareNodes, compareWorkflowsNodes, groupWorkflows, NodeDiffStatus, RULES, WorkflowChangeSet, type DiffableNode, type DiffRule, } from '../src/workflow-diff'; describe('NodeDiffStatus', () => { it('should have correct enum values', () => { expect(NodeDiffStatus.Eq).toBe('equal'); expect(NodeDiffStatus.Modified).toBe('modified'); expect(NodeDiffStatus.Added).toBe('added'); expect(NodeDiffStatus.Deleted).toBe('deleted'); }); }); describe('compareNodes', () => { const createTestNode = (overrides: Partial = {}): TestNode => ({ id: 'test-node-1', name: 'Test Node', type: 'test-type', typeVersion: 1, webhookId: 'webhook-123', credentials: { test: 'credential' }, parameters: { param1: 'value1' }, position: [100, 200], disabled: false, ...overrides, }); type TestNode = { id: string; name: string; type: string; typeVersion: number; webhookId: string; credentials: Record; parameters: INodeParameters; position: [number, number]; disabled: boolean; }; it('should return true for identical nodes', () => { const node1 = createTestNode(); const node2 = createTestNode(); const result = compareNodes(node1, node2); expect(result).toBe(true); }); it('should return false when nodes have different names', () => { const node1 = createTestNode({ name: 'Node 1' }); const node2 = createTestNode({ name: 'Node 2' }); const result = compareNodes(node1, node2); expect(result).toBe(false); }); it('should return false when nodes have different types', () => { const node1 = createTestNode({ type: 'type1' }); const node2 = createTestNode({ type: 'type2' }); const result = compareNodes(node1, node2); expect(result).toBe(false); }); it('should return false when nodes have different typeVersions', () => { const node1 = createTestNode({ typeVersion: 1 }); const node2 = createTestNode({ typeVersion: 2 }); const result = compareNodes(node1, node2); expect(result).toBe(false); }); it('should return false when nodes have different webhookIds', () => { const node1 = createTestNode({ webhookId: 'webhook1' }); const node2 = createTestNode({ webhookId: 'webhook2' }); const result = compareNodes(node1, node2); expect(result).toBe(false); }); it('should return false when nodes have different credentials', () => { const node1 = createTestNode({ credentials: { test: 'cred1' } }); const node2 = createTestNode({ credentials: { test: 'cred2' } }); const result = compareNodes(node1, node2); expect(result).toBe(false); }); it('should return false when nodes have different parameters', () => { const node1 = createTestNode({ parameters: { param1: 'value1' } }); const node2 = createTestNode({ parameters: { param1: 'value2' } }); const result = compareNodes(node1, node2); expect(result).toBe(false); }); it('should ignore properties not in comparison list', () => { const node1 = createTestNode({ position: [100, 200], disabled: false }); const node2 = createTestNode({ position: [300, 400], disabled: true }); const result = compareNodes(node1, node2); expect(result).toBe(true); }); it('should handle undefined base node', () => { const node2 = createTestNode(); const result = compareNodes(undefined, node2); expect(result).toBe(false); }); it('should handle undefined target node', () => { const node1 = createTestNode(); const result = compareNodes(node1, undefined); expect(result).toBe(false); }); it('should handle both nodes being undefined', () => { const result = compareNodes(undefined, undefined); expect(result).toBe(true); }); }); describe('compareWorkflowsNodes', () => { const createTestNode = (id: string, overrides: Partial = {}): TestNode => ({ id, name: `Node ${id}`, type: 'test-type', typeVersion: 1, webhookId: `webhook-${id}`, credentials: {}, parameters: {}, ...overrides, }); type TestNode = { id: string; name: string; type: string; typeVersion: number; webhookId: string; credentials: Record; parameters: INodeParameters; }; it('should detect equal nodes', () => { const baseNodes = [createTestNode('1'), createTestNode('2')]; const targetNodes = [createTestNode('1'), createTestNode('2')]; const diff = compareWorkflowsNodes(baseNodes, targetNodes); expect(diff.size).toBe(2); expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq); expect(diff.get('2')?.status).toBe(NodeDiffStatus.Eq); }); it('should detect modified nodes', () => { const baseNodes = [createTestNode('1', { name: 'Original Name' })]; const targetNodes = [createTestNode('1', { name: 'Modified Name' })]; const diff = compareWorkflowsNodes(baseNodes, targetNodes); expect(diff.size).toBe(1); expect(diff.get('1')?.status).toBe(NodeDiffStatus.Modified); expect(diff.get('1')?.node).toEqual(baseNodes[0]); }); it('should detect added nodes', () => { const baseNodes = [createTestNode('1')]; const targetNodes = [createTestNode('1'), createTestNode('2')]; const diff = compareWorkflowsNodes(baseNodes, targetNodes); expect(diff.size).toBe(2); expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq); expect(diff.get('2')?.status).toBe(NodeDiffStatus.Added); expect(diff.get('2')?.node).toEqual(targetNodes[1]); }); it('should detect deleted nodes', () => { const baseNodes = [createTestNode('1'), createTestNode('2')]; const targetNodes = [createTestNode('1')]; const diff = compareWorkflowsNodes(baseNodes, targetNodes); expect(diff.size).toBe(2); expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq); expect(diff.get('2')?.status).toBe(NodeDiffStatus.Deleted); expect(diff.get('2')?.node).toEqual(baseNodes[1]); }); it('should handle empty base array', () => { const baseNodes: TestNode[] = []; const targetNodes = [createTestNode('1'), createTestNode('2')]; const diff = compareWorkflowsNodes(baseNodes, targetNodes); expect(diff.size).toBe(2); expect(diff.get('1')?.status).toBe(NodeDiffStatus.Added); expect(diff.get('2')?.status).toBe(NodeDiffStatus.Added); }); it('should handle empty target array', () => { const baseNodes = [createTestNode('1'), createTestNode('2')]; const targetNodes: TestNode[] = []; const diff = compareWorkflowsNodes(baseNodes, targetNodes); expect(diff.size).toBe(2); expect(diff.get('1')?.status).toBe(NodeDiffStatus.Deleted); expect(diff.get('2')?.status).toBe(NodeDiffStatus.Deleted); }); it('should handle both arrays being empty', () => { const baseNodes: TestNode[] = []; const targetNodes: TestNode[] = []; const diff = compareWorkflowsNodes(baseNodes, targetNodes); expect(diff.size).toBe(0); }); it('should use custom comparison function when provided', () => { const baseNodes = [createTestNode('1', { name: 'Original' })]; const targetNodes = [createTestNode('1', { name: 'Modified' })]; const customCompare = vi.fn().mockReturnValue(true); const diff = compareWorkflowsNodes(baseNodes, targetNodes, customCompare); expect(customCompare).toHaveBeenCalledWith(baseNodes[0], targetNodes[0]); expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq); }); it('should handle complex workflow comparison', () => { const baseNodes = [ createTestNode('1', { name: 'Node 1' }), createTestNode('2', { name: 'Node 2' }), createTestNode('3', { name: 'Node 3' }), ]; const targetNodes = [ createTestNode('1', { name: 'Node 1' }), // Equal createTestNode('2', { name: 'Node 2 Modified' }), // Modified createTestNode('4', { name: 'Node 4' }), // Added ]; const diff = compareWorkflowsNodes(baseNodes, targetNodes); expect(diff.size).toBe(4); expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq); expect(diff.get('2')?.status).toBe(NodeDiffStatus.Modified); expect(diff.get('3')?.status).toBe(NodeDiffStatus.Deleted); expect(diff.get('4')?.status).toBe(NodeDiffStatus.Added); }); }); describe('groupWorkflows', () => { const node1 = mock({ id: '1', parameters: { a: 1 } }); const node2 = mock({ id: '2', parameters: { a: 2 } }); let rules: DiffRule[] = []; let workflows: IWorkflowBase[]; beforeEach(() => { rules = []; workflows = []; }); describe('basic grouping', () => { it('should group workflows with no changes', () => { workflows = mock<[IWorkflowBase, IWorkflowBase]>([ { id: '1', nodes: [node1, node2] }, { id: '1', nodes: [node1, node2] }, ]); const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(1); expect(grouped[0].from).toEqual(workflows[0]); expect(grouped[0].to).toEqual(workflows[1]); expect(grouped[0].workflowChangeSet.nodes.size).toBe(2); expect(grouped[0].workflowChangeSet.nodes.get(node1.id)?.status).toBe(NodeDiffStatus.Eq); expect(grouped[0].workflowChangeSet.nodes.get(node2.id)?.status).toBe(NodeDiffStatus.Eq); expect(grouped[0].groupedWorkflows).toEqual([]); }); it('should group workflows with added nodes', () => { workflows = mock<[IWorkflowBase, IWorkflowBase]>([ { id: '1', nodes: [node1] }, { id: '1', nodes: [node1, node2] }, ]); const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(1); expect(grouped[0].from).toEqual(workflows[0]); expect(grouped[0].to).toEqual(workflows[1]); expect(grouped[0].workflowChangeSet.nodes.size).toBe(2); expect(grouped[0].workflowChangeSet.nodes.get(node1.id)?.status).toBe(NodeDiffStatus.Eq); expect(grouped[0].workflowChangeSet.nodes.get(node2.id)?.status).toBe(NodeDiffStatus.Added); expect(grouped[0].groupedWorkflows).toEqual([]); }); it('should group workflows with deleted nodes', () => { workflows = mock<[IWorkflowBase, IWorkflowBase]>([ { id: '1', nodes: [node1, node2] }, { id: '1', nodes: [node1] }, ]); const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(1); expect(grouped[0].from).toEqual(workflows[0]); expect(grouped[0].to).toEqual(workflows[1]); expect(grouped[0].workflowChangeSet.nodes.size).toBe(2); expect(grouped[0].workflowChangeSet.nodes.get(node1.id)?.status).toBe(NodeDiffStatus.Eq); expect(grouped[0].workflowChangeSet.nodes.get(node2.id)?.status).toBe(NodeDiffStatus.Deleted); expect(grouped[0].groupedWorkflows).toEqual([]); }); it('should group workflows with modified nodes', () => { const modifiedNode2 = { id: '2', parameter: { a: 3 } }; workflows = mock<[IWorkflowBase, IWorkflowBase]>([ { id: '1', nodes: [node1, node2] }, { id: '1', nodes: [node1, modifiedNode2] }, ]); const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(1); expect(grouped[0].from).toEqual(workflows[0]); expect(grouped[0].to).toEqual(workflows[1]); expect(grouped[0].workflowChangeSet.nodes.size).toBe(2); expect(grouped[0].workflowChangeSet.nodes.get(node1.id)?.status).toBe(NodeDiffStatus.Eq); expect(grouped[0].workflowChangeSet.nodes.get(modifiedNode2.id)?.status).toBe( NodeDiffStatus.Modified, ); expect(grouped[0].groupedWorkflows).toEqual([]); }); it('should handle multiple workflow groups', () => { workflows = mock([ { id: '1', nodes: [node1] }, { id: '1', nodes: [node1, node2] }, { id: '1', nodes: [node1] }, ]); const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(2); expect(grouped[0].from).toEqual(workflows[0]); expect(grouped[0].to).toEqual(workflows[1]); expect(grouped[0].workflowChangeSet.nodes.size).toBe(2); expect(grouped[0].workflowChangeSet.nodes.get(node1.id)?.status).toBe(NodeDiffStatus.Eq); expect(grouped[0].workflowChangeSet.nodes.get(node2.id)?.status).toBe(NodeDiffStatus.Added); expect(grouped[1].from).toEqual(workflows[1]); expect(grouped[1].to).toEqual(workflows[2]); expect(grouped[1].workflowChangeSet.nodes.size).toBe(2); expect(grouped[1].workflowChangeSet.nodes.get(node1.id)?.status).toBe(NodeDiffStatus.Eq); expect(grouped[1].workflowChangeSet.nodes.get(node2.id)?.status).toBe(NodeDiffStatus.Deleted); }); it('should handle empty workflows array', () => { const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(0); }); it('should handle single workflow', () => { const workflows = mock([{ id: '1', nodes: [node1] }]); const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(1); expect(grouped[0].from).toEqual(workflows[0]); expect(grouped[0].to).toEqual(workflows[0]); expect(grouped[0].workflowChangeSet.nodes.size).toBe(0); expect(grouped[0].groupedWorkflows).toEqual([]); }); }); describe('rules', () => { it('should not apply an inapplicable rule', () => { workflows = mock([ { id: '1', nodes: [node1] }, { id: '1', nodes: [node1, node2] }, { id: '1', nodes: [node1] }, ]); const alwaysMergeRule: DiffRule = (_l, _r) => false; const rules = [alwaysMergeRule]; const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(2); expect(grouped[0].from).toEqual(workflows[0]); expect(grouped[0].to).toEqual(workflows[1]); expect(grouped[0].groupedWorkflows).toEqual([]); expect(grouped[1].from).toEqual(workflows[1]); expect(grouped[1].to).toEqual(workflows[2]); expect(grouped[1].groupedWorkflows).toEqual([]); }); it('should apply a given rule', () => { workflows = mock([ { id: '1', nodes: [node1] }, { id: '1', nodes: [node1, node2] }, { id: '1', nodes: [node1] }, ]); const alwaysMergeRule: DiffRule = (_l, _r) => true; const rules = [alwaysMergeRule]; const grouped = groupWorkflows(workflows, rules); expect(grouped.length).toBe(1); expect(grouped[0].from).toEqual(workflows[0]); expect(grouped[0].to).toEqual(workflows[2]); expect(grouped[0].groupedWorkflows).toEqual([workflows[1]]); expect(grouped[0].workflowChangeSet.nodes.size).toBe(1); expect(grouped[0].workflowChangeSet.nodes.get(node1.id)?.status).toBe(NodeDiffStatus.Eq); }); describe('mergeAdditiveChanges', () => { const createWorkflow = (id: string, nodes: DiffableNode[]): IWorkflowBase => { return { id, nodes, } as IWorkflowBase; }; test.each([ { description: 'should return true when all changes are additive', baseWorkflow: createWorkflow('1', [{ id: '1', parameters: { a: 'value1' }, name: 'n1' }]), nextWorkflow: createWorkflow('1', [ { id: '1', parameters: { a: 'value1', b: 'value2' }, name: 'n1' }, ]), expected: true, }, { description: 'should return false when a node is deleted', baseWorkflow: createWorkflow('1', [{ id: '1', parameters: { a: 'value1' }, name: 'n1' }]), nextWorkflow: createWorkflow('1', []), expected: false, }, { description: 'should return false when a node is modified non-additively', baseWorkflow: createWorkflow('1', [{ id: '1', parameters: { a: 'value1' }, name: 'n1' }]), nextWorkflow: createWorkflow('1', [ { id: '1', parameters: { a: 'differentValue' }, name: 'n1' }, ]), expected: false, }, { description: 'should return true when a node is added', baseWorkflow: createWorkflow('1', [{ id: '1', parameters: { a: 'value1' }, name: 'n1' }]), nextWorkflow: createWorkflow('1', [ { id: '1', parameters: { a: 'value1' }, name: 'n1' }, { id: '2', parameters: { b: 'value2' }, name: 'n1' }, ]), expected: true, }, { description: 'should return false when a node is modified and loses data', baseWorkflow: createWorkflow('1', [ { id: '1', parameters: { a: 'value1', b: 'value2' }, name: 'n1' }, ]), nextWorkflow: createWorkflow('1', [{ id: '1', parameters: { a: 'value1' }, name: 'n1' }]), expected: false, }, { description: 'should handle empty workflows', baseWorkflow: createWorkflow('1', []), nextWorkflow: createWorkflow('1', []), expected: true, }, ])('$description', ({ baseWorkflow, nextWorkflow, expected }) => { const result = RULES.mergeAdditiveChanges( { from: baseWorkflow, to: baseWorkflow, groupedWorkflows: [], workflowChangeSet: new WorkflowChangeSet(), }, { from: nextWorkflow, to: nextWorkflow, groupedWorkflows: [], workflowChangeSet: new WorkflowChangeSet(), }, compareWorkflowsNodes(baseWorkflow.nodes, nextWorkflow.nodes), ); expect(result).toEqual(expected); }); }); }); });