/** * @vitest-environment node */ import { describe, expect, it } from 'vitest' import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer' import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements' import { createSearchReplaceWorkflowFixture, SEARCH_REPLACE_BLOCK_CONFIGS, } from '@/lib/workflows/search-replace/search-replace.fixtures' import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields' describe('buildWorkflowSearchReplacePlan', () => { it('replaces selected text ranges across blocks without touching unselected matches', () => { const workflow = createSearchReplaceWorkflowFixture() const matches = indexWorkflowSearchMatches({ workflow, query: 'email', mode: 'text', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }) const selectedMatchIds = new Set(matches.slice(0, 2).map((match) => match.id)) const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds, defaultReplacement: 'message', }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toHaveLength(1) expect(plan.updates[0]).toMatchObject({ blockId: 'agent-1', subBlockId: 'systemPrompt', nextValue: 'message {{OLD_SECRET}} and then message again. Use .', }) }) it('skips non-editable display-label matches instead of writing labels into stored values', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['dropdown-1'] = { id: 'dropdown-1', type: 'custom', name: 'Dropdown Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { operation: { id: 'operation', type: 'dropdown', value: 'send_email' }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'Email', mode: 'text', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [ { id: 'operation', title: 'Operation', type: 'dropdown', options: [{ id: 'send_email', label: 'Send Email' }], }, ], }, }, }).filter((match) => match.blockId === 'dropdown-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'Slack', }) expect(plan.updates).toEqual([]) expect(plan.skipped).toEqual([ { matchId: matches[0].id, reason: 'Display labels cannot be replaced' }, ]) }) it('indexes dropdown display labels with the same casing shown by the editor', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['file-1'] = { id: 'file-1', type: 'file_v4', name: 'File', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { operation: { id: 'operation', type: 'dropdown', value: 'file_read' }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'Read', mode: 'text', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, file_v4: { subBlocks: [ { id: 'operation', title: 'Operation', type: 'dropdown', options: [{ id: 'file_read', label: 'Read' }], }, ], }, }, }).filter((match) => match.blockId === 'file-1') expect(matches).toEqual([ expect.objectContaining({ searchText: 'read', rawValue: 'read', range: { start: 0, end: 4 }, }), ]) }) it('replaces environment tokens while preserving surrounding text', () => { const workflow = createSearchReplaceWorkflowFixture() const matches = indexWorkflowSearchMatches({ workflow, query: 'OLD_SECRET', mode: 'resource', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }) const selectedMatchIds = new Set(matches.map((match) => match.id)) const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds, defaultReplacement: 'NEW_SECRET', resourceReplacementOptions: [ { kind: 'environment', value: '{{NEW_SECRET}}', label: '{{NEW_SECRET}}' }, ], }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toEqual( expect.arrayContaining([ expect.objectContaining({ blockId: 'agent-1', subBlockId: 'systemPrompt', nextValue: 'Email {{NEW_SECRET}} and then email again. Use .', }), expect.objectContaining({ blockId: 'api-1', subBlockId: 'headers', nextValue: [ { id: 'row-1', cells: { Key: 'Authorization', Value: 'Bearer {{NEW_SECRET}}' } }, ], }), ]) ) }) it('replaces exact structured resources in comma-separated values', () => { const workflow = createSearchReplaceWorkflowFixture() const matches = indexWorkflowSearchMatches({ workflow, query: 'kb-old', mode: 'resource', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }) const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'kb-new', resourceReplacementOptions: [ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, ], }) expect(plan.updates).toHaveLength(1) expect(plan.updates[0].nextValue).toBe('kb-new,kb-second') }) it('replaces only the selected duplicate structured resource occurrence', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-old,kb-second' const matches = indexWorkflowSearchMatches({ workflow, query: 'kb-old', mode: 'resource', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.kind === 'knowledge-base') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[0].id]), defaultReplacement: 'kb-new', resourceReplacementOptions: [ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, ], }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toHaveLength(1) expect(plan.updates[0].nextValue).toBe('kb-new,kb-old,kb-second') }) it('replaces a selected duplicate structured resource when duplicates are separated', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second,kb-old' const matches = indexWorkflowSearchMatches({ workflow, query: 'kb-old', mode: 'resource', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.kind === 'knowledge-base') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[1].id]), defaultReplacement: 'kb-new', resourceReplacementOptions: [ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, ], }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toHaveLength(1) expect(plan.updates[0].nextValue).toBe('kb-old,kb-second,kb-new') }) it('conflicts when a selected duplicate structured resource occurrence is removed', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second,kb-old' const matches = indexWorkflowSearchMatches({ workflow, query: 'kb-old', mode: 'resource', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.kind === 'knowledge-base') workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second' const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[1].id]), defaultReplacement: 'kb-new', resourceReplacementOptions: [ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, ], }) expect(plan.updates).toEqual([]) expect(plan.conflicts).toEqual([ { matchId: matches[1].id, reason: 'Target resource changed since search' }, ]) }) it('replaces duplicate structured resources with blank comma segments consistently', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,,kb-second,kb-old' const matches = indexWorkflowSearchMatches({ workflow, query: 'kb-old', mode: 'resource', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.kind === 'knowledge-base') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[1].id]), defaultReplacement: 'kb-new', resourceReplacementOptions: [ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, ], }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toHaveLength(1) expect(plan.updates[0].nextValue).toBe('kb-old,,kb-second,kb-new') }) it('replaces all compatible knowledge base references across blocks', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['knowledge-2'] = { ...workflow.blocks['knowledge-1'], id: 'knowledge-2', name: 'Knowledge 2', subBlocks: { ...workflow.blocks['knowledge-1'].subBlocks, knowledgeBaseIds: { id: 'knowledgeBaseIds', type: 'knowledge-base-selector', value: 'kb-old', }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'kb-old', mode: 'resource', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.kind === 'knowledge-base') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'kb-new', resourceReplacementOptions: [ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, ], }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toEqual( expect.arrayContaining([ expect.objectContaining({ blockId: 'knowledge-1', nextValue: 'kb-new,kb-second' }), expect.objectContaining({ blockId: 'knowledge-2', nextValue: 'kb-new' }), ]) ) }) it('replaces selector-backed workflow and knowledge document resources with scoped options', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['workflow-tool-1'] = { id: 'workflow-tool-1', type: 'custom', name: 'Workflow Tool', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'workflow-old', }, }, } const blockConfigs = { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [ { id: 'workflowId', title: 'Workflow', type: 'workflow-selector', selectorKey: 'sim.workflows', }, ], }, } const workflowMatches = indexWorkflowSearchMatches({ workflow, query: 'workflow-old', mode: 'resource', workspaceId: 'workspace-1', workflowId: 'current-workflow', blockConfigs, }).filter((match) => match.kind === 'workflow') const documentMatches = indexWorkflowSearchMatches({ workflow, query: 'doc-old', mode: 'resource', workspaceId: 'workspace-1', workflowId: 'current-workflow', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.kind === 'knowledge-document') const workflowPlan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches: workflowMatches, selectedMatchIds: new Set(workflowMatches.map((match) => match.id)), defaultReplacement: 'workflow-new', resourceReplacementOptions: [ { kind: 'workflow', value: 'workflow-new', label: 'New Workflow', resourceGroupKey: workflowMatches[0].resource?.resourceGroupKey, }, ], }) const documentPlan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches: documentMatches, selectedMatchIds: new Set(documentMatches.map((match) => match.id)), defaultReplacement: 'doc-new', resourceReplacementOptions: [ { kind: 'knowledge-document', value: 'doc-new', label: 'New Document', resourceGroupKey: documentMatches[0].resource?.resourceGroupKey, }, ], }) expect(workflowPlan.conflicts).toEqual([]) expect(workflowPlan.updates).toEqual([ expect.objectContaining({ blockId: 'workflow-tool-1', subBlockId: 'workflowId', nextValue: 'workflow-new', }), ]) expect(documentPlan.conflicts).toEqual([]) expect(documentPlan.updates).toEqual([ expect.objectContaining({ blockId: 'knowledge-1', subBlockId: 'documentId', nextValue: 'doc-new', }), ]) }) it('replaces structured file resources stored as serialized tool input params', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'slack', toolId: 'slack_message', operation: 'send', title: 'Slack message', params: { authMethod: 'oauth', credential: 'slack-credential', text: 'message with file', attachmentFiles: JSON.stringify({ name: 'contract.pdf', key: 'file-key-old', path: '/contract.pdf', size: 12, type: 'application/pdf', }), }, }, ], }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'contract', mode: 'all', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, }, }).filter((match) => match.kind === 'file') const replacementFile = { name: 'contract-v2.pdf', key: 'file-key-new', path: '/contract-v2.pdf', size: 24, type: 'application/pdf', } const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: JSON.stringify(replacementFile), resourceReplacementOptions: [ { kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name }, ], }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toHaveLength(1) expect(plan.updates[0].nextValue).toEqual([ expect.objectContaining({ params: expect.objectContaining({ attachmentFiles: JSON.stringify(replacementFile), }), }), ]) }) it('replaces one duplicate file occurrence in a serialized tool input file array', () => { const workflow = createSearchReplaceWorkflowFixture() const files = [ { name: 'first.pdf', key: 'file-key-old', path: '/first.pdf', size: 12, type: 'application/pdf', }, { name: 'second.pdf', key: 'file-key-other', path: '/second.pdf', size: 14, type: 'application/pdf', }, { name: 'third.pdf', key: 'file-key-old', path: '/third.pdf', size: 16, type: 'application/pdf', }, ] workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'slack', toolId: 'slack_message', operation: 'send', title: 'Slack message', params: { authMethod: 'oauth', credential: 'slack-credential', text: 'message with files', attachmentFiles: JSON.stringify(files), }, }, ], }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'file-key-old', mode: 'resource', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, }, }).filter((match) => match.kind === 'file') const replacementFile = { name: 'replacement.pdf', key: 'file-key-new', path: '/replacement.pdf', size: 24, type: 'application/pdf', } const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[1].id]), defaultReplacement: JSON.stringify(replacementFile), resourceReplacementOptions: [ { kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name }, ], }) const nextTools = plan.updates[0].nextValue as Array<{ params: { attachmentFiles: string } }> expect(plan.conflicts).toEqual([]) expect(JSON.parse(nextTools[0].params.attachmentFiles)).toEqual([ files[0], files[1], replacementFile, ]) }) it('conflicts when a selected duplicate file occurrence is removed', () => { const workflow = createSearchReplaceWorkflowFixture() const files = [ { name: 'first.pdf', key: 'file-key-old', path: '/first.pdf', size: 12, type: 'application/pdf', }, { name: 'second.pdf', key: 'file-key-other', path: '/second.pdf', size: 14, type: 'application/pdf', }, { name: 'third.pdf', key: 'file-key-old', path: '/third.pdf', size: 16, type: 'application/pdf', }, ] workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'slack', toolId: 'slack_message', operation: 'send', title: 'Slack message', params: { authMethod: 'oauth', credential: 'slack-credential', text: 'message with files', attachmentFiles: JSON.stringify(files), }, }, ], }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'file-key-old', mode: 'resource', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, }, }).filter((match) => match.kind === 'file') workflow.blocks['tool-input-1'].subBlocks.tools.value = [ { type: 'slack', toolId: 'slack_message', operation: 'send', title: 'Slack message', params: { authMethod: 'oauth', credential: 'slack-credential', text: 'message with files', attachmentFiles: JSON.stringify([files[0], files[1]]), }, }, ] const replacementFile = { name: 'replacement.pdf', key: 'file-key-new', path: '/replacement.pdf', size: 24, type: 'application/pdf', } const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[1].id]), defaultReplacement: JSON.stringify(replacementFile), resourceReplacementOptions: [ { kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name }, ], }) expect(plan.updates).toEqual([]) expect(plan.conflicts).toEqual([ { matchId: matches[1].id, reason: 'Target resource changed since search' }, ]) }) it('conflicts when a selected duplicate file occurrence becomes a single file object', () => { const workflow = createSearchReplaceWorkflowFixture() const firstFile = { name: 'first.pdf', key: 'file-key-old', path: '/first.pdf', size: 12, type: 'application/pdf', } const secondFile = { name: 'second.pdf', key: 'file-key-old', path: '/second.pdf', size: 14, type: 'application/pdf', } workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'slack', toolId: 'slack_message', operation: 'send', title: 'Slack message', params: { authMethod: 'oauth', credential: 'slack-credential', text: 'message with files', attachmentFiles: [firstFile, secondFile], }, }, ], }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'file-key-old', mode: 'resource', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, }, }).filter((match) => match.kind === 'file') workflow.blocks['tool-input-1'].subBlocks.tools.value = [ { type: 'slack', toolId: 'slack_message', operation: 'send', title: 'Slack message', params: { authMethod: 'oauth', credential: 'slack-credential', text: 'message with files', attachmentFiles: firstFile, }, }, ] const replacementFile = { name: 'replacement.pdf', key: 'file-key-new', path: '/replacement.pdf', size: 24, type: 'application/pdf', } const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[1].id]), defaultReplacement: JSON.stringify(replacementFile), resourceReplacementOptions: [ { kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name }, ], }) expect(plan.updates).toEqual([]) expect(plan.conflicts).toEqual([ { matchId: matches[1].id, reason: 'Target resource changed since search' }, ]) }) it('clears nested tool-input dependents when replacing a parent resource', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'workflow_input', toolId: 'workflow_executor', title: 'Workflow', params: { workflowId: 'workflow-old', inputMapping: JSON.stringify({ customerEmail: 'old email value' }), }, }, ], }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'workflow-old', mode: 'resource', workspaceId: 'workspace-1', workflowId: 'current-workflow', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, }, }).filter((match) => match.kind === 'workflow') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'workflow-new', resourceReplacementOptions: [ { kind: 'workflow', value: 'workflow-new', label: 'New Workflow', resourceGroupKey: matches[0].resource?.resourceGroupKey, }, ], }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toEqual([ expect.objectContaining({ subBlockId: 'tools', nextValue: [ expect.objectContaining({ params: expect.objectContaining({ workflowId: 'workflow-new', inputMapping: '', }), }), ], }), ]) }) it('preserves selected nested dependent replacements when replacing a parent resource', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'workflow_input', toolId: 'workflow_executor', title: 'Workflow', params: { workflowId: 'workflow-old', inputMapping: JSON.stringify({ customerEmail: 'old email value' }), }, }, ], }, }, } const blockConfigs = { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, } const workflowMatches = indexWorkflowSearchMatches({ workflow, query: 'workflow-old', mode: 'resource', workspaceId: 'workspace-1', workflowId: 'current-workflow', blockConfigs, }).filter((match) => match.kind === 'workflow') const mappingMatches = indexWorkflowSearchMatches({ workflow, query: 'old email', mode: 'text', blockConfigs, }).filter((match) => match.subBlockType === 'workflow-input-mapper') const matches = [...workflowMatches, ...mappingMatches] const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), replacementByMatchId: { [workflowMatches[0].id]: 'workflow-new', [mappingMatches[0].id]: 'new email', }, resourceReplacementOptions: [ { kind: 'workflow', value: 'workflow-new', label: 'New Workflow', resourceGroupKey: workflowMatches[0].resource?.resourceGroupKey, }, ], }) const nextTools = plan.updates[0].nextValue as Array<{ params: { inputMapping: string; workflowId: string } }> expect(plan.conflicts).toEqual([]) expect(nextTools[0].params.workflowId).toBe('workflow-new') expect(JSON.parse(nextTools[0].params.inputMapping)).toEqual({ customerEmail: 'new email value', }) }) it('replaces serialized workflow-input mapper values without changing keys', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'workflow_input', toolId: 'workflow_executor', title: 'Workflow', params: { workflowId: 'workflow-old', inputMapping: JSON.stringify({ customerEmail: 'old email value' }), }, }, ], }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'old email', mode: 'text', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, }, }).filter((match) => match.subBlockType === 'workflow-input-mapper') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'new email', }) const nextTools = plan.updates[0].nextValue as Array<{ params: { inputMapping: string } }> expect(plan.conflicts).toEqual([]) expect(JSON.parse(nextTools[0].params.inputMapping)).toEqual({ customerEmail: 'new email value', }) }) it('replaces object-valued fallback tool params without changing metadata', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'mcp', title: 'MCP tool', params: { payload: { type: 'metadata-type', filter: { status: 'old customer' }, }, }, }, ], }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'old', mode: 'text', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, }, }).filter((match) => match.blockId === 'tool-input-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'new', }) const nextTools = plan.updates[0].nextValue as Array<{ params: { payload: { type: string; filter: { status: string } } } }> expect(plan.conflicts).toEqual([]) expect(nextTools[0].params.payload).toEqual({ type: 'metadata-type', filter: { status: 'new customer' }, }) }) it('replaces serialized JSON fallback tool param values without changing keys', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tool-input-1'] = { id: 'tool-input-1', type: 'custom', name: 'Tool Input Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [ { type: 'mcp', title: 'MCP tool', params: { payload: JSON.stringify({ customer: { name: 'old customer' } }), }, }, ], }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'old', mode: 'text', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], }, }, }).filter((match) => match.blockId === 'tool-input-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'new', }) const nextTools = plan.updates[0].nextValue as Array<{ params: { payload: string } }> expect(plan.conflicts).toEqual([]) expect(JSON.parse(nextTools[0].params.payload)).toEqual({ customer: { name: 'new customer' }, }) }) it('replaces stringified variables-input values without changing metadata', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['variables-1'] = { id: 'variables-1', type: 'custom', name: 'Variables', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { assignments: { id: 'assignments', type: 'variables-input', value: JSON.stringify([ { id: 'assignment-id', variableId: 'variable-id', variableName: 'customer', type: 'string', value: 'old customer', isExisting: true, }, ]), }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'old', mode: 'text', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'assignments', title: 'Variables', type: 'variables-input' }], }, }, }).filter((match) => match.blockId === 'variables-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'new', }) const nextAssignments = JSON.parse(plan.updates[0].nextValue as string) expect(plan.conflicts).toEqual([]) expect(nextAssignments).toEqual([ { id: 'assignment-id', variableId: 'variable-id', variableName: 'customer', type: 'string', value: 'new customer', isExisting: true, }, ]) }) it('replaces stringified table cell values without changing row metadata', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['table-1'] = { id: 'table-1', type: 'custom', name: 'Table', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { rows: { id: 'rows', type: 'table', value: JSON.stringify([{ id: 'row-id', cells: { Name: 'old customer' } }]), }, }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'old', mode: 'text', blockConfigs: { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'rows', title: 'Rows', type: 'table', columns: ['Name'] }], }, }, }).filter((match) => match.blockId === 'table-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'new', }) const nextRows = JSON.parse(plan.updates[0].nextValue as string) expect(plan.conflicts).toEqual([]) expect(nextRows).toEqual([{ id: 'row-id', cells: { Name: 'new customer' } }]) }) it('allows replacing text matches with an empty string', () => { const workflow = createSearchReplaceWorkflowFixture() const matches = indexWorkflowSearchMatches({ workflow, query: 'email', mode: 'text', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.blockId === 'agent-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[0].id]), defaultReplacement: '', }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toEqual([ expect.objectContaining({ nextValue: ' {{OLD_SECRET}} and then email again. Use .', }), ]) }) it('replaces loop and parallel subflow editor values', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['parallel-1'] = { id: 'parallel-1', type: 'parallel', name: 'Parallel 1', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: {}, data: { parallelType: 'count', count: 20, }, } workflow.blocks['loop-1'] = { id: 'loop-1', type: 'loop', name: 'Loop 1', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: {}, data: { loopType: 'forEach', collection: "['item-2']", }, } const countMatches = indexWorkflowSearchMatches({ workflow, query: '20', mode: 'text', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.target.kind === 'subflow') const collectionMatches = indexWorkflowSearchMatches({ workflow, query: 'item-2', mode: 'text', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.target.kind === 'subflow') const countPlan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches: countMatches, selectedMatchIds: new Set(countMatches.map((match) => match.id)), defaultReplacement: '3', }) const collectionPlan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches: collectionMatches, selectedMatchIds: new Set(collectionMatches.map((match) => match.id)), defaultReplacement: 'item-3', }) expect(countPlan.conflicts).toEqual([]) expect(countPlan.subflowUpdates).toEqual([ expect.objectContaining({ blockId: 'parallel-1', blockType: 'parallel', fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.batchSize, previousValue: '20', nextValue: 3, }), expect.objectContaining({ blockId: 'parallel-1', blockType: 'parallel', fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations, previousValue: '20', nextValue: 3, }), ]) expect(collectionPlan.conflicts).toEqual([]) expect(collectionPlan.subflowUpdates).toEqual([ expect.objectContaining({ blockId: 'loop-1', blockType: 'loop', fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items, previousValue: "['item-2']", nextValue: "['item-3']", }), ]) }) it('replaces JSON-backed tag value fields without touching tag metadata', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tag-block-1'] = { id: 'tag-block-1', type: 'custom', name: 'Tag Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { tagFilters: { id: 'tagFilters', type: 'knowledge-tag-filters', value: JSON.stringify([ { id: 'filter-open', tagName: 'Status', fieldType: 'text', operator: 'eq', tagValue: 'open ticket', collapsed: false, }, ]), }, documentTags: { id: 'documentTags', type: 'document-tag-entry', value: JSON.stringify([ { id: 'tag-open', tagName: 'Priority', fieldType: 'text', value: 'open escalation', collapsed: false, }, ]), }, }, } const blockConfigs = { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [ { id: 'tagFilters', title: 'Tag Filters', type: 'knowledge-tag-filters' }, { id: 'documentTags', title: 'Document Tags', type: 'document-tag-entry' }, ], }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'open', mode: 'text', blockConfigs, }).filter((match) => match.blockId === 'tag-block-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'resolved', }) const tagFilterUpdate = plan.updates.find((update) => update.subBlockId === 'tagFilters') const documentTagUpdate = plan.updates.find((update) => update.subBlockId === 'documentTags') const nextTagFilterValue = JSON.parse(String(tagFilterUpdate?.nextValue)) const nextDocumentTagValue = JSON.parse(String(documentTagUpdate?.nextValue)) expect(plan.conflicts).toEqual([]) expect(nextTagFilterValue).toEqual([ { id: 'filter-open', tagName: 'Status', fieldType: 'text', operator: 'eq', tagValue: 'resolved ticket', collapsed: false, }, ]) expect(nextDocumentTagValue).toEqual([ { id: 'tag-open', tagName: 'Priority', fieldType: 'text', value: 'resolved escalation', collapsed: false, }, ]) }) it('replaces JSON-backed condition branch values without touching branch metadata', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['branch-1'] = { id: 'branch-1', type: 'custom', name: 'Branch Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { conditions: { id: 'conditions', type: 'condition-input', value: JSON.stringify([ { id: 'branch-open', title: 'if', value: 'open ticket', showTags: false, showEnvVars: false, }, ]), }, }, } const blockConfigs = { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'conditions', title: 'Conditions', type: 'condition-input' }], }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'open', mode: 'text', blockConfigs, }).filter((match) => match.blockId === 'branch-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'resolved', }) const branchUpdate = plan.updates.find((update) => update.subBlockId === 'conditions') const nextValue = JSON.parse(String(branchUpdate?.nextValue)) expect(plan.conflicts).toEqual([]) expect(nextValue).toEqual([ { id: 'branch-open', title: 'if', value: 'resolved ticket', showTags: false, showEnvVars: false, }, ]) }) it('replaces object-backed input mapping values without changing mapping keys', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['mapping-1'] = { id: 'mapping-1', type: 'custom', name: 'Mapping Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { inputMapping: { id: 'inputMapping', type: 'input-mapping', value: { customerEmail: 'old email value' }, }, }, } const blockConfigs = { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'inputMapping', title: 'Input Mapping', type: 'input-mapping' }], }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'old', mode: 'text', blockConfigs, }).filter((match) => match.blockId === 'mapping-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'new', }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toEqual([ expect.objectContaining({ subBlockId: 'inputMapping', nextValue: { customerEmail: 'new email value' }, }), ]) }) it('replaces workspace file upload resources with the selected file object', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['file-upload-1'] = { id: 'file-upload-1', type: 'custom', name: 'File Upload Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { file: { id: 'file', type: 'file-upload', value: { name: 'old.csv', path: '/workspace/ws-1/old-key', key: 'old-key', size: 42, type: 'text/csv', }, }, }, } const blockConfigs = { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'file', title: 'File', type: 'file-upload' }], }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'old', mode: 'resource', blockConfigs, }).filter((match) => match.blockId === 'file-upload-1') const replacementFile = { name: 'new.csv', path: '/workspace/ws-1/new-key', key: 'new-key', size: 84, type: 'text/csv', } const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: JSON.stringify(replacementFile), resourceReplacementOptions: [ { kind: 'file', value: JSON.stringify(replacementFile), label: 'new.csv' }, ], }) expect(plan.conflicts).toEqual([]) expect(plan.updates).toEqual([ expect.objectContaining({ subBlockId: 'file', nextValue: replacementFile, }), ]) }) it('rejects invalid file upload replacement payloads', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['file-upload-1'] = { id: 'file-upload-1', type: 'custom', name: 'File Upload Block', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: { file: { id: 'file', type: 'file-upload', value: { name: 'old.csv', path: '/workspace/ws-1/old-key', key: 'old-key', }, }, }, } const blockConfigs = { ...SEARCH_REPLACE_BLOCK_CONFIGS, custom: { subBlocks: [{ id: 'file', title: 'File', type: 'file-upload' }], }, } const matches = indexWorkflowSearchMatches({ workflow, query: 'old', mode: 'resource', blockConfigs, }).filter((match) => match.blockId === 'file-upload-1') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: '"not-a-file-object"', resourceReplacementOptions: [ { kind: 'file', value: '"not-a-file-object"', label: 'Invalid file' }, ], }) expect(plan.updates).toEqual([]) expect(plan.conflicts).toEqual([ { matchId: matches[0].id, reason: 'Replacement file is no longer valid', }, ]) }) it('rejects invalid subflow iteration replacements', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['parallel-1'] = { id: 'parallel-1', type: 'parallel', name: 'Parallel 1', position: { x: 0, y: 0 }, enabled: true, outputs: {}, subBlocks: {}, data: { parallelType: 'count', count: 2, }, } const matches = indexWorkflowSearchMatches({ workflow, query: '2', mode: 'text', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }).filter((match) => match.target.kind === 'subflow') const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: '25', }) expect(plan.subflowUpdates).toEqual([]) expect(plan.conflicts).toEqual([ { matchId: 'subflow-text:parallel-1:subflowBatchSize:0:0', reason: 'Parallel batch size must be between 1 and 20', }, ]) }) it('rejects structured resource replacements that are not resolvable options', () => { const workflow = createSearchReplaceWorkflowFixture() const matches = indexWorkflowSearchMatches({ workflow, query: 'kb-old', mode: 'resource', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }) const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set(matches.map((match) => match.id)), defaultReplacement: 'missing-kb', resourceReplacementOptions: [ { kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' }, ], }) expect(plan.updates).toEqual([]) expect(plan.conflicts).toEqual([ { matchId: matches[0].id, reason: 'Choose a valid knowledge base replacement.' }, ]) }) it('rejects stale matches without partial writes', () => { const workflow = createSearchReplaceWorkflowFixture() const matches = indexWorkflowSearchMatches({ workflow, query: 'email', mode: 'text', blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, }) workflow.blocks['agent-1'].subBlocks.systemPrompt.value = 'changed' const plan = buildWorkflowSearchReplacePlan({ blocks: workflow.blocks, matches, selectedMatchIds: new Set([matches[0].id]), defaultReplacement: 'message', }) expect(plan.updates).toEqual([]) expect(plan.conflicts).toEqual([ { matchId: matches[0].id, reason: 'Target text changed since search' }, ]) }) })