import { graphToFlow, flowToGraph } from '../serialization'; import type { WorkflowGraph, BackendConnections } from '../types'; /** * Build the LOCKED backend connections shape directly so we test against * the exact format the Django test suite constructs: * * { sourceId: { "main": [[{ node, type, index }]] } } */ describe('serialization — graphToFlow', () => { it('converts a single-output edge to one flow edge', () => { const graph: WorkflowGraph = { nodes: [ { id: 'a', name: 'A', type: 'trigger.event' }, { id: 'b', name: 'B', type: 'transform.set' }, ], edges: [{ source: 'a', target: 'b' }], }; const { nodes, edges } = graphToFlow(graph); expect(nodes).toHaveLength(2); expect(edges).toHaveLength(1); expect(edges[0]).toMatchObject({ source: 'a', target: 'b', sourceHandle: '0', targetHandle: '0', }); expect(edges[0].id).toBeTruthy(); // node data carries the original WfNode expect(nodes[0].id).toBe('a'); expect(nodes[0].position).toBeDefined(); }); it('encodes multi-output (if/else) via distinct sourceHandles', () => { const graph: WorkflowGraph = { nodes: [ { id: 'cond', name: 'Check', type: 'condition.if' }, { id: 'pass', name: 'Pass', type: 'transform.set' }, { id: 'fail', name: 'Fail', type: 'transform.set' }, ], edges: [ { source: 'cond', target: 'pass', sourceOutput: 0 }, { source: 'cond', target: 'fail', sourceOutput: 1 }, ], }; const { edges } = graphToFlow(graph); const passEdge = edges.find((e) => e.target === 'pass'); const failEdge = edges.find((e) => e.target === 'fail'); expect(passEdge?.sourceHandle).toBe('0'); expect(failEdge?.sourceHandle).toBe('1'); }); it('preserves explicit node positions', () => { const graph: WorkflowGraph = { nodes: [{ id: 'a', name: 'A', type: 't', position: { x: 42, y: 99 } }], edges: [], }; const { nodes } = graphToFlow(graph); expect(nodes[0].position).toEqual({ x: 42, y: 99 }); }); it('handles an empty graph', () => { const { nodes, edges } = graphToFlow({ nodes: [], edges: [] }); expect(nodes).toEqual([]); expect(edges).toEqual([]); }); }); describe('serialization — flowToGraph produces LOCKED backend shape', () => { it('single-output edge', () => { const graph: WorkflowGraph = { nodes: [ { id: 'a', name: 'A', type: 'trigger.event' }, { id: 'b', name: 'B', type: 'transform.set' }, ], edges: [{ source: 'a', target: 'b' }], }; const { nodes, edges } = graphToFlow(graph); const { connections } = flowToGraph(nodes, edges); const expected: BackendConnections = { a: { main: [[{ node: 'b', type: 'main', index: 0 }]] }, }; expect(connections).toEqual(expected); }); it('multi-output if/else — output 0 and 1', () => { const graph: WorkflowGraph = { nodes: [ { id: 'cond', name: 'Check', type: 'condition.if' }, { id: 'pass', name: 'Pass', type: 'transform.set' }, { id: 'fail', name: 'Fail', type: 'transform.set' }, ], edges: [ { source: 'cond', target: 'pass', sourceOutput: 0 }, { source: 'cond', target: 'fail', sourceOutput: 1 }, ], }; const { nodes, edges } = graphToFlow(graph); const { connections } = flowToGraph(nodes, edges); expect(connections).toEqual({ cond: { main: [ [{ node: 'pass', type: 'main', index: 0 }], [{ node: 'fail', type: 'main', index: 0 }], ], }, }); }); it('parallel fan-out — one source output to multiple targets', () => { const graph: WorkflowGraph = { nodes: [ { id: 's', name: 'S', type: 'trigger.event' }, { id: 't1', name: 'T1', type: 'transform.set' }, { id: 't2', name: 'T2', type: 'transform.set' }, ], edges: [ { source: 's', target: 't1' }, { source: 's', target: 't2' }, ], }; const { nodes, edges } = graphToFlow(graph); const { connections } = flowToGraph(nodes, edges); expect(connections).toEqual({ s: { main: [ [ { node: 't1', type: 'main', index: 0 }, { node: 't2', type: 'main', index: 0 }, ], ], }, }); }); it('empty graph yields empty connections', () => { const { nodes, edges } = graphToFlow({ nodes: [], edges: [] }); const { connections } = flowToGraph(nodes, edges); expect(connections).toEqual({}); }); it('dangling node with no edges produces no connection entry but is kept as a node', () => { const graph: WorkflowGraph = { nodes: [ { id: 'a', name: 'A', type: 'trigger.event' }, { id: 'orphan', name: 'Orphan', type: 'transform.set' }, { id: 'b', name: 'B', type: 'transform.set' }, ], edges: [{ source: 'a', target: 'b' }], }; const { nodes, edges } = graphToFlow(graph); const result = flowToGraph(nodes, edges); expect(result.connections).toEqual({ a: { main: [[{ node: 'b', type: 'main', index: 0 }]] }, }); expect(result.nodes.map((n) => n.id).sort()).toEqual(['a', 'b', 'orphan']); }); it('respects a non-zero targetInput index', () => { const graph: WorkflowGraph = { nodes: [ { id: 'a', name: 'A', type: 'x' }, { id: 'm', name: 'Merge', type: 'x' }, ], edges: [{ source: 'a', target: 'm', sourceOutput: 0, targetInput: 1 }], }; const { nodes, edges } = graphToFlow(graph); const { connections } = flowToGraph(nodes, edges); expect(connections).toEqual({ a: { main: [[{ node: 'm', type: 'main', index: 1 }]] }, }); }); it('back-fills empty earlier outputs when only output 1 is connected', () => { // n8n main is positional: output index 1 connected, index 0 empty. const graph: WorkflowGraph = { nodes: [ { id: 'cond', name: 'C', type: 'condition.if' }, { id: 'fail', name: 'F', type: 'x' }, ], edges: [{ source: 'cond', target: 'fail', sourceOutput: 1 }], }; const { nodes, edges } = graphToFlow(graph); const { connections } = flowToGraph(nodes, edges); expect(connections).toEqual({ cond: { main: [[], [{ node: 'fail', type: 'main', index: 0 }]], }, }); }); }); describe('serialization — round-trip', () => { const roundtrip = (graph: WorkflowGraph): BackendConnections => { const { nodes, edges } = graphToFlow(graph); return flowToGraph(nodes, edges).connections; }; it('flowToGraph(graphToFlow(g)) connections equal direct conversion', () => { const graph: WorkflowGraph = { nodes: [ { id: 'trigger', name: 'Start', type: 'trigger.event' }, { id: 'transform', name: 'Set', type: 'transform.set' }, { id: 'condition', name: 'Check', type: 'condition.if' }, { id: 'pass_node', name: 'Pass', type: 'transform.set' }, { id: 'fail_node', name: 'Fail', type: 'transform.set' }, ], edges: [ { source: 'trigger', target: 'transform' }, { source: 'transform', target: 'condition' }, { source: 'condition', target: 'pass_node', sourceOutput: 0 }, { source: 'condition', target: 'fail_node', sourceOutput: 1 }, ], }; // This is the exact shape from the backend test_models.py fixture. const expected: BackendConnections = { trigger: { main: [[{ node: 'transform', type: 'main', index: 0 }]] }, transform: { main: [[{ node: 'condition', type: 'main', index: 0 }]] }, condition: { main: [ [{ node: 'pass_node', type: 'main', index: 0 }], [{ node: 'fail_node', type: 'main', index: 0 }], ], }, }; expect(roundtrip(graph)).toEqual(expected); }); it('round-trips connections -> graph -> connections losslessly', () => { const connections: BackendConnections = { trigger: { main: [[{ node: 'transform', type: 'main', index: 0 }]] }, transform: { main: [[{ node: 'condition', type: 'main', index: 0 }]] }, condition: { main: [ [{ node: 'pass_node', type: 'main', index: 0 }], [{ node: 'fail_node', type: 'main', index: 0 }], ], }, }; const nodes = [ { id: 'trigger', name: 'Start', type: 'trigger.event' }, { id: 'transform', name: 'Set', type: 'transform.set' }, { id: 'condition', name: 'Check', type: 'condition.if' }, { id: 'pass_node', name: 'Pass', type: 'transform.set' }, { id: 'fail_node', name: 'Fail', type: 'transform.set' }, ]; const graph: WorkflowGraph = { nodes, edges: [], }; // hydrate edges from backend connections then re-serialize const hydrated = { ...graph, edges: connectionsToEdgesForTest(connections) }; expect(roundtrip(hydrated)).toEqual(connections); }); }); // Helper mirroring how a consumer would hydrate edges from backend // connections (kept local to the test to avoid coupling to impl detail). function connectionsToEdgesForTest(connections: BackendConnections) { const edges = []; for (const [source, outputs] of Object.entries(connections)) { for (const [type, outputLists] of Object.entries(outputs)) { outputLists.forEach((conns, outputIndex) => { for (const conn of conns) { edges.push({ source, target: conn.node, sourceOutput: outputIndex, targetInput: conn.index, type, }); } }); } } return edges; }