Files
simstudioai--sim/apps/sim/lib/workflows/search-replace/indexer.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

2982 lines
83 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
import { workflowSearchMatchMatchesQuery } from '@/lib/workflows/search-replace/resources'
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('indexWorkflowSearchMatches', () => {
it('finds plain text matches across nested subblock values', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'email',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(matches.map((match) => [match.blockId, match.subBlockId, match.valuePath])).toEqual([
['agent-1', 'systemPrompt', []],
['agent-1', 'systemPrompt', []],
['api-1', 'body', ['content']],
['locked-1', 'systemPrompt', []],
])
expect(matches.at(-1)?.editable).toBe(false)
expect(matches.at(-1)?.reason).toBe('Block is locked')
})
it('keeps exact ranges for duplicate matches in the same text field', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['agent-1'].subBlocks.systemPrompt.value = 'alpha beta alpha'
const matches = indexWorkflowSearchMatches({
workflow,
query: 'alpha',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.blockId === 'agent-1' && match.subBlockId === 'systemPrompt')
expect(matches.map((match) => match.range)).toEqual([
{ start: 0, end: 5 },
{ start: 11, end: 16 },
])
expect(matches.map((match) => match.rawValue)).toEqual(['alpha', 'alpha'])
})
it('finds matches in block names', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'agent',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
const blockNameMatches = matches.filter((match) => match.target.kind === 'block-name')
expect(blockNameMatches.map((match) => [match.blockId, match.rawValue])).toEqual([
['agent-1', 'Agent'],
['locked-1', 'Agent'],
])
expect(blockNameMatches.every((match) => match.editable === false)).toBe(true)
expect(blockNameMatches.every((match) => match.navigable === true)).toBe(true)
expect(blockNameMatches[0]?.fieldTitle).toBe('Block name')
})
it('does not include block-name matches in resource-only mode', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'agent',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
})
it('does not index internal row metadata in structured subblock values', () => {
const workflow = createSearchReplaceWorkflowFixture()
const rowMatches = indexWorkflowSearchMatches({
workflow,
query: 'row-1',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
workflow.blocks['api-1'].subBlocks.body.value = {
filtersById: {
'filter-1': {
id: 'filter-2',
collapsed: false,
value: '',
},
},
}
const objectMatches = indexWorkflowSearchMatches({
workflow,
query: 'filter-2',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(rowMatches).toEqual([])
expect(objectMatches).toEqual([])
})
it('indexes input format fields by visible nested field labels', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['start-1'] = {
id: 'start-1',
type: 'start_trigger',
name: 'Start',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
inputFormat: {
id: 'inputFormat',
type: 'input-format',
value: [
{
id: 'internal-field-id',
name: 'customerInput',
type: 'string',
description: 'Incoming payload',
value: 'sample',
collapsed: false,
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'in',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
start_trigger: {
subBlocks: [{ id: 'inputFormat', title: 'Inputs', type: 'input-format' }],
},
},
}).filter((match) => match.blockId === 'start-1')
expect(matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
valuePath: [0, 'name'],
fieldTitle: 'Name',
searchText: 'customerInput',
}),
expect.objectContaining({
valuePath: [0, 'description'],
fieldTitle: 'Description',
searchText: 'Incoming payload',
}),
])
)
expect(matches.some((match) => match.rawValue === 'internal-field-id')).toBe(false)
expect(matches.some((match) => match.valuePath.join('.') === '0.type')).toBe(false)
})
it('does not index evaluator type metadata', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['evaluator-1'] = {
id: 'evaluator-1',
type: 'custom',
name: 'Evaluator',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
metrics: {
id: 'metrics',
type: 'eval-input',
value: [
{
id: 'metric-1',
name: 'Accuracy',
type: 'score-kind',
description: 'Factual correctness',
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'score-kind',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'metrics', title: 'Metrics', type: 'eval-input' }],
},
},
}).filter((match) => match.blockId === 'evaluator-1')
expect(matches).toEqual([])
})
it('indexes fixed option display labels as non-editable matches', () => {
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')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'operation',
valuePath: [],
searchText: 'send email',
rawValue: 'email',
editable: false,
reason: 'Display labels cannot be replaced',
}),
])
})
it('indexes selected skill labels as non-editable display-label matches', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['agent-skills-1'] = {
id: 'agent-skills-1',
type: 'agent',
name: 'Agent Skills',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
skills: {
id: 'skills',
type: 'skill-input',
value: [{ skillId: 'skill-1', name: 'Sim bot' }],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'bot',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
agent: {
subBlocks: [{ id: 'skills', title: 'Skills', type: 'skill-input' }],
},
},
}).filter((match) => match.blockId === 'agent-skills-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'skills',
valuePath: [0, 'name'],
searchText: 'Sim bot',
rawValue: 'bot',
editable: false,
reason: 'Display labels cannot be replaced',
}),
])
})
it('indexes checkbox option labels as non-editable display-label matches', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['checkbox-1'] = {
id: 'checkbox-1',
type: 'custom',
name: 'Checkbox Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
options: { id: 'options', type: 'checkbox-list', value: null },
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'Values',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'options',
title: 'Options',
type: 'checkbox-list',
options: [{ id: 'includeValues', label: 'Include Values' }],
},
],
},
},
}).filter((match) => match.blockId === 'checkbox-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'options',
valuePath: ['options', 0],
searchText: 'Include Values',
editable: false,
reason: 'Display labels cannot be replaced',
}),
])
})
it('indexes table column labels alongside cell values', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['table-1'] = {
id: 'table-1',
type: 'custom',
name: 'Table Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
rows: { id: 'rows', type: 'table', value: [{ id: 'row-1', cells: { Email: 'owner' } }] },
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'Email',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'rows', title: 'Rows', type: 'table', columns: ['Email'] }],
},
},
}).filter((match) => match.blockId === 'table-1')
expect(matches).toEqual([
expect.objectContaining({
valuePath: ['columns', 0],
searchText: 'Email',
editable: false,
}),
])
})
it('aligns multi-resource matches to visible occurrence paths', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['kb-1'] = {
id: 'kb-1',
type: 'custom',
name: 'Knowledge Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
knowledgeBase: {
id: 'knowledgeBase',
type: 'knowledge-base-selector',
value: 'alpha-resource,beta-resource',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'beta-resource',
mode: 'resource',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'knowledgeBase',
title: 'Knowledge Base',
type: 'knowledge-base-selector',
multiSelect: true,
},
],
},
},
}).filter((match) => match.blockId === 'kb-1')
expect(matches).toEqual([
expect.objectContaining({
valuePath: [1],
structuredOccurrenceIndex: 1,
rawValue: 'beta-resource',
}),
])
})
it('indexes variables selector labels and MCP dynamic args with renderer paths', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['structured-1'] = {
id: 'structured-1',
type: 'custom',
name: 'Structured Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
variableAssignments: {
id: 'variableAssignments',
type: 'variables-input',
value: [
{
id: 'assignment-1',
variableId: 'var-1',
variableName: 'Account Name',
type: 'string',
value: 'customer',
isExisting: true,
},
],
},
args: {
id: 'args',
type: 'mcp-dynamic-args',
value: { prompt: 'Summarize account notes' },
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{ id: 'variableAssignments', title: 'Variables', type: 'variables-input' },
{ id: 'args', title: 'Arguments', type: 'mcp-dynamic-args' },
],
},
}
const variableMatches = indexWorkflowSearchMatches({
workflow,
query: 'Account',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'structured-1')
const mcpMatches = indexWorkflowSearchMatches({
workflow,
query: 'account',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'structured-1' && match.subBlockId === 'args')
expect(variableMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({
subBlockId: 'variableAssignments',
valuePath: [0, 'variableName'],
searchText: 'Account Name',
editable: false,
}),
])
)
expect(mcpMatches).toEqual([
expect.objectContaining({
subBlockId: 'args',
valuePath: ['prompt'],
searchText: 'Summarize account notes',
}),
])
})
it('indexes MCP dynamic enum args as non-editable display-label matches', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['mcp-enum-1'] = {
id: 'mcp-enum-1',
type: 'mcp',
name: 'MCP Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
arguments: {
id: 'arguments',
type: 'mcp-dynamic-args',
value: { format: 'markdown', prompt: 'customer prompt' },
},
_toolSchema: {
id: '_toolSchema',
type: 'short-input',
value: {
properties: {
format: { type: 'string', enum: ['markdown', 'json'] },
prompt: { type: 'string' },
},
},
},
},
}
const enumMatches = indexWorkflowSearchMatches({
workflow,
query: 'markdown',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
mcp: {
subBlocks: [{ id: 'arguments', title: 'Arguments', type: 'mcp-dynamic-args' }],
},
},
}).filter((match) => match.blockId === 'mcp-enum-1')
const promptMatches = indexWorkflowSearchMatches({
workflow,
query: 'customer',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
mcp: {
subBlocks: [{ id: 'arguments', title: 'Arguments', type: 'mcp-dynamic-args' }],
},
},
}).filter((match) => match.blockId === 'mcp-enum-1')
expect(enumMatches).toEqual([
expect.objectContaining({
subBlockId: 'arguments',
valuePath: ['format'],
searchText: 'markdown',
editable: false,
reason: 'Display labels cannot be replaced',
}),
])
expect(promptMatches).toEqual([
expect.objectContaining({
subBlockId: 'arguments',
valuePath: ['prompt'],
searchText: 'customer prompt',
editable: true,
}),
])
})
it('uses the same mode visibility as the editor', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['mode-1'] = {
id: 'mode-1',
type: 'custom',
name: 'Mode Block',
position: { x: 0, y: 0 },
enabled: true,
advancedMode: false,
triggerMode: false,
outputs: {},
subBlocks: {
basicOnly: { id: 'basicOnly', type: 'short-input', value: 'visible-basic' },
advancedOnly: { id: 'advancedOnly', type: 'short-input', value: 'hidden-advanced' },
triggerOnly: { id: 'triggerOnly', type: 'short-input', value: 'hidden-trigger' },
triggerManual: { id: 'triggerManual', type: 'short-input', value: 'hidden-trigger-manual' },
triggerConfig: {
id: 'triggerConfig',
type: 'trigger-config',
value: 'visible-trigger-config',
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{ id: 'basicOnly', title: 'Basic', type: 'short-input', mode: 'basic' },
{ id: 'advancedOnly', title: 'Advanced', type: 'short-input', mode: 'advanced' },
{
id: 'triggerOnly',
title: 'Trigger',
type: 'short-input',
mode: 'trigger',
canonicalParamId: 'triggerValue',
},
{
id: 'triggerManual',
title: 'Trigger Manual',
type: 'short-input',
mode: 'trigger-advanced',
canonicalParamId: 'triggerValue',
},
{ id: 'triggerConfig', title: 'Trigger Config', type: 'trigger-config' },
],
},
}
expect(
indexWorkflowSearchMatches({
workflow,
query: 'hidden',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'mode-1')
).toEqual([])
workflow.blocks['mode-1'].advancedMode = true
const advancedMatches = indexWorkflowSearchMatches({
workflow,
query: 'advanced',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'mode-1')
const basicMatches = indexWorkflowSearchMatches({
workflow,
query: 'basic',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'mode-1')
expect(advancedMatches).toHaveLength(1)
expect(advancedMatches[0].subBlockId).toBe('advancedOnly')
expect(basicMatches).toEqual([])
workflow.blocks['mode-1'].triggerMode = true
const triggerMatches = indexWorkflowSearchMatches({
workflow,
query: 'hidden-trigger',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'mode-1')
const nonTriggerMatches = indexWorkflowSearchMatches({
workflow,
query: 'advanced',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'mode-1')
expect(triggerMatches).toHaveLength(1)
expect(triggerMatches[0].subBlockId).toBe('triggerOnly')
expect(nonTriggerMatches).toEqual([])
const triggerConfigMatches = indexWorkflowSearchMatches({
workflow,
query: 'trigger-config',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'mode-1')
const triggerManualMatches = indexWorkflowSearchMatches({
workflow,
query: 'trigger-manual',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'mode-1')
expect(triggerConfigMatches).toHaveLength(1)
expect(triggerConfigMatches[0].subBlockId).toBe('triggerConfig')
expect(triggerManualMatches).toEqual([])
})
it('does not index fixed-choice dropdown values as text replacements', () => {
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',
},
flags: {
id: 'flags',
type: 'dropdown',
value: ['read', 'unread'],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'send',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [{ label: 'Send Email', id: 'send_email' }],
},
{
id: 'flags',
title: 'Flags',
type: 'dropdown',
multiSelect: true,
options: [
{ label: 'Read', id: 'read' },
{ label: 'Unread', id: 'unread' },
],
},
],
},
},
})
expect(matches.filter((match) => match.blockId === 'dropdown-1')).toEqual([
expect.objectContaining({
subBlockId: 'operation',
valuePath: [],
searchText: 'send email',
rawValue: 'send',
editable: false,
reason: 'Display labels cannot be replaced',
}),
])
})
it('does not index display-only subblocks that render from config', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['display-1'] = {
id: 'display-1',
type: 'custom',
name: 'Display',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
help: { id: 'help', type: 'text', value: 'stored shadow text' },
scheduleInfo: { id: 'scheduleInfo', type: 'schedule-info', value: 'stored schedule text' },
modal: { id: 'modal', type: 'modal', value: 'stored modal text' },
webhook: { id: 'webhook', type: 'webhook-config', value: 'stored webhook text' },
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'stored',
mode: 'all',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{ id: 'help', title: 'Help', type: 'text', defaultValue: 'Rendered help' },
{ id: 'scheduleInfo', title: 'Schedule', type: 'schedule-info' },
{ id: 'modal', title: 'Modal', type: 'modal' },
{ id: 'webhook', title: 'Webhook', type: 'webhook-config' },
],
},
},
}).filter((match) => match.blockId === 'display-1')
expect(matches).toEqual([])
})
it('indexes only value fields for JSON-backed knowledge tag subblocks', () => {
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',
valueTo: 'closed 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 valueMatches = indexWorkflowSearchMatches({
workflow,
query: 'open',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'tag-block-1')
const tagNameMatches = indexWorkflowSearchMatches({
workflow,
query: 'Status',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'tag-block-1')
const typeMatches = indexWorkflowSearchMatches({
workflow,
query: 'text',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'tag-block-1')
expect(valueMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({
subBlockId: 'tagFilters',
valuePath: [0, 'tagValue'],
fieldTitle: 'Value',
searchText: 'open ticket',
}),
expect.objectContaining({
subBlockId: 'documentTags',
valuePath: [0, 'value'],
fieldTitle: 'Value',
searchText: 'open escalation',
}),
])
)
expect(tagNameMatches).toEqual([])
expect(typeMatches).toEqual([])
})
it('indexes only assignment values for stringified variables input', () => {
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-needle-id',
variableId: 'variable-needle-id',
variableName: 'needleVariable',
type: 'string',
value: 'safe needle value',
isExisting: true,
},
]),
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'assignments', title: 'Variables', type: 'variables-input' }],
},
}
const valueMatches = indexWorkflowSearchMatches({
workflow,
query: 'needle value',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'variables-1')
const metadataMatches = indexWorkflowSearchMatches({
workflow,
query: 'variable-needle-id',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'variables-1')
const variableNameMatches = indexWorkflowSearchMatches({
workflow,
query: 'needleVariable',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'variables-1')
expect(valueMatches).toEqual([
expect.objectContaining({
subBlockId: 'assignments',
valuePath: [0, 'value'],
fieldTitle: 'Value',
searchText: 'safe needle value',
}),
])
expect(metadataMatches).toEqual([])
expect(variableNameMatches).toEqual([
expect.objectContaining({
subBlockId: 'assignments',
valuePath: [0, 'variableName'],
fieldTitle: 'Variable',
searchText: 'needleVariable',
editable: false,
}),
])
})
it('indexes table cells from stringified table values without exposing 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-needle-id', cells: { Name: 'Acme needle' } }]),
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'rows', title: 'Rows', type: 'table', columns: ['Name'] }],
},
}
const cellMatches = indexWorkflowSearchMatches({
workflow,
query: 'needle',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'table-1')
const metadataMatches = indexWorkflowSearchMatches({
workflow,
query: 'row-needle-id',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'table-1')
expect(cellMatches).toEqual([
expect.objectContaining({
subBlockId: 'rows',
valuePath: [0, 'cells', 'Name'],
fieldTitle: 'Name',
searchText: 'Acme needle',
}),
])
expect(metadataMatches).toEqual([])
})
it('indexes only editable branch values for JSON-backed condition and router subblocks', () => {
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-hidden-id',
title: 'branch hidden title',
value: 'branch visible value',
showTags: false,
},
]),
},
routes: {
id: 'routes',
type: 'router-input',
value: JSON.stringify([
{
id: 'route-hidden-id',
title: 'route hidden title',
value: 'route visible value',
showTags: false,
},
]),
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{ id: 'conditions', title: 'Conditions', type: 'condition-input' },
{ id: 'routes', title: 'Routes', type: 'router-input' },
],
},
}
const visibleMatches = indexWorkflowSearchMatches({
workflow,
query: 'visible',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'branch-1')
const hiddenMatches = indexWorkflowSearchMatches({
workflow,
query: 'hidden',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'branch-1')
expect(visibleMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({
subBlockId: 'conditions',
valuePath: [0, 'value'],
fieldTitle: 'Condition',
}),
expect.objectContaining({
subBlockId: 'routes',
valuePath: [0, 'value'],
fieldTitle: 'Route',
}),
])
)
expect(hiddenMatches).toEqual([])
})
it('does not index non-editable builder enums or message metadata', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['structured-1'] = {
id: 'structured-1',
type: 'custom',
name: 'Structured Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
messages: {
id: 'messages',
type: 'messages-input',
value: [{ role: 'user', content: 'user visible content' }],
},
filters: {
id: 'filters',
type: 'filter-builder',
value: [
{
id: 'filter-1',
column: 'status',
operator: 'contains',
value: 'contains visible value',
logicalOperator: 'and',
},
],
},
sorts: {
id: 'sorts',
type: 'sort-builder',
value: [{ id: 'sort-1', column: 'status', direction: 'asc' }],
},
skills: {
id: 'skills',
type: 'skill-input',
value: [{ skillId: 'skill-hidden-id', name: 'Skill Hidden Name' }],
},
runAt: {
id: 'runAt',
type: 'time-input',
value: '12:30',
},
mapping: {
id: 'mapping',
type: 'input-mapping',
value: { childInput: 'mapped visible value' },
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{ id: 'messages', title: 'Messages', type: 'messages-input' },
{ id: 'filters', title: 'Filters', type: 'filter-builder' },
{ id: 'sorts', title: 'Sorts', type: 'sort-builder' },
{ id: 'skills', title: 'Skills', type: 'skill-input' },
{ id: 'runAt', title: 'Run At', type: 'time-input' },
{ id: 'mapping', title: 'Input Mapping', type: 'input-mapping' },
],
},
}
const containsMatches = indexWorkflowSearchMatches({
workflow,
query: 'contains visible',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'structured-1')
const userMatches = indexWorkflowSearchMatches({
workflow,
query: 'user',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'structured-1')
const excludedMatches = indexWorkflowSearchMatches({
workflow,
query: 'hidden',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'structured-1')
const timeMatches = indexWorkflowSearchMatches({
workflow,
query: '12',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'structured-1')
const mappingMatches = indexWorkflowSearchMatches({
workflow,
query: 'mapped',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'structured-1')
expect(containsMatches).toEqual([
expect.objectContaining({
subBlockId: 'filters',
valuePath: [0, 'value'],
searchText: 'contains visible value',
}),
])
expect(userMatches).toEqual([
expect.objectContaining({
subBlockId: 'messages',
valuePath: [0, 'content'],
searchText: 'user visible content',
}),
])
expect(excludedMatches).toEqual([
expect.objectContaining({
subBlockId: 'skills',
valuePath: [0, 'name'],
searchText: 'Skill Hidden Name',
editable: false,
}),
])
expect(timeMatches).toEqual([
expect.objectContaining({
subBlockId: 'runAt',
valuePath: [],
searchText: '12:30 PM',
editable: false,
reason: 'Display labels cannot be replaced',
}),
])
expect(mappingMatches).toEqual([
expect.objectContaining({
subBlockId: 'mapping',
valuePath: ['childInput'],
searchText: 'mapped visible value',
}),
])
})
it('does not index skill-shaped values even when persisted under a legacy id', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['legacy-agent-1'] = {
id: 'legacy-agent-1',
type: 'custom',
name: 'Legacy Agent',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
legacySkills: {
id: 'legacySkills',
type: 'short-input',
value: [{ skillId: 'skill-vik-id', name: 'vik-skill' }],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'vik',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: { subBlocks: [] },
},
}).filter((match) => match.blockId === 'legacy-agent-1')
expect(matches).toEqual([])
})
it('indexes only editable variable assignment values as text', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['variables-1'] = {
id: 'variables-1',
type: 'variables',
name: 'Variables',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
variables: {
id: 'variables',
type: 'variables-input',
value: [
{
id: 'assignment-needle-id',
variableId: 'variable-needle-id',
variableName: 'needleVariable',
type: 'string',
value: 'needle assignment value',
isExisting: true,
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'needle',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
variables: {
subBlocks: [{ id: 'variables', title: 'Variable Assignments', type: 'variables-input' }],
},
},
}).filter((match) => match.blockId === 'variables-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'variables',
valuePath: [0, 'variableName'],
searchText: 'needleVariable',
editable: false,
}),
expect.objectContaining({
subBlockId: 'variables',
valuePath: [0, 'value'],
searchText: 'needle assignment value',
editable: true,
}),
])
})
it('does not index upload or dynamic control internals as text', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['dynamic-1'] = {
id: 'dynamic-1',
type: 'custom',
name: 'Dynamic Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
upload: {
id: 'upload',
type: 'file-upload',
value: {
name: 'customer.csv',
path: '/workspace/customer.csv',
key: 'storage-customer-key',
},
},
mcpArgs: {
id: 'mcpArgs',
type: 'mcp-dynamic-args',
value: { prompt: 'customer prompt' },
},
slider: {
id: 'slider',
type: 'slider',
value: 42,
},
enabled: {
id: 'enabled',
type: 'switch',
value: true,
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'customer',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{ id: 'upload', title: 'Upload', type: 'file-upload' },
{ id: 'mcpArgs', title: 'MCP Args', type: 'mcp-dynamic-args' },
{ id: 'slider', title: 'Slider', type: 'slider' },
{ id: 'enabled', title: 'Enabled', type: 'switch' },
],
},
},
}).filter((match) => match.blockId === 'dynamic-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'mcpArgs',
valuePath: ['prompt'],
searchText: 'customer prompt',
}),
])
})
it('indexes only safe user-facing paths inside tool input values', () => {
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: 'native',
toolId: 'gmail_customer_tool',
operation: 'send_customer_message',
title: 'Customer notifier',
params: {
body: 'hello customer',
credentialId: 'credential-customer-id',
inputMapping: JSON.stringify({ query: 'customer json value' }),
},
schema: {
description: 'customer schema text',
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'customer',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
native: {
name: 'Customer Mailer',
subBlocks: [],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
expect(matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
subBlockId: 'tools',
valuePath: [0, 'title'],
searchText: 'Customer Mailer',
}),
expect.objectContaining({
subBlockId: 'tools',
valuePath: [0, 'params', 'body'],
searchText: 'hello customer',
}),
])
)
expect(matches.some((match) => match.searchText === 'Customer notifier')).toBe(false)
expect(matches.some((match) => match.valuePath.includes('toolId'))).toBe(false)
expect(matches.some((match) => match.valuePath.includes('operation'))).toBe(false)
expect(matches.some((match) => match.valuePath.includes('credentialId'))).toBe(false)
expect(matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
valuePath: [0, 'params', 'inputMapping', 'query'],
searchText: 'customer json value',
}),
])
)
expect(matches.some((match) => match.valuePath.includes('schema'))).toBe(false)
})
it('indexes canonical MCP and custom-tool names over mutated stored titles', () => {
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', toolId: 'mcp-tool-1', title: 'Mutated Title', params: {} },
{ type: 'custom-tool', customToolId: 'ct-1', title: 'Mutated Title', params: {} },
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'customer',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
customTools: [{ id: 'ct-1', title: 'Customer Records' }],
mcpToolNamesById: new Map([['mcp-tool-1', 'customer_lookup']]),
}).filter((match) => match.blockId === 'tool-input-1')
expect(matches).toEqual(
expect.arrayContaining([
expect.objectContaining({ valuePath: [0, 'title'], searchText: 'customer_lookup' }),
expect.objectContaining({ valuePath: [1, 'title'], searchText: 'Customer Records' }),
])
)
expect(matches.some((match) => match.searchText === 'Mutated Title')).toBe(false)
})
it('indexes explicit secret tool params for intentional replacement', () => {
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',
botToken: 'xoxb-hidden-token',
text: 'visible slack body',
},
},
],
},
},
}
const hiddenMatches = indexWorkflowSearchMatches({
workflow,
query: 'xoxb-hidden-token',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
const visibleMatches = indexWorkflowSearchMatches({
workflow,
query: 'visible slack',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
expect(hiddenMatches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
valuePath: [0, 'params', 'botToken'],
searchText: 'xoxb-hidden-token',
}),
])
expect(visibleMatches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
valuePath: [0, 'params', 'text'],
searchText: 'visible slack body',
}),
])
})
it('indexes structured resources inside tool input params using nested subblock config', () => {
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.blockId === 'tool-input-1')
expect(matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'file',
subBlockId: 'tools',
subBlockType: 'file-upload',
rawValue: 'file-key-old',
searchText: 'contract.pdf',
valuePath: [0, 'params', 'attachmentFiles'],
}),
])
)
})
it('does not double index synthetic tool-input mirror subblocks', () => {
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: 'api',
toolId: 'http_request',
title: 'API',
params: {
url: 'Lmfap',
},
},
],
},
'tools-tool-0-url': {
id: 'tools-tool-0-url',
type: 'short-input',
value: 'Lmfap',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'lmf',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
valuePath: [0, 'params', 'url'],
searchText: 'Lmfap',
}),
])
})
it('attaches selector context to selector-backed 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',
channel: 'COLD',
text: 'message',
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'COLD',
mode: 'resource',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.kind === 'selector-resource')
expect(matches).toEqual([
expect.objectContaining({
rawValue: 'COLD',
resource: expect.objectContaining({
selectorKey: 'slack.channels',
selectorContext: expect.objectContaining({
oauthCredential: 'slack-credential',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
excludeWorkflowId: 'workflow-1',
}),
}),
}),
])
})
it('indexes tool-input titles as non-editable display labels', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['tool-title-1'] = {
id: 'tool-title-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: { text: 'hello' },
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'Slack',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-title-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
subBlockType: 'tool-input',
valuePath: [0, 'title'],
searchText: 'Slack message',
editable: false,
reason: 'Display labels cannot be replaced',
}),
])
})
it('indexes workflow-input tool mappings by values without exposing JSON 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 keyMatches = indexWorkflowSearchMatches({
workflow,
query: 'customerEmail',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
const valueMatches = indexWorkflowSearchMatches({
workflow,
query: 'old email',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
expect(keyMatches).toEqual([])
expect(valueMatches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
subBlockType: 'workflow-input-mapper',
valuePath: [0, 'params', 'inputMapping', 'customerEmail'],
searchText: 'old email value',
}),
])
})
it('indexes object-backed workflow-input tool mappings by values', () => {
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: { customerEmail: 'object email value' },
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'object email',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
subBlockType: 'workflow-input-mapper',
valuePath: [0, 'params', 'inputMapping', 'customerEmail'],
searchText: 'object email value',
}),
])
})
it('indexes object-valued fallback tool params by leaf values', () => {
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: 'open customer' },
},
},
},
],
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
}
const valueMatches = indexWorkflowSearchMatches({
workflow,
query: 'open customer',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'tool-input-1')
const typeMatches = indexWorkflowSearchMatches({
workflow,
query: 'metadata-type',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'tool-input-1')
expect(valueMatches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
subBlockType: 'workflow-input-mapper',
valuePath: [0, 'params', 'payload', 'filter', 'status'],
searchText: 'open customer',
}),
])
expect(typeMatches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
subBlockType: 'workflow-input-mapper',
valuePath: [0, 'params', 'payload', 'type'],
searchText: 'metadata-type',
}),
])
})
it('indexes visible tool params ending in key', () => {
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: 'custom-tool',
title: 'Custom issue tool',
params: {
issueKey: 'PROJ-123',
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'PROJ',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
valuePath: [0, 'params', 'issueKey'],
searchText: 'PROJ-123',
}),
])
})
it('indexes nested JSON object fallback tool params by values without exposing 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',
toolId: 'server-tool',
title: 'MCP tool',
params: {
payload: JSON.stringify({ customer: { name: 'Acme Corp' } }),
},
},
],
},
},
}
const valueMatches = indexWorkflowSearchMatches({
workflow,
query: 'Acme',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
const keyMatches = indexWorkflowSearchMatches({
workflow,
query: 'customer',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
expect(valueMatches).toEqual([
expect.objectContaining({
subBlockId: 'tools',
subBlockType: 'workflow-input-mapper',
valuePath: [0, 'params', 'payload', 'customer', 'name'],
searchText: 'Acme Corp',
}),
])
expect(keyMatches).toEqual([])
})
it('scopes MCP tool resources to the selected server', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['mcp-1'] = {
id: 'mcp-1',
type: 'mcp',
name: 'MCP',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
server: {
id: 'server',
type: 'mcp-server-selector',
value: 'server-a',
},
tool: {
id: 'tool',
type: 'mcp-tool-selector',
value: 'server-a-search',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'search',
mode: 'resource',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
mcp: {
subBlocks: [
{ id: 'server', title: 'Server', type: 'mcp-server-selector' },
{ id: 'tool', title: 'Tool', type: 'mcp-tool-selector', dependsOn: ['server'] },
],
},
},
}).filter((match) => match.kind === 'mcp-tool')
expect(matches).toEqual([
expect.objectContaining({
rawValue: 'server-a-search',
resource: expect.objectContaining({
selectorContext: expect.objectContaining({ mcpServerId: 'server-a' }),
}),
}),
])
})
it('does not index condition-hidden subblocks', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['condition-1'] = {
id: 'condition-1',
type: 'custom',
name: 'Conditional Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
operation: {
id: 'operation',
type: 'dropdown',
value: 'send',
},
hiddenBody: {
id: 'hiddenBody',
type: 'long-input',
value: 'invisible content',
},
visibleBody: {
id: 'visibleBody',
type: 'long-input',
value: 'visible content',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'content',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{ id: 'operation', title: 'Operation', type: 'dropdown' },
{
id: 'hiddenBody',
title: 'Hidden Body',
type: 'long-input',
condition: { field: 'operation', value: 'receive' },
},
{
id: 'visibleBody',
title: 'Visible Body',
type: 'long-input',
condition: { field: 'operation', value: 'send' },
},
],
},
},
}).filter((match) => match.blockId === 'condition-1')
expect(matches).toEqual([
expect.objectContaining({
subBlockId: 'visibleBody',
fieldTitle: 'Visible Body',
}),
])
})
it('does not index hidden generated subblocks', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['evaluator-1'] = {
id: 'evaluator-1',
type: 'evaluator',
name: 'Evaluator',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
systemPrompt: {
id: 'systemPrompt',
type: 'code',
value: 'Generated content should not be searchable',
},
content: {
id: 'content',
type: 'long-input',
value: 'Visible content should be searchable',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'content',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
evaluator: {
subBlocks: [
{ id: 'systemPrompt', title: 'System Prompt', type: 'code', hidden: true },
{ id: 'content', title: 'Content', type: 'long-input' },
],
},
},
})
expect(matches).toEqual([
expect.objectContaining({
blockId: 'evaluator-1',
subBlockId: 'content',
fieldTitle: 'Content',
}),
])
})
it('indexes only the active member of a canonical basic/advanced pair', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['canonical-1'] = {
id: 'canonical-1',
type: 'custom',
name: 'Canonical Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
data: {
canonicalModes: { file: 'advanced' },
},
subBlocks: {
fileSelector: {
id: 'fileSelector',
type: 'file-selector',
value: 'basic-file-id',
},
fileReference: {
id: 'fileReference',
type: 'short-input',
value: 'advanced-file-reference',
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'fileSelector',
title: 'File',
type: 'file-selector',
canonicalParamId: 'file',
mode: 'basic',
},
{
id: 'fileReference',
title: 'File',
type: 'short-input',
canonicalParamId: 'file',
mode: 'advanced',
},
],
},
}
const basicMatches = indexWorkflowSearchMatches({
workflow,
query: 'basic-file-id',
mode: 'all',
blockConfigs,
}).filter((match) => match.blockId === 'canonical-1')
const advancedMatches = indexWorkflowSearchMatches({
workflow,
query: 'advanced-file',
mode: 'all',
blockConfigs,
}).filter((match) => match.blockId === 'canonical-1')
expect(basicMatches).toEqual([])
expect(advancedMatches).toEqual([
expect.objectContaining({
subBlockId: 'fileReference',
canonicalSubBlockId: 'file',
kind: 'text',
}),
])
})
it('indexes reactive credential-type fields only when their credential type matches', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['reactive-1'] = {
id: 'reactive-1',
type: 'custom',
name: 'Reactive Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
credential: {
id: 'credential',
type: 'oauth-input',
value: 'credential-1',
},
impersonateUserEmail: {
id: 'impersonateUserEmail',
type: 'short-input',
value: 'service-account-user@example.com',
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'credential',
title: 'Google Account',
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
},
{
id: 'impersonateUserEmail',
title: 'Impersonated Account',
type: 'short-input',
reactiveCondition: {
watchFields: ['oauthCredential'],
requiredType: 'service_account',
},
},
],
},
}
const hiddenMatches = indexWorkflowSearchMatches({
workflow,
query: 'service-account-user',
mode: 'text',
blockConfigs,
credentialTypeById: { 'credential-1': 'oauth' },
}).filter((match) => match.blockId === 'reactive-1')
const visibleMatches = indexWorkflowSearchMatches({
workflow,
query: 'service-account-user',
mode: 'text',
blockConfigs,
credentialTypeById: { 'credential-1': 'service_account' },
}).filter((match) => match.blockId === 'reactive-1')
expect(hiddenMatches).toEqual([])
expect(visibleMatches).toEqual([
expect.objectContaining({
subBlockId: 'impersonateUserEmail',
fieldTitle: 'Impersonated Account',
}),
])
})
it('indexes editable combobox text and still finds inline references', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['combobox-1'] = {
id: 'combobox-1',
type: 'custom',
name: 'Combobox Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
model: {
id: 'model',
type: 'combobox',
value: 'claude-sonnet-4-6',
},
dynamicModel: {
id: 'dynamicModel',
type: 'combobox',
value: '<start.model>',
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'model',
title: 'Model',
type: 'combobox',
options: [{ label: 'Claude Sonnet', id: 'claude-sonnet-4-6' }],
},
{
id: 'dynamicModel',
title: 'Dynamic Model',
type: 'combobox',
options: [{ label: 'Claude Sonnet', id: 'claude-sonnet-4-6' }],
},
],
},
}
const textMatches = indexWorkflowSearchMatches({
workflow,
query: 'claude',
mode: 'text',
blockConfigs,
})
const referenceMatches = indexWorkflowSearchMatches({
workflow,
query: 'start.model',
mode: 'resource',
blockConfigs,
})
expect(textMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({
blockId: 'combobox-1',
subBlockId: 'model',
kind: 'text',
rawValue: 'claude',
editable: true,
}),
])
)
expect(referenceMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({
blockId: 'combobox-1',
subBlockId: 'dynamicModel',
kind: 'workflow-reference',
rawValue: '<start.model>',
}),
])
)
})
it('indexes evaluator metrics by visible nested field labels', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['evaluator-1'] = {
id: 'evaluator-1',
type: 'evaluator',
name: 'Evaluator',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
metrics: {
id: 'metrics',
type: 'eval-input',
value: [
{
id: 'metric-internal-id',
name: 'Accuracy',
description: 'Score factual correctness',
range: { min: 0, max: 10 },
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: '10',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
evaluator: {
subBlocks: [{ id: 'metrics', title: 'Evaluation Metrics', type: 'eval-input' }],
},
},
}).filter((match) => match.blockId === 'evaluator-1')
expect(matches).toEqual([
expect.objectContaining({
valuePath: [0, 'range', 'max'],
fieldTitle: 'Max Value',
searchText: '10',
editable: false,
reason: 'Only text values can be replaced',
}),
])
const idMatches = indexWorkflowSearchMatches({
workflow,
query: 'metric-internal-id',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
evaluator: {
subBlocks: [{ id: 'metrics', title: 'Evaluation Metrics', type: 'eval-input' }],
},
},
})
expect(idMatches).toEqual([])
})
it('indexes non-string scalar values as searchable but not editable', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['api-1'].subBlocks.body.value = { count: 2, enabled: true }
const matches = indexWorkflowSearchMatches({
workflow,
query: '2',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.blockId === 'api-1')
expect(matches).toEqual([
expect.objectContaining({
valuePath: ['count'],
rawValue: '2',
editable: false,
reason: 'Only text values can be replaced',
}),
])
})
it('indexes loop and parallel editor settings for navigation', () => {
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,
})
const collectionMatches = indexWorkflowSearchMatches({
workflow,
query: 'item-2',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(countMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({
blockId: 'parallel-1',
subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
canonicalSubBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
fieldTitle: 'Parallel Iterations',
editable: true,
target: {
kind: 'subflow',
fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
},
}),
])
)
expect(collectionMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({
blockId: 'loop-1',
subBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items,
canonicalSubBlockId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items,
fieldTitle: 'Collection Items',
editable: true,
target: {
kind: 'subflow',
fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items,
},
}),
])
)
})
it('indexes environment tokens and workflow references embedded in strings', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'OLD_SECRET',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(matches.filter((match) => match.kind === 'environment')).toHaveLength(2)
expect(matches.every((match) => match.rawValue === '{{OLD_SECRET}}')).toBe(true)
const references = indexWorkflowSearchMatches({
workflow,
query: 'start.output',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(references.map((match) => match.kind)).toEqual(['workflow-reference'])
})
it('classifies structured resources by subblock type instead of UUID shape', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
mode: 'resource',
includeResourceMatchesWithoutQuery: true,
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'oauth-credential',
rawValue: 'gmail-credential-old',
resource: expect.objectContaining({ providerId: 'gmail' }),
}),
expect.objectContaining({ kind: 'knowledge-base', rawValue: 'kb-old' }),
expect.objectContaining({ kind: 'knowledge-base', rawValue: 'kb-second' }),
expect.objectContaining({ kind: 'knowledge-document', rawValue: 'doc-old' }),
])
)
})
it('can enumerate resource candidates before display-label filtering', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'Test LMFAO',
mode: 'all',
includeResourceMatchesWithoutQuery: true,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
const knowledgeMatch = matches.find(
(match) => match.kind === 'knowledge-base' && match.rawValue === 'kb-old'
)
expect(knowledgeMatch).toBeDefined()
expect(
workflowSearchMatchMatchesQuery(
{ ...knowledgeMatch!, displayLabel: 'Test LMFAO' },
'Test LMFAO'
)
).toBe(true)
})
it('does not match opaque structured resource ids during display-label filtering', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-2-opaque-id'
const matches = indexWorkflowSearchMatches({
workflow,
query: '2',
mode: 'all',
includeResourceMatchesWithoutQuery: true,
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
const knowledgeMatch = matches.find(
(match) => match.kind === 'knowledge-base' && match.rawValue === 'kb-2-opaque-id'
)
expect(knowledgeMatch).toBeDefined()
expect(
workflowSearchMatchMatchesQuery({ ...knowledgeMatch!, displayLabel: 'Support Articles' }, '2')
).toBe(false)
expect(
workflowSearchMatchMatchesQuery(
{ ...knowledgeMatch!, displayLabel: 'Support Articles 2' },
'2'
)
).toBe(true)
})
it('does not index structured resource ids as plain text matches', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-2-opaque-id'
const matches = indexWorkflowSearchMatches({
workflow,
query: '2',
mode: 'all',
includeResourceMatchesWithoutQuery: true,
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(
matches.some(
(match) =>
match.kind === 'text' &&
match.blockId === 'knowledge-1' &&
match.subBlockId === 'knowledgeBaseIds'
)
).toBe(false)
expect(
matches.some(
(match) =>
match.kind === 'knowledge-base' &&
match.blockId === 'knowledge-1' &&
match.subBlockId === 'knowledgeBaseIds'
)
).toBe(true)
})
it('keeps selector-like legacy state out of plain text matches when config is missing', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['legacy-resource-1'] = {
id: 'legacy-resource-1',
type: 'unknown_block',
name: 'Legacy Resource',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
knowledgeBaseIds: {
id: 'knowledgeBaseIds',
type: 'knowledge-base-selector',
value: 'kb-legacy',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'kb',
mode: 'all',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.blockId === 'legacy-resource-1')
expect(matches.some((match) => match.kind === 'text')).toBe(false)
expect(matches).toEqual([
expect.objectContaining({
kind: 'knowledge-base',
rawValue: 'kb-legacy',
}),
])
})
it('indexes workspace file uploads as resource matches by visible file name', () => {
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: 'violet_polaris.csv',
path: '/workspace/ws-1/violet-key',
key: 'violet-key',
size: 42,
type: 'text/csv',
},
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'violet',
mode: 'all',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'file', title: 'File', type: 'file-upload' }],
},
},
}).filter((match) => match.blockId === 'file-upload-1')
expect(matches.some((match) => match.kind === 'text')).toBe(false)
expect(matches).toEqual([
expect.objectContaining({
kind: 'file',
rawValue: 'violet-key',
searchText: 'violet_polaris.csv',
}),
])
})
it('attaches selector context for workflow selectors', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['workflow-1'] = {
id: 'workflow-1',
type: 'workflow',
name: 'Workflow Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
workflowId: {
id: 'workflowId',
type: 'workflow-selector',
value: 'child-workflow-1',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'child',
mode: 'resource',
workspaceId: 'workspace-1',
workflowId: 'current-workflow-1',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
workflow: {
subBlocks: [
{
id: 'workflowId',
title: 'Select Workflow',
type: 'workflow-selector',
selectorKey: 'sim.workflows',
},
],
},
},
}).filter((match) => match.blockId === 'workflow-1')
expect(matches).toEqual([
expect.objectContaining({
kind: 'workflow',
rawValue: 'child-workflow-1',
resource: expect.objectContaining({
selectorKey: 'sim.workflows',
selectorContext: expect.objectContaining({
workspaceId: 'workspace-1',
workflowId: 'current-workflow-1',
excludeWorkflowId: 'current-workflow-1',
}),
}),
}),
])
})
it('builds selector context from declared dependencies instead of sibling selectors', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['spreadsheet-1'] = {
id: 'spreadsheet-1',
type: 'custom',
name: 'Spreadsheet Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
spreadsheetSelector: {
id: 'spreadsheetSelector',
type: 'file-selector',
value: 'spreadsheet-1',
},
sheetSelector: {
id: 'sheetSelector',
type: 'sheet-selector',
value: 'sheet-1',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
mode: 'resource',
includeResourceMatchesWithoutQuery: true,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'spreadsheetSelector',
title: 'Spreadsheet',
type: 'file-selector',
canonicalParamId: 'spreadsheetId',
selectorKey: 'google.drive',
},
{
id: 'sheetSelector',
title: 'Sheet',
type: 'sheet-selector',
selectorKey: 'google.sheets',
dependsOn: ['spreadsheetSelector'],
},
],
},
},
}).filter((match) => match.blockId === 'spreadsheet-1')
const spreadsheetMatch = matches.find((match) => match.subBlockId === 'spreadsheetSelector')
const sheetMatch = matches.find((match) => match.subBlockId === 'sheetSelector')
expect(spreadsheetMatch?.resource?.selectorContext).not.toHaveProperty('spreadsheetId')
expect(sheetMatch?.resource?.selectorContext).toEqual(
expect.objectContaining({
spreadsheetId: 'spreadsheet-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
})
)
})
it('captures selector context for selector-backed resources', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
mode: 'resource',
includeResourceMatchesWithoutQuery: true,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'selector-resource',
rawValue: 'INBOX',
resource: expect.objectContaining({
selectorKey: 'gmail.labels',
selectorContext: expect.objectContaining({
oauthCredential: 'gmail-credential-old',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
}),
}),
}),
expect.objectContaining({
kind: 'knowledge-document',
rawValue: 'doc-old',
resource: expect.objectContaining({
selectorKey: 'knowledge.documents',
selectorContext: expect.objectContaining({
knowledgeBaseId: 'kb-old,kb-second',
}),
}),
}),
])
)
})
it('marks snapshot view matches as searchable but not editable', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'email',
mode: 'text',
isSnapshotView: true,
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(matches.every((match) => !match.editable)).toBe(true)
expect(matches.every((match) => match.reason === 'Snapshot view is readonly')).toBe(true)
})
it('marks readonly workflow matches as searchable but not editable', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'email',
mode: 'text',
isReadOnly: true,
readonlyReason: 'Workflow is locked',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
expect(matches.every((match) => !match.editable)).toBe(true)
expect(matches.every((match) => match.reason === 'Workflow is locked')).toBe(true)
})
})