mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(tools): added hunter.io tools/block, added default values of first option in dropdowns to avoid operation selector issue, added descriptions & param validation & updated docs (#825)
* feat(tools): added hunter.io tools/block, added default values of first option in dropdowns to avoid operation selector issue * fix * added description for all outputs, fixed param validation for tools * cleanup * add dual validation, once during serialization and once during execution * improvement(docs): add base exec charge info to docs (#826) * improvement(doc-tags-subblock): use table for doc tags subblock in create_document tool for KB (#827) * improvement(doc-tags-subblock): use table for doc tags create doc tool in KB block * enforce max tags * remove red warning text * fix(bugs): fixed rb2b csp, fixed overly-verbose logs, fixed x URLs (#828) Co-authored-by: waleedlatif <waleedlatif@waleedlatifs-MacBook-Pro.local> * fixed serialization errors to appear like execution errors, also fixed contrast on cmdk modal * fixed required for tools, added tag dropdown for kb tags * fix remaining tools with required fields * update utils * update docs * fix kb tags * fix types for exa * lint * updated contributing guide + pr template * Test pre-commit hook with linting * Test pre-commit hook again * remove test files * fixed wealthbox tool * update telemetry endpoints --------- Co-authored-by: waleedlatif <waleedlatif@waleedlatifs-MacBook-Pro.local> Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
* converting between workflow state (blocks, edges, loops) and serialized format
|
||||
* used by the executor.
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import {
|
||||
createAgentWithToolsWorkflowState,
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { Serializer } from '@/serializer/index'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
// Mock getBlock function
|
||||
vi.mock('@/blocks', () => ({
|
||||
getBlock: (type: string) => {
|
||||
// Mock block configurations for different block types
|
||||
@@ -120,12 +119,76 @@ vi.mock('@/blocks', () => ({
|
||||
],
|
||||
inputs: {},
|
||||
},
|
||||
jina: {
|
||||
name: 'Jina',
|
||||
description: 'Convert website content into text',
|
||||
category: 'tools',
|
||||
bgColor: '#333333',
|
||||
tools: {
|
||||
access: ['jina_read_url'],
|
||||
config: {
|
||||
tool: () => 'jina_read_url',
|
||||
},
|
||||
},
|
||||
subBlocks: [
|
||||
{ id: 'url', type: 'short-input', title: 'URL', required: true },
|
||||
{ id: 'apiKey', type: 'short-input', title: 'API Key', required: true },
|
||||
],
|
||||
inputs: {
|
||||
url: { type: 'string' },
|
||||
apiKey: { type: 'string' },
|
||||
},
|
||||
},
|
||||
reddit: {
|
||||
name: 'Reddit',
|
||||
description: 'Access Reddit data and content',
|
||||
category: 'tools',
|
||||
bgColor: '#FF5700',
|
||||
tools: {
|
||||
access: ['reddit_get_posts', 'reddit_get_comments'],
|
||||
config: {
|
||||
tool: () => 'reddit_get_posts',
|
||||
},
|
||||
},
|
||||
subBlocks: [
|
||||
{ id: 'operation', type: 'dropdown', title: 'Operation', required: true },
|
||||
{ id: 'credential', type: 'oauth-input', title: 'Reddit Account', required: true },
|
||||
{ id: 'subreddit', type: 'short-input', title: 'Subreddit', required: true },
|
||||
],
|
||||
inputs: {
|
||||
operation: { type: 'string' },
|
||||
credential: { type: 'string' },
|
||||
subreddit: { type: 'string' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return mockConfigs[type] || null
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock getTool function
|
||||
vi.mock('@/tools/utils', () => ({
|
||||
getTool: (toolId: string) => {
|
||||
// Mock tool configurations for testing
|
||||
const mockTools: Record<string, any> = {
|
||||
jina_read_url: {
|
||||
params: {
|
||||
url: { visibility: 'user-or-llm', required: true },
|
||||
apiKey: { visibility: 'user-only', required: true },
|
||||
},
|
||||
},
|
||||
reddit_get_posts: {
|
||||
params: {
|
||||
subreddit: { visibility: 'user-or-llm', required: true },
|
||||
credential: { visibility: 'user-only', required: true },
|
||||
},
|
||||
},
|
||||
}
|
||||
return mockTools[toolId] || null
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -141,7 +204,7 @@ describe('Serializer', () => {
|
||||
* Serialization tests
|
||||
*/
|
||||
describe('serializeWorkflow', () => {
|
||||
test('should serialize a minimal workflow correctly', () => {
|
||||
it.concurrent('should serialize a minimal workflow correctly', () => {
|
||||
const { blocks, edges, loops } = createMinimalWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -170,7 +233,7 @@ describe('Serializer', () => {
|
||||
expect(serialized.connections[0].target).toBe('agent1')
|
||||
})
|
||||
|
||||
test('should serialize a conditional workflow correctly', () => {
|
||||
it.concurrent('should serialize a conditional workflow correctly', () => {
|
||||
const { blocks, edges, loops } = createConditionalWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -202,7 +265,7 @@ describe('Serializer', () => {
|
||||
expect(falsePathConnection?.target).toBe('agent2')
|
||||
})
|
||||
|
||||
test('should serialize a workflow with loops correctly', () => {
|
||||
it.concurrent('should serialize a workflow with loops correctly', () => {
|
||||
const { blocks, edges, loops } = createLoopWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -223,7 +286,7 @@ describe('Serializer', () => {
|
||||
expect(loopBackConnection?.sourceHandle).toBe('condition-true')
|
||||
})
|
||||
|
||||
test('should serialize a complex workflow with multiple block types', () => {
|
||||
it.concurrent('should serialize a complex workflow with multiple block types', () => {
|
||||
const { blocks, edges, loops } = createComplexWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -260,7 +323,7 @@ describe('Serializer', () => {
|
||||
expect(agentBlock?.outputs.responseFormat).toBeDefined()
|
||||
})
|
||||
|
||||
test('should serialize agent block with custom tools correctly', () => {
|
||||
it.concurrent('should serialize agent block with custom tools correctly', () => {
|
||||
const { blocks, edges, loops } = createAgentWithToolsWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -292,7 +355,7 @@ describe('Serializer', () => {
|
||||
expect(functionTool.name).toBe('calculator')
|
||||
})
|
||||
|
||||
test('should handle invalid block types gracefully', () => {
|
||||
it.concurrent('should handle invalid block types gracefully', () => {
|
||||
const { blocks, edges, loops } = createInvalidWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -307,7 +370,7 @@ describe('Serializer', () => {
|
||||
* Deserialization tests
|
||||
*/
|
||||
describe('deserializeWorkflow', () => {
|
||||
test('should deserialize a serialized workflow correctly', () => {
|
||||
it.concurrent('should deserialize a serialized workflow correctly', () => {
|
||||
const { blocks, edges, loops } = createMinimalWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -341,7 +404,7 @@ describe('Serializer', () => {
|
||||
expect(deserialized.edges[0].target).toBe('agent1')
|
||||
})
|
||||
|
||||
test('should deserialize a complex workflow with all block types', () => {
|
||||
it.concurrent('should deserialize a complex workflow with all block types', () => {
|
||||
const { blocks, edges, loops } = createComplexWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -379,7 +442,7 @@ describe('Serializer', () => {
|
||||
expect(agentBlock.subBlocks.provider.value).toBe('openai')
|
||||
})
|
||||
|
||||
test('should handle serialized workflow with invalid block metadata', () => {
|
||||
it.concurrent('should handle serialized workflow with invalid block metadata', () => {
|
||||
const invalidWorkflow = createInvalidSerializedWorkflow() as SerializedWorkflow
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -389,7 +452,7 @@ describe('Serializer', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('should handle serialized workflow with missing metadata', () => {
|
||||
it.concurrent('should handle serialized workflow with missing metadata', () => {
|
||||
const invalidWorkflow = createMissingMetadataWorkflow() as SerializedWorkflow
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -402,7 +465,7 @@ describe('Serializer', () => {
|
||||
* End-to-end serialization/deserialization tests
|
||||
*/
|
||||
describe('round-trip serialization', () => {
|
||||
test('should preserve all data through serialization and deserialization', () => {
|
||||
it.concurrent('should preserve all data through serialization and deserialization', () => {
|
||||
const { blocks, edges, loops } = createComplexWorkflowState()
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -446,4 +509,211 @@ describe('Serializer', () => {
|
||||
expect(reserialized.loops).toEqual(serialized.loops)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation during serialization', () => {
|
||||
it.concurrent('should throw error for missing user-only required fields', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
// Create a block state with a missing user-only required field (API key)
|
||||
const blockWithMissingUserOnlyField: any = {
|
||||
id: 'test-block',
|
||||
type: 'jina',
|
||||
name: 'Test Jina Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
url: { value: 'https://example.com' },
|
||||
apiKey: { value: null }, // Missing user-only required field
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'test-block': blockWithMissingUserOnlyField },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).toThrow('Test Jina Block is missing required fields: API Key')
|
||||
})
|
||||
|
||||
it.concurrent('should not throw error when all user-only required fields are present', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const blockWithAllUserOnlyFields: any = {
|
||||
id: 'test-block',
|
||||
type: 'jina',
|
||||
name: 'Test Jina Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
url: { value: 'https://example.com' },
|
||||
apiKey: { value: 'test-api-key' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'test-block': blockWithAllUserOnlyFields },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should not validate user-or-llm fields during serialization', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
// Create a Reddit block with missing subreddit (user-or-llm field)
|
||||
const blockWithMissingUserOrLlmField: any = {
|
||||
id: 'test-block',
|
||||
type: 'reddit',
|
||||
name: 'Test Reddit Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
operation: { value: 'get_posts' },
|
||||
credential: { value: 'test-credential' },
|
||||
subreddit: { value: null }, // Missing user-or-llm field - should NOT be validated here
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Should NOT throw because subreddit is user-or-llm, not user-only
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'test-block': blockWithMissingUserOrLlmField },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should not validate when validateRequired is false', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const blockWithMissingField: any = {
|
||||
id: 'test-block',
|
||||
type: 'jina',
|
||||
name: 'Test Jina Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
url: { value: 'https://example.com' },
|
||||
apiKey: { value: null }, // Missing required field
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Should NOT throw when validation is disabled (default behavior)
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow({ 'test-block': blockWithMissingField }, [], {})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should validate multiple user-only fields and report all missing', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const blockWithMultipleMissing: any = {
|
||||
id: 'test-block',
|
||||
type: 'jina',
|
||||
name: 'Test Jina Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
url: { value: null }, // Missing user-or-llm field (should NOT be validated)
|
||||
apiKey: { value: null }, // Missing user-only field (should be validated)
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'test-block': blockWithMultipleMissing },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).toThrow('Test Jina Block is missing required fields: API Key')
|
||||
})
|
||||
|
||||
it.concurrent('should handle blocks with no tool configuration gracefully', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const blockWithNoTools: any = {
|
||||
id: 'test-block',
|
||||
type: 'condition', // Condition blocks have different tool setup
|
||||
name: 'Test Condition Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
condition: { value: null }, // Missing required field but not user-only
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Should NOT throw because condition blocks don't have user-only params
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow({ 'test-block': blockWithNoTools }, [], {}, undefined, true)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty string values as missing', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const blockWithEmptyString: any = {
|
||||
id: 'test-block',
|
||||
type: 'jina',
|
||||
name: 'Test Jina Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
url: { value: 'https://example.com' },
|
||||
apiKey: { value: '' }, // Empty string should be treated as missing
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'test-block': blockWithEmptyString },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).toThrow('Test Jina Block is missing required fields: API Key')
|
||||
})
|
||||
|
||||
it.concurrent('should only validate user-only fields, not user-or-llm fields', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
// Block with both user-only and user-or-llm missing fields
|
||||
const mixedBlock: any = {
|
||||
id: 'test-block',
|
||||
type: 'reddit',
|
||||
name: 'Test Reddit Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
operation: { value: 'get_posts' },
|
||||
credential: { value: null }, // user-only - should be validated
|
||||
subreddit: { value: null }, // user-or-llm - should NOT be validated
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow({ 'test-block': mixedBlock }, [], {}, undefined, true)
|
||||
}).toThrow('Test Reddit Block is missing required fields: Reddit Account')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
const logger = createLogger('Serializer')
|
||||
|
||||
@@ -11,11 +12,12 @@ export class Serializer {
|
||||
blocks: Record<string, BlockState>,
|
||||
edges: Edge[],
|
||||
loops: Record<string, Loop>,
|
||||
parallels?: Record<string, Parallel>
|
||||
parallels?: Record<string, Parallel>,
|
||||
validateRequired = false
|
||||
): SerializedWorkflow {
|
||||
return {
|
||||
version: '1.0',
|
||||
blocks: Object.values(blocks).map((block) => this.serializeBlock(block)),
|
||||
blocks: Object.values(blocks).map((block) => this.serializeBlock(block, validateRequired)),
|
||||
connections: edges.map((edge) => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
@@ -27,7 +29,7 @@ export class Serializer {
|
||||
}
|
||||
}
|
||||
|
||||
private serializeBlock(block: BlockState): SerializedBlock {
|
||||
private serializeBlock(block: BlockState, validateRequired = false): SerializedBlock {
|
||||
// Special handling for subflow blocks (loops, parallels, etc.)
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
return {
|
||||
@@ -55,8 +57,14 @@ export class Serializer {
|
||||
throw new Error(`Invalid block type: ${block.type}`)
|
||||
}
|
||||
|
||||
// Check if this is an agent block with custom tools
|
||||
// Extract parameters from UI state
|
||||
const params = this.extractParams(block)
|
||||
|
||||
// Validate required fields that only users can provide (before execution starts)
|
||||
if (validateRequired) {
|
||||
this.validateRequiredFieldsBeforeExecution(block, blockConfig, params)
|
||||
}
|
||||
|
||||
let toolId = ''
|
||||
|
||||
if (block.type === 'agent' && params.tools) {
|
||||
@@ -208,6 +216,62 @@ export class Serializer {
|
||||
return params
|
||||
}
|
||||
|
||||
private validateRequiredFieldsBeforeExecution(
|
||||
block: BlockState,
|
||||
blockConfig: any,
|
||||
params: Record<string, any>
|
||||
) {
|
||||
// Validate user-only required fields before execution starts
|
||||
// This catches missing API keys, credentials, and other user-provided values early
|
||||
// Fields that are user-or-llm will be validated later after parameter merging
|
||||
|
||||
// Get the tool configuration to check parameter visibility
|
||||
const toolAccess = blockConfig.tools?.access
|
||||
if (!toolAccess || toolAccess.length === 0) {
|
||||
return // No tools to validate against
|
||||
}
|
||||
|
||||
// Determine the current tool ID using the same logic as the serializer
|
||||
let currentToolId = ''
|
||||
try {
|
||||
currentToolId = blockConfig.tools.config?.tool
|
||||
? blockConfig.tools.config.tool(params)
|
||||
: blockConfig.tools.access[0]
|
||||
} catch (error) {
|
||||
logger.warn('Tool selection failed during validation, using default:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
currentToolId = blockConfig.tools.access[0]
|
||||
}
|
||||
|
||||
// Get the specific tool to validate against
|
||||
const currentTool = getTool(currentToolId)
|
||||
if (!currentTool) {
|
||||
return // Tool not found, skip validation
|
||||
}
|
||||
|
||||
// Check required user-only parameters for the current tool
|
||||
const missingFields: string[] = []
|
||||
|
||||
// Iterate through the tool's parameters, not the block's subBlocks
|
||||
Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]) => {
|
||||
if (paramConfig.required && paramConfig.visibility === 'user-only') {
|
||||
const fieldValue = params[paramId]
|
||||
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
|
||||
// Find the corresponding subBlock to get the display title
|
||||
const subBlockConfig = blockConfig.subBlocks?.find((sb: any) => sb.id === paramId)
|
||||
const displayName = subBlockConfig?.title || paramId
|
||||
missingFields.push(displayName)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const blockName = block.name || blockConfig.name || 'Block'
|
||||
throw new Error(`${blockName} is missing required fields: ${missingFields.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
deserializeWorkflow(workflow: SerializedWorkflow): {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
|
||||
372
apps/sim/serializer/tests/dual-validation.test.ts
Normal file
372
apps/sim/serializer/tests/dual-validation.test.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Integration Tests for Validation Architecture
|
||||
*
|
||||
* These tests verify the complete validation flow:
|
||||
* 1. Early validation (serialization) - user-only required fields
|
||||
* 2. Late validation (tool execution) - user-or-llm required fields
|
||||
*/
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
import { Serializer } from '@/serializer/index'
|
||||
import { validateRequiredParametersAfterMerge } from '@/tools/utils'
|
||||
|
||||
vi.mock('@/blocks', () => ({
|
||||
getBlock: (type: string) => {
|
||||
const mockConfigs: Record<string, any> = {
|
||||
jina: {
|
||||
name: 'Jina',
|
||||
description: 'Convert website content into text',
|
||||
category: 'tools',
|
||||
bgColor: '#333333',
|
||||
tools: {
|
||||
access: ['jina_read_url'],
|
||||
},
|
||||
subBlocks: [
|
||||
{ id: 'url', type: 'short-input', title: 'URL', required: true },
|
||||
{ id: 'apiKey', type: 'short-input', title: 'API Key', required: true },
|
||||
],
|
||||
inputs: {
|
||||
url: { type: 'string' },
|
||||
apiKey: { type: 'string' },
|
||||
},
|
||||
},
|
||||
reddit: {
|
||||
name: 'Reddit',
|
||||
description: 'Access Reddit data',
|
||||
category: 'tools',
|
||||
bgColor: '#FF5700',
|
||||
tools: {
|
||||
access: ['reddit_get_posts'],
|
||||
},
|
||||
subBlocks: [
|
||||
{ id: 'operation', type: 'dropdown', title: 'Operation', required: true },
|
||||
{ id: 'credential', type: 'oauth-input', title: 'Reddit Account', required: true },
|
||||
{ id: 'subreddit', type: 'short-input', title: 'Subreddit', required: true },
|
||||
],
|
||||
inputs: {
|
||||
operation: { type: 'string' },
|
||||
credential: { type: 'string' },
|
||||
subreddit: { type: 'string' },
|
||||
},
|
||||
},
|
||||
}
|
||||
return mockConfigs[type] || null
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/utils', async () => {
|
||||
const actual = await vi.importActual('@/tools/utils')
|
||||
return {
|
||||
...actual,
|
||||
getTool: (toolId: string) => {
|
||||
const mockTools: Record<string, any> = {
|
||||
jina_read_url: {
|
||||
name: 'Jina Reader',
|
||||
params: {
|
||||
url: {
|
||||
type: 'string',
|
||||
visibility: 'user-or-llm',
|
||||
required: true,
|
||||
description: 'URL to extract content from',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
visibility: 'user-only',
|
||||
required: true,
|
||||
description: 'Your Jina API key',
|
||||
},
|
||||
},
|
||||
},
|
||||
reddit_get_posts: {
|
||||
name: 'Reddit Posts',
|
||||
params: {
|
||||
subreddit: {
|
||||
type: 'string',
|
||||
visibility: 'user-or-llm',
|
||||
required: true,
|
||||
description: 'Subreddit name',
|
||||
},
|
||||
credential: {
|
||||
type: 'string',
|
||||
visibility: 'user-only',
|
||||
required: true,
|
||||
description: 'Reddit credentials',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return mockTools[toolId] || null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('Validation Integration Tests', () => {
|
||||
it.concurrent('early validation should catch missing user-only fields', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
// Block missing user-only field (API key)
|
||||
const blockWithMissingUserOnlyField: any = {
|
||||
id: 'jina-block',
|
||||
type: 'jina',
|
||||
name: 'Jina Content Extractor',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
url: { value: 'https://example.com' }, // Present
|
||||
apiKey: { value: null }, // Missing user-only field
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Should fail at serialization (early validation)
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'jina-block': blockWithMissingUserOnlyField },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).toThrow('Jina Content Extractor is missing required fields: API Key')
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'early validation should allow missing user-or-llm fields (LLM can provide later)',
|
||||
() => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
// Block missing user-or-llm field (URL) but has user-only field (API key)
|
||||
const blockWithMissingUserOrLlmField: any = {
|
||||
id: 'jina-block',
|
||||
type: 'jina',
|
||||
name: 'Jina Content Extractor',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
url: { value: null }, // Missing user-or-llm field (LLM can provide)
|
||||
apiKey: { value: 'test-api-key' }, // Present user-only field
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Should pass serialization (early validation doesn't check user-or-llm fields)
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'jina-block': blockWithMissingUserOrLlmField },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).not.toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'late validation should catch missing user-or-llm fields after parameter merge',
|
||||
() => {
|
||||
// Simulate parameters after user + LLM merge
|
||||
const mergedParams = {
|
||||
url: null, // Missing user-or-llm field
|
||||
apiKey: 'test-api-key', // Present user-only field
|
||||
}
|
||||
|
||||
// Should fail at tool validation (late validation)
|
||||
expect(() => {
|
||||
validateRequiredParametersAfterMerge(
|
||||
'jina_read_url',
|
||||
{
|
||||
name: 'Jina Reader',
|
||||
params: {
|
||||
url: {
|
||||
type: 'string',
|
||||
visibility: 'user-or-llm',
|
||||
required: true,
|
||||
description: 'URL to extract content from',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
visibility: 'user-only',
|
||||
required: true,
|
||||
description: 'Your Jina API key',
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
mergedParams
|
||||
)
|
||||
}).toThrow('"Url" is required for Jina Reader')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('late validation should NOT validate user-only fields (validated earlier)', () => {
|
||||
// Simulate parameters after user + LLM merge - missing user-only field
|
||||
const mergedParams = {
|
||||
url: 'https://example.com', // Present user-or-llm field
|
||||
apiKey: null, // Missing user-only field (but shouldn't be checked here)
|
||||
}
|
||||
|
||||
// Should pass tool validation (late validation doesn't check user-only fields)
|
||||
expect(() => {
|
||||
validateRequiredParametersAfterMerge(
|
||||
'jina_read_url',
|
||||
{
|
||||
name: 'Jina Reader',
|
||||
params: {
|
||||
url: {
|
||||
type: 'string',
|
||||
visibility: 'user-or-llm',
|
||||
required: true,
|
||||
description: 'URL to extract content from',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
visibility: 'user-only',
|
||||
required: true,
|
||||
description: 'Your Jina API key',
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
mergedParams
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('complete validation flow: both layers working together', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
// Scenario 1: Missing user-only field - should fail at serialization
|
||||
const blockMissingUserOnly: any = {
|
||||
id: 'reddit-block',
|
||||
type: 'reddit',
|
||||
name: 'Reddit Posts',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
operation: { value: 'get_posts' },
|
||||
credential: { value: null }, // Missing user-only
|
||||
subreddit: { value: 'programming' }, // Present user-or-llm
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'reddit-block': blockMissingUserOnly },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).toThrow('Reddit Posts is missing required fields: Reddit Account')
|
||||
|
||||
// Scenario 2: Has user-only fields but missing user-or-llm - should pass serialization
|
||||
const blockMissingUserOrLlm: any = {
|
||||
id: 'reddit-block',
|
||||
type: 'reddit',
|
||||
name: 'Reddit Posts',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
operation: { value: 'get_posts' },
|
||||
credential: { value: 'reddit-token' }, // Present user-only
|
||||
subreddit: { value: null }, // Missing user-or-llm
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Should pass serialization
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow(
|
||||
{ 'reddit-block': blockMissingUserOrLlm },
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
}).not.toThrow()
|
||||
|
||||
// But should fail at tool validation
|
||||
const mergedParams = {
|
||||
subreddit: null, // Missing user-or-llm field
|
||||
credential: 'reddit-token', // Present user-only field
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
validateRequiredParametersAfterMerge(
|
||||
'reddit_get_posts',
|
||||
{
|
||||
name: 'Reddit Posts',
|
||||
params: {
|
||||
subreddit: {
|
||||
type: 'string',
|
||||
visibility: 'user-or-llm',
|
||||
required: true,
|
||||
description: 'Subreddit name',
|
||||
},
|
||||
credential: {
|
||||
type: 'string',
|
||||
visibility: 'user-only',
|
||||
required: true,
|
||||
description: 'Reddit credentials',
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
mergedParams
|
||||
)
|
||||
}).toThrow('"Subreddit" is required for Reddit Posts')
|
||||
})
|
||||
|
||||
it.concurrent('complete success: all required fields provided correctly', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
// Block with all required fields present
|
||||
const completeBlock: any = {
|
||||
id: 'jina-block',
|
||||
type: 'jina',
|
||||
name: 'Jina Content Extractor',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
url: { value: 'https://example.com' }, // Present user-or-llm
|
||||
apiKey: { value: 'test-api-key' }, // Present user-only
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Should pass serialization (early validation)
|
||||
expect(() => {
|
||||
serializer.serializeWorkflow({ 'jina-block': completeBlock }, [], {}, undefined, true)
|
||||
}).not.toThrow()
|
||||
|
||||
// Should pass tool validation (late validation)
|
||||
const completeParams = {
|
||||
url: 'https://example.com',
|
||||
apiKey: 'test-api-key',
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
validateRequiredParametersAfterMerge(
|
||||
'jina_read_url',
|
||||
{
|
||||
name: 'Jina Reader',
|
||||
params: {
|
||||
url: {
|
||||
type: 'string',
|
||||
visibility: 'user-or-llm',
|
||||
required: true,
|
||||
description: 'URL to extract content from',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
visibility: 'user-only',
|
||||
required: true,
|
||||
description: 'Your Jina API key',
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
completeParams
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user