/** * @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: '', }, }, } 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: '', }), ]) ) }) 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) }) })