d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
1721 lines
50 KiB
TypeScript
1721 lines
50 KiB
TypeScript
/**
|
|
* @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 <start.output>.',
|
|
})
|
|
})
|
|
|
|
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 <start.output>.',
|
|
}),
|
|
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 <start.output>.',
|
|
}),
|
|
])
|
|
})
|
|
|
|
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' },
|
|
])
|
|
})
|
|
})
|