/** * Tests for workflow normalization utilities */ import { describe, expect, it } from 'vitest' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' import { extractBlockFieldsForComparison, filterSubBlockIds, normalizedStringify, normalizeEdge, normalizeLoop, normalizeParallel, normalizeTriggerConfigValues, normalizeValue, sanitizeInputFormat, sanitizeTools, sanitizeVariable, sortEdges, } from './normalize' describe('Workflow Normalization Utilities', () => { describe('normalizeValue', () => { it.concurrent('should return primitives unchanged', () => { expect(normalizeValue(42)).toBe(42) expect(normalizeValue('hello')).toBe('hello') expect(normalizeValue(true)).toBe(true) expect(normalizeValue(false)).toBe(false) }) it.concurrent('should normalize null and undefined to undefined', () => { // null and undefined are semantically equivalent in our system expect(normalizeValue(null)).toBe(undefined) expect(normalizeValue(undefined)).toBe(undefined) }) it.concurrent('should handle arrays by normalizing each element', () => { const input = [ { b: 2, a: 1 }, { d: 4, c: 3 }, ] const result = normalizeValue(input) expect(result).toEqual([ { a: 1, b: 2 }, { c: 3, d: 4 }, ]) }) it.concurrent('should sort object keys alphabetically', () => { const input = { zebra: 1, apple: 2, mango: 3 } const result = normalizeValue(input) as Record expect(Object.keys(result)).toEqual(['apple', 'mango', 'zebra']) }) it.concurrent('should recursively normalize nested objects', () => { const input = { outer: { z: 1, a: { y: 2, b: 3, }, }, first: 'value', } const result = normalizeValue(input) as { first: string outer: { z: number; a: { y: number; b: number } } } expect(Object.keys(result)).toEqual(['first', 'outer']) expect(Object.keys(result.outer)).toEqual(['a', 'z']) expect(Object.keys(result.outer.a)).toEqual(['b', 'y']) }) it.concurrent('should handle empty objects', () => { expect(normalizeValue({})).toEqual({}) }) it.concurrent('should handle empty arrays', () => { expect(normalizeValue([])).toEqual([]) }) it.concurrent('should handle arrays with mixed types', () => { const input = [1, 'string', { b: 2, a: 1 }, null, [3, 2, 1]] const result = normalizeValue(input) as unknown[] expect(result[0]).toBe(1) expect(result[1]).toBe('string') expect(Object.keys(result[2] as Record)).toEqual(['a', 'b']) expect(result[3]).toBe(undefined) // null normalized to undefined expect(result[4]).toEqual([3, 2, 1]) // Array order preserved }) it.concurrent('should handle deeply nested structures', () => { const input = { level1: { level2: { level3: { level4: { z: 'deep', a: 'value', }, }, }, }, } const result = normalizeValue(input) as { level1: { level2: { level3: { level4: { z: string; a: string } } } } } expect(Object.keys(result.level1.level2.level3.level4)).toEqual(['a', 'z']) }) }) describe('normalizedStringify', () => { it.concurrent('should produce identical strings for objects with different key orders', () => { const obj1 = { b: 2, a: 1, c: 3 } const obj2 = { a: 1, c: 3, b: 2 } const obj3 = { c: 3, b: 2, a: 1 } const str1 = normalizedStringify(obj1) const str2 = normalizedStringify(obj2) const str3 = normalizedStringify(obj3) expect(str1).toBe(str2) expect(str2).toBe(str3) }) it.concurrent('should produce valid JSON', () => { const obj = { nested: { value: [1, 2, 3] }, name: 'test' } const str = normalizedStringify(obj) expect(() => JSON.parse(str)).not.toThrow() }) it.concurrent('should handle primitive values', () => { expect(normalizedStringify(42)).toBe('42') expect(normalizedStringify('hello')).toBe('"hello"') expect(normalizedStringify(true)).toBe('true') }) it.concurrent('should treat null and undefined equivalently', () => { // Both null and undefined normalize to undefined, which JSON.stringify returns as undefined expect(normalizedStringify(null)).toBe(normalizedStringify(undefined)) }) it.concurrent('should produce different strings for different values', () => { const obj1 = { a: 1, b: 2 } const obj2 = { a: 1, b: 3 } expect(normalizedStringify(obj1)).not.toBe(normalizedStringify(obj2)) }) }) describe('sanitizeVariable', () => { it.concurrent('removes UI-only fields without changing persisted values', () => { expect( sanitizeVariable({ id: 'variable-a', workflowId: 'workflow-a', name: 'Optional payload', type: 'object', value: null, validationError: 'invalid', }) ).toEqual({ id: 'variable-a', name: 'Optional payload', type: 'object', value: null, }) }) }) describe('normalizeLoop', () => { it.concurrent('should normalize null/undefined to undefined', () => { // null and undefined are semantically equivalent expect(normalizeLoop(null)).toBe(undefined) expect(normalizeLoop(undefined)).toBe(undefined) }) it.concurrent('should normalize "for" loop type', () => { const loop: Loop & { extraField?: string } = { id: 'loop1', nodes: ['block1', 'block2'], loopType: 'for', iterations: 10, forEachItems: 'should-be-excluded', whileCondition: 'should-be-excluded', doWhileCondition: 'should-be-excluded', extraField: 'should-be-excluded', } const result = normalizeLoop(loop) expect(result).toEqual({ id: 'loop1', nodes: ['block1', 'block2'], loopType: 'for', iterations: 10, }) }) it.concurrent('should normalize "forEach" loop type', () => { const loop: Loop = { id: 'loop2', nodes: ['block1'], loopType: 'forEach', iterations: 5, forEachItems: '', whileCondition: 'should-be-excluded', } const result = normalizeLoop(loop) expect(result).toEqual({ id: 'loop2', nodes: ['block1'], loopType: 'forEach', forEachItems: '', }) }) it.concurrent('should normalize "while" loop type', () => { const loop: Loop = { id: 'loop3', nodes: ['block1', 'block2', 'block3'], loopType: 'while', iterations: 0, whileCondition: ' === true', doWhileCondition: 'should-be-excluded', } const result = normalizeLoop(loop) expect(result).toEqual({ id: 'loop3', nodes: ['block1', 'block2', 'block3'], loopType: 'while', whileCondition: ' === true', }) }) it.concurrent('should normalize "doWhile" loop type', () => { const loop: Loop = { id: 'loop4', nodes: ['block1'], loopType: 'doWhile', iterations: 0, doWhileCondition: ' < 100', whileCondition: 'should-be-excluded', } const result = normalizeLoop(loop) expect(result).toEqual({ id: 'loop4', nodes: ['block1'], loopType: 'doWhile', doWhileCondition: ' < 100', }) }) it.concurrent('should extract only relevant fields for for loop type', () => { const loop: Loop = { id: 'loop5', nodes: ['block1'], loopType: 'for', iterations: 5, forEachItems: 'items', } const result = normalizeLoop(loop) expect(result).toEqual({ id: 'loop5', nodes: ['block1'], loopType: 'for', iterations: 5, }) }) }) describe('normalizeParallel', () => { it.concurrent('should normalize null/undefined to undefined', () => { // null and undefined are semantically equivalent expect(normalizeParallel(null)).toBe(undefined) expect(normalizeParallel(undefined)).toBe(undefined) }) it.concurrent('should normalize "count" parallel type', () => { const parallel: Parallel & { extraField?: string } = { id: 'parallel1', nodes: ['block1', 'block2'], parallelType: 'count', count: 5, distribution: 'should-be-excluded', extraField: 'should-be-excluded', } const result = normalizeParallel(parallel) expect(result).toEqual({ id: 'parallel1', nodes: ['block1', 'block2'], parallelType: 'count', count: 5, }) }) it.concurrent('should normalize "collection" parallel type', () => { const parallel: Parallel = { id: 'parallel2', nodes: ['block1'], parallelType: 'collection', count: 10, distribution: '', } const result = normalizeParallel(parallel) expect(result).toEqual({ id: 'parallel2', nodes: ['block1'], parallelType: 'collection', distribution: '', }) }) it.concurrent('should include base fields for undefined parallel type', () => { const parallel: Parallel = { id: 'parallel3', nodes: ['block1'], parallelType: undefined, count: 5, distribution: 'items', } const result = normalizeParallel(parallel) expect(result).toEqual({ id: 'parallel3', nodes: ['block1'], parallelType: undefined, }) }) }) describe('sanitizeTools', () => { it.concurrent('should return empty array for undefined', () => { expect(sanitizeTools(undefined)).toEqual([]) }) it.concurrent('should return empty array for non-array input', () => { expect(sanitizeTools(null as any)).toEqual([]) expect(sanitizeTools('not-an-array' as any)).toEqual([]) expect(sanitizeTools({} as any)).toEqual([]) }) it.concurrent('should remove isExpanded field from tools', () => { const tools = [ { id: 'tool1', name: 'Search', isExpanded: true }, { id: 'tool2', name: 'Calculator', isExpanded: false }, { id: 'tool3', name: 'Weather' }, ] const result = sanitizeTools(tools) expect(result).toEqual([ { id: 'tool1', name: 'Search' }, { id: 'tool2', name: 'Calculator' }, { id: 'tool3', name: 'Weather' }, ]) }) it.concurrent('should preserve all other fields', () => { const tools = [ { id: 'tool1', name: 'Complex Tool', isExpanded: true, schema: { type: 'function', name: 'search' }, params: { query: 'test' }, nested: { deep: { value: 123 } }, }, ] const result = sanitizeTools(tools) expect(result[0]).toEqual({ id: 'tool1', name: 'Complex Tool', schema: { type: 'function', name: 'search' }, params: { query: 'test' }, nested: { deep: { value: 123 } }, }) }) it.concurrent('should handle empty array', () => { expect(sanitizeTools([])).toEqual([]) }) }) describe('sanitizeInputFormat', () => { it.concurrent('should return empty array for undefined', () => { expect(sanitizeInputFormat(undefined)).toEqual([]) }) it.concurrent('should return empty array for non-array input', () => { expect(sanitizeInputFormat(null as any)).toEqual([]) expect(sanitizeInputFormat('not-an-array' as any)).toEqual([]) expect(sanitizeInputFormat({} as any)).toEqual([]) }) it.concurrent('should remove collapsed field but keep value', () => { const inputFormat = [ { id: 'input1', name: 'Name', value: 'John', collapsed: true }, { id: 'input2', name: 'Age', value: 25, collapsed: false }, { id: 'input3', name: 'Email' }, ] const result = sanitizeInputFormat(inputFormat) expect(result).toEqual([ { id: 'input1', name: 'Name', value: 'John' }, { id: 'input2', name: 'Age', value: 25 }, { id: 'input3', name: 'Email' }, ]) }) it.concurrent('should preserve all other fields including value', () => { const inputFormat = [ { id: 'input1', name: 'Complex Input', value: 'test-value', collapsed: true, type: 'string', required: true, validation: { min: 0, max: 100 }, }, ] const result = sanitizeInputFormat(inputFormat) expect(result[0]).toEqual({ id: 'input1', name: 'Complex Input', value: 'test-value', type: 'string', required: true, validation: { min: 0, max: 100 }, }) }) it.concurrent('should handle empty array', () => { expect(sanitizeInputFormat([])).toEqual([]) }) }) describe('normalizeEdge', () => { it.concurrent('should extract only connection-relevant fields', () => { const edge = { id: 'edge1', source: 'block1', sourceHandle: 'output', target: 'block2', targetHandle: 'input', type: 'smoothstep', animated: true, style: { stroke: 'red' }, data: { label: 'connection' }, } const result = normalizeEdge(edge) expect(result).toEqual({ source: 'block1', sourceHandle: 'output', target: 'block2', targetHandle: 'input', }) }) it.concurrent('should handle edges without handles', () => { const edge = { id: 'edge1', source: 'block1', target: 'block2', } const result = normalizeEdge(edge) expect(result).toEqual({ source: 'block1', sourceHandle: undefined, target: 'block2', targetHandle: undefined, }) }) it.concurrent('should handle edges with only source handle', () => { const edge = { id: 'edge1', source: 'block1', sourceHandle: 'output', target: 'block2', } const result = normalizeEdge(edge) expect(result).toEqual({ source: 'block1', sourceHandle: 'output', target: 'block2', targetHandle: undefined, }) }) }) describe('sortEdges', () => { it.concurrent('should sort edges consistently', () => { const edges = [ { source: 'c', target: 'd' }, { source: 'a', target: 'b' }, { source: 'b', target: 'c' }, ] const result = sortEdges(edges) expect(result[0].source).toBe('a') expect(result[1].source).toBe('b') expect(result[2].source).toBe('c') }) it.concurrent( 'should sort by source, then sourceHandle, then target, then targetHandle', () => { const edges = [ { source: 'a', sourceHandle: 'out2', target: 'b', targetHandle: 'in1' }, { source: 'a', sourceHandle: 'out1', target: 'b', targetHandle: 'in1' }, { source: 'a', sourceHandle: 'out1', target: 'b', targetHandle: 'in2' }, { source: 'a', sourceHandle: 'out1', target: 'c', targetHandle: 'in1' }, ] const result = sortEdges(edges) expect(result[0]).toEqual({ source: 'a', sourceHandle: 'out1', target: 'b', targetHandle: 'in1', }) expect(result[1]).toEqual({ source: 'a', sourceHandle: 'out1', target: 'b', targetHandle: 'in2', }) expect(result[2]).toEqual({ source: 'a', sourceHandle: 'out1', target: 'c', targetHandle: 'in1', }) expect(result[3]).toEqual({ source: 'a', sourceHandle: 'out2', target: 'b', targetHandle: 'in1', }) } ) it.concurrent('should not mutate the original array', () => { const edges = [ { source: 'c', target: 'd' }, { source: 'a', target: 'b' }, ] const originalFirst = edges[0] sortEdges(edges) expect(edges[0]).toBe(originalFirst) }) it.concurrent('should handle empty array', () => { expect(sortEdges([])).toEqual([]) }) it.concurrent('should handle edges with undefined handles', () => { const edges = [ { source: 'b', target: 'c' }, { source: 'a', target: 'b', sourceHandle: 'out' }, ] const result = sortEdges(edges) expect(result[0].source).toBe('a') expect(result[1].source).toBe('b') }) it.concurrent('should produce identical results regardless of input order', () => { const edges1 = [ { source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' }, { source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' }, { source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' }, ] const edges2 = [ { source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' }, { source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' }, { source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' }, ] const edges3 = [ { source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' }, { source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' }, { source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' }, ] const result1 = normalizedStringify(sortEdges(edges1)) const result2 = normalizedStringify(sortEdges(edges2)) const result3 = normalizedStringify(sortEdges(edges3)) expect(result1).toBe(result2) expect(result2).toBe(result3) }) }) describe('filterSubBlockIds', () => { it.concurrent('should exclude exact SYSTEM_SUBBLOCK_IDS', () => { const ids = ['signingSecret', 'samplePayload', 'triggerInstructions', 'botToken'] const result = filterSubBlockIds(ids) expect(result).toEqual(['botToken', 'signingSecret']) }) it.concurrent('should exclude namespaced SYSTEM_SUBBLOCK_IDS (prefix matching)', () => { const ids = [ 'signingSecret', 'samplePayload_slack_webhook', 'triggerInstructions_slack_webhook', 'webhookUrlDisplay_slack_webhook', 'botToken', ] const result = filterSubBlockIds(ids) expect(result).toEqual(['botToken', 'signingSecret']) }) it.concurrent('should exclude exact TRIGGER_RUNTIME_SUBBLOCK_IDS', () => { const ids = ['webhookId', 'triggerPath', 'triggerConfig', 'triggerId', 'signingSecret'] const result = filterSubBlockIds(ids) expect(result).toEqual(['signingSecret']) }) it.concurrent('should not exclude IDs that merely contain a system ID substring', () => { const ids = ['mySamplePayload', 'notSamplePayload'] const result = filterSubBlockIds(ids) expect(result).toEqual(['mySamplePayload', 'notSamplePayload']) }) it.concurrent('should return sorted results', () => { const ids = ['zebra', 'alpha', 'middle'] const result = filterSubBlockIds(ids) expect(result).toEqual(['alpha', 'middle', 'zebra']) }) it.concurrent('should handle empty array', () => { expect(filterSubBlockIds([])).toEqual([]) }) it.concurrent('should handle all IDs being excluded', () => { const ids = ['webhookId', 'triggerPath', 'samplePayload', 'triggerConfig'] const result = filterSubBlockIds(ids) expect(result).toEqual([]) }) it.concurrent('should exclude setupScript and scheduleInfo namespaced variants', () => { const ids = ['setupScript_google_sheets_row', 'scheduleInfo_cron_trigger', 'realField'] const result = filterSubBlockIds(ids) expect(result).toEqual(['realField']) }) it.concurrent('should exclude triggerCredentials namespaced variants', () => { const ids = ['triggerCredentials_slack_webhook', 'signingSecret'] const result = filterSubBlockIds(ids) expect(result).toEqual(['signingSecret']) }) it.concurrent('should exclude synthetic tool-input subBlock IDs', () => { const ids = [ 'toolConfig', 'toolConfig-tool-0-query', 'toolConfig-tool-0-url', 'toolConfig-tool-1-status', 'systemPrompt', ] const result = filterSubBlockIds(ids) expect(result).toEqual(['systemPrompt', 'toolConfig']) }) }) describe('normalizeTriggerConfigValues', () => { it.concurrent('should return subBlocks unchanged when no triggerConfig exists', () => { const subBlocks = { signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' }, botToken: { id: 'botToken', type: 'short-input', value: 'token456' }, } const result = normalizeTriggerConfigValues(subBlocks) expect(result).toEqual(subBlocks) }) it.concurrent('should return subBlocks unchanged when triggerConfig value is null', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: null }, signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, } const result = normalizeTriggerConfigValues(subBlocks) expect(result).toEqual(subBlocks) }) it.concurrent( 'should return subBlocks unchanged when triggerConfig value is not an object', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: 'string-value' }, signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, } const result = normalizeTriggerConfigValues(subBlocks) expect(result).toEqual(subBlocks) } ) it.concurrent('should populate null individual fields from triggerConfig', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { signingSecret: 'secret123', botToken: 'token456' }, }, signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, botToken: { id: 'botToken', type: 'short-input', value: null }, } const result = normalizeTriggerConfigValues(subBlocks) expect((result.signingSecret as Record).value).toBe('secret123') expect((result.botToken as Record).value).toBe('token456') }) it.concurrent('should populate undefined individual fields from triggerConfig', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { signingSecret: 'secret123' }, }, signingSecret: { id: 'signingSecret', type: 'short-input', value: undefined }, } const result = normalizeTriggerConfigValues(subBlocks) expect((result.signingSecret as Record).value).toBe('secret123') }) it.concurrent('should populate empty string individual fields from triggerConfig', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { signingSecret: 'secret123' }, }, signingSecret: { id: 'signingSecret', type: 'short-input', value: '' }, } const result = normalizeTriggerConfigValues(subBlocks) expect((result.signingSecret as Record).value).toBe('secret123') }) it.concurrent('should NOT overwrite existing non-empty individual field values', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { signingSecret: 'old-secret' }, }, signingSecret: { id: 'signingSecret', type: 'short-input', value: 'user-edited-secret' }, } const result = normalizeTriggerConfigValues(subBlocks) expect((result.signingSecret as Record).value).toBe('user-edited-secret') }) it.concurrent('should skip triggerConfig fields that are null/undefined', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { signingSecret: null, botToken: undefined }, }, signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, botToken: { id: 'botToken', type: 'short-input', value: null }, } const result = normalizeTriggerConfigValues(subBlocks) expect((result.signingSecret as Record).value).toBe(null) expect((result.botToken as Record).value).toBe(null) }) it.concurrent('should skip fields from triggerConfig that have no matching subBlock', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { nonExistentField: 'value123' }, }, signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, } const result = normalizeTriggerConfigValues(subBlocks) expect(result.nonExistentField).toBeUndefined() expect((result.signingSecret as Record).value).toBe(null) }) it.concurrent('should not mutate the original subBlocks object', () => { const original = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { signingSecret: 'secret123' }, }, signingSecret: { id: 'signingSecret', type: 'short-input', value: null }, } normalizeTriggerConfigValues(original) expect((original.signingSecret as Record).value).toBe(null) }) it.concurrent('should preserve other subBlock properties when populating value', () => { const subBlocks = { triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { signingSecret: 'secret123' }, }, signingSecret: { id: 'signingSecret', type: 'short-input', value: null, placeholder: 'Enter signing secret', }, } const result = normalizeTriggerConfigValues(subBlocks) const normalized = result.signingSecret as Record expect(normalized.value).toBe('secret123') expect(normalized.id).toBe('signingSecret') expect(normalized.type).toBe('short-input') expect(normalized.placeholder).toBe('Enter signing secret') }) }) describe('extractBlockFieldsForComparison', () => { function createBlock(overrides: Partial = {}): BlockState { return { id: 'block-1', type: 'agent', name: 'Test', enabled: true, position: { x: 0, y: 0 }, subBlocks: {}, outputs: {}, ...overrides, } as BlockState } it.concurrent('should strip the locked field from blockRest', () => { const { blockRest } = extractBlockFieldsForComparison(createBlock({ locked: true })) expect((blockRest as Record).locked).toBeUndefined() }) it.concurrent( 'should yield identical blockRest when only locked differs between two blocks', () => { const lockedBlock = createBlock({ locked: true }) const unlockedBlock = createBlock({ locked: false }) const { blockRest: lockedRest } = extractBlockFieldsForComparison(lockedBlock) const { blockRest: unlockedRest } = extractBlockFieldsForComparison(unlockedBlock) expect(normalizedStringify(lockedRest)).toBe(normalizedStringify(unlockedRest)) } ) it.concurrent('should keep functional fields like name and enabled', () => { const { blockRest } = extractBlockFieldsForComparison( createBlock({ name: 'A', enabled: false, locked: true }) ) const rest = blockRest as Record expect(rest.name).toBe('A') expect(rest.enabled).toBe(false) expect(rest.locked).toBeUndefined() }) }) })