import { describe, expect, it, vi } from 'vitest' vi.unmock('@/blocks/registry') import { generateRouterPrompt } from '@/blocks/blocks/router' import { getAllBlocks, getAllBlockTypes, getBlock, getBlockByToolName, getBlocksByCategory, isValidBlockType, } from '@/blocks/registry' import { AuthMode } from '@/blocks/types' describe.concurrent('Blocks Module', () => { describe('Registry', () => { it('should have a non-empty registry of blocks', () => { expect(getAllBlocks().length).toBeGreaterThan(0) }) it('should have all blocks with required properties', () => { const blocks = getAllBlocks() for (const block of blocks) { expect(block.type).toBeDefined() expect(typeof block.type).toBe('string') expect(block.name).toBeDefined() expect(typeof block.name).toBe('string') expect(block.description).toBeDefined() expect(typeof block.description).toBe('string') expect(block.category).toBeDefined() expect(['blocks', 'tools', 'triggers']).toContain(block.category) expect(block.bgColor).toBeDefined() expect(typeof block.bgColor).toBe('string') expect(block.bgColor.length).toBeGreaterThan(0) expect(block.icon).toBeDefined() expect(typeof block.icon).toBe('function') expect(block.tools).toBeDefined() expect(block.tools.access).toBeDefined() expect(Array.isArray(block.tools.access)).toBe(true) expect(block.inputs).toBeDefined() expect(typeof block.inputs).toBe('object') expect(block.outputs).toBeDefined() expect(typeof block.outputs).toBe('object') expect(block.subBlocks).toBeDefined() expect(Array.isArray(block.subBlocks)).toBe(true) } }) it('should have unique block types', () => { const types = getAllBlockTypes() const uniqueTypes = new Set(types) expect(types.length).toBe(uniqueTypes.size) }) }) describe('getBlock', () => { it('should return a block by type', () => { const block = getBlock('function') expect(block).toBeDefined() expect(block?.type).toBe('function') expect(block?.name).toBe('Function') }) it('should return undefined for non-existent block type', () => { const block = getBlock('non-existent-block') expect(block).toBeUndefined() }) it('should normalize hyphens to underscores', () => { const block = getBlock('microsoft-teams') expect(block).toBeDefined() expect(block?.type).toBe('microsoft_teams') }) }) describe('getBlockByToolName', () => { it('should find a block by tool name', () => { const block = getBlockByToolName('function_execute') expect(block).toBeDefined() expect(block?.type).toBe('function') }) it('should find a block with http_request tool', () => { const block = getBlockByToolName('http_request') expect(block).toBeDefined() expect(block?.type).toBe('api') }) it('should return undefined for non-existent tool name', () => { const block = getBlockByToolName('non_existent_tool') expect(block).toBeUndefined() }) }) describe('File block', () => { it('should keep v3 read and get routed to the legacy tools', () => { const block = getBlock('file_v3') expect(block).toBeDefined() expect(block?.hideFromToolbar).toBe(true) expect(block?.subBlocks[0].options?.map((option) => option.id)).toEqual([ 'file_parser_v3', 'file_get', 'file_write', 'file_append', ]) expect(block?.tools.config?.tool({ operation: 'file_parser_v3' })).toBe('file_parser_v3') expect(block?.tools.config?.tool({ operation: 'file_get' })).toBe('file_get') }) it('should keep v4 read and fetch routed to the expected tools', () => { const block = getBlock('file_v4') expect(block).toBeDefined() expect(block?.hideFromToolbar).toBe(true) expect(block?.subBlocks[0].options?.map((option) => option.id)).toEqual([ 'file_read', 'file_fetch', 'file_write', 'file_append', ]) expect(block?.subBlocks.find((subBlock) => subBlock.id === 'readFile')?.multiple).toBe(true) expect(block?.tools.config?.tool({ operation: 'file_read' })).toBe('file_read') expect(block?.tools.config?.tool({ operation: 'file_fetch' })).toBe('file_fetch') expect( block?.tools.config?.params?.({ operation: 'file_read', readFileInput: '["file-1","file-2"]', _context: { workspaceId: 'workspace-1' }, }) ).toEqual({ fileId: ['file-1', 'file-2'], workspaceId: 'workspace-1', }) expect( block?.tools.config?.params?.({ operation: 'file_read', readFileInput: [ { key: 'workspace/workspace-1/example.md', name: 'example.md', path: '/api/files/serve/workspace%2Fworkspace-1%2Fexample.md?context=workspace', size: 123, type: 'text/markdown', }, ], _context: { workspaceId: 'workspace-1' }, }) ).toEqual({ fileInput: [ { key: 'workspace/workspace-1/example.md', name: 'example.md', path: '/api/files/serve/workspace%2Fworkspace-1%2Fexample.md?context=workspace', size: 123, type: 'text/markdown', }, ], workspaceId: 'workspace-1', }) }) it('should expose v5 with read (files only) and a get content operation', () => { const block = getBlock('file_v5') expect(block).toBeDefined() expect(block?.hideFromToolbar).toBe(false) expect(block?.subBlocks[0].options?.map((option) => option.id)).toEqual([ 'file_read', 'file_get_content', 'file_fetch', 'file_write', 'file_append', 'file_compress', 'file_decompress', 'file_manage_sharing', ]) expect(block?.tools.config?.tool({ operation: 'file_compress' })).toBe('file_compress') expect(block?.tools.config?.tool({ operation: 'file_decompress' })).toBe('file_decompress') expect(block?.subBlocks.find((subBlock) => subBlock.id === 'readFile')?.multiple).toBe(true) expect(block?.tools.config?.tool({ operation: 'file_read' })).toBe('file_read') expect(block?.tools.config?.tool({ operation: 'file_get_content' })).toBe('file_get_content') expect(block?.tools.config?.tool({ operation: 'file_fetch' })).toBe('file_fetch') // Read exposes files only; the redundant single-file output is gone expect(block?.outputs.files).toBeDefined() expect(block?.outputs.file).toBeUndefined() // Get content exposes a contents array expect(block?.outputs.contents).toBeDefined() // Get content resolves canonical IDs expect( block?.tools.config?.params?.({ operation: 'file_get_content', getContentInput: '["file-1","file-2"]', _context: { workspaceId: 'workspace-1' }, }) ).toEqual({ fileId: ['file-1', 'file-2'], workspaceId: 'workspace-1', }) // Get content resolves selected file objects expect( block?.tools.config?.params?.({ operation: 'file_get_content', getContentInput: [ { key: 'workspace/workspace-1/example.md', name: 'example.md', path: '/api/files/serve/workspace%2Fworkspace-1%2Fexample.md?context=workspace', size: 123, type: 'text/markdown', }, ], _context: { workspaceId: 'workspace-1' }, }) ).toEqual({ fileInput: [ { key: 'workspace/workspace-1/example.md', name: 'example.md', path: '/api/files/serve/workspace%2Fworkspace-1%2Fexample.md?context=workspace', size: 123, type: 'text/markdown', }, ], workspaceId: 'workspace-1', }) }) }) describe('Agent block', () => { it('should expose canonical file attachments and normalize file params', () => { const block = getBlock('agent') expect(block).toBeDefined() const uploadSubBlock = block?.subBlocks.find((subBlock) => subBlock.id === 'attachmentFiles') const advancedSubBlock = block?.subBlocks.find((subBlock) => subBlock.id === 'files') expect(uploadSubBlock?.type).toBe('file-upload') expect(uploadSubBlock?.canonicalParamId).toBe('files') expect(uploadSubBlock?.multiple).toBe(true) expect(advancedSubBlock?.canonicalParamId).toBe('files') expect(block?.inputs.files).toEqual({ type: 'array', description: 'Files to include with the latest user message', }) expect( block?.tools.config?.params?.({ model: 'gpt-4o', files: '[{"id":"file-1","key":"workspace/ws-1/example.png","name":"example.png","url":"/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace","size":123,"type":"image/png"}]', }) ).toMatchObject({ files: [ { id: 'file-1', key: 'workspace/ws-1/example.png', name: 'example.png', type: 'image/png', }, ], }) }) }) describe('getBlocksByCategory', () => { it('should return blocks in the "blocks" category', () => { const blocks = getBlocksByCategory('blocks') expect(blocks.length).toBeGreaterThan(0) for (const block of blocks) { expect(block.category).toBe('blocks') } }) it('should return blocks in the "tools" category', () => { const blocks = getBlocksByCategory('tools') expect(blocks.length).toBeGreaterThan(0) for (const block of blocks) { expect(block.category).toBe('tools') } }) it('should return blocks in the "triggers" category', () => { const blocks = getBlocksByCategory('triggers') expect(blocks.length).toBeGreaterThan(0) for (const block of blocks) { expect(block.category).toBe('triggers') } }) }) describe('getAllBlockTypes', () => { it('should return an array of block types', () => { const types = getAllBlockTypes() expect(Array.isArray(types)).toBe(true) expect(types.length).toBeGreaterThan(0) for (const type of types) { expect(typeof type).toBe('string') } }) }) describe('isValidBlockType', () => { it('should return true for valid block types', () => { expect(isValidBlockType('function')).toBe(true) expect(isValidBlockType('agent')).toBe(true) expect(isValidBlockType('condition')).toBe(true) expect(isValidBlockType('api')).toBe(true) }) it('should return false for invalid block types', () => { expect(isValidBlockType('invalid-block')).toBe(false) expect(isValidBlockType('')).toBe(false) }) it('should handle hyphenated versions of underscored types', () => { expect(isValidBlockType('microsoft-teams')).toBe(true) expect(isValidBlockType('google-calendar')).toBe(true) }) }) describe('Block Definitions', () => { describe('FunctionBlock', () => { const block = getBlock('function') it('should have correct metadata', () => { expect(block?.type).toBe('function') expect(block?.name).toBe('Function') expect(block?.category).toBe('blocks') expect(block?.bgColor).toBe('#FF402F') }) it('should have language and code subBlocks', () => { expect(block?.subBlocks.length).toBeGreaterThanOrEqual(1) const languageSubBlock = block?.subBlocks.find((sb) => sb.id === 'language') const codeSubBlock = block?.subBlocks.find((sb) => sb.id === 'code') expect(codeSubBlock).toBeDefined() expect(codeSubBlock?.type).toBe('code') }) it('should have function_execute tool access', () => { expect(block?.tools.access).toContain('function_execute') }) it('should have code input', () => { expect(block?.inputs.code).toBeDefined() expect(block?.inputs.code.type).toBe('string') }) it('should have result and stdout outputs', () => { expect(block?.outputs.result).toBeDefined() expect(block?.outputs.stdout).toBeDefined() }) }) describe('ConditionBlock', () => { const block = getBlock('condition') it('should have correct metadata', () => { expect(block?.type).toBe('condition') expect(block?.name).toBe('Condition') expect(block?.category).toBe('blocks') expect(block?.bgColor).toBe('#FF752F') }) it('should have condition-input subBlock', () => { const conditionsSubBlock = block?.subBlocks.find((sb) => sb.id === 'conditions') expect(conditionsSubBlock).toBeDefined() expect(conditionsSubBlock?.type).toBe('condition-input') }) it('should have empty tools access', () => { expect(block?.tools.access).toEqual([]) }) it('should have condition-related outputs', () => { expect(block?.outputs.conditionResult).toBeDefined() expect(block?.outputs.selectedPath).toBeDefined() expect(block?.outputs.selectedOption).toBeDefined() }) }) describe('ApiBlock', () => { const block = getBlock('api') it('should have correct metadata', () => { expect(block?.type).toBe('api') expect(block?.name).toBe('API') expect(block?.category).toBe('blocks') expect(block?.bgColor).toBe('#2F55FF') }) it('should have required url subBlock', () => { const urlSubBlock = block?.subBlocks.find((sb) => sb.id === 'url') expect(urlSubBlock).toBeDefined() expect(urlSubBlock?.type).toBe('short-input') expect(urlSubBlock?.required).toBe(true) }) it('should have method dropdown with HTTP methods', () => { const methodSubBlock = block?.subBlocks.find((sb) => sb.id === 'method') expect(methodSubBlock).toBeDefined() expect(methodSubBlock?.type).toBe('dropdown') expect(methodSubBlock?.required).toBe(true) const options = methodSubBlock?.options as Array<{ label: string; id: string }> expect(options?.map((o) => o.id)).toContain('GET') expect(options?.map((o) => o.id)).toContain('POST') expect(options?.map((o) => o.id)).toContain('PUT') expect(options?.map((o) => o.id)).toContain('DELETE') expect(options?.map((o) => o.id)).toContain('PATCH') }) it('should have http_request tool access', () => { expect(block?.tools.access).toContain('http_request') }) it('should have API-related inputs', () => { expect(block?.inputs.url).toBeDefined() expect(block?.inputs.method).toBeDefined() expect(block?.inputs.headers).toBeDefined() expect(block?.inputs.body).toBeDefined() expect(block?.inputs.params).toBeDefined() }) it('should have API response outputs', () => { expect(block?.outputs.data).toBeDefined() expect(block?.outputs.status).toBeDefined() expect(block?.outputs.headers).toBeDefined() }) }) describe('ResponseBlock', () => { const block = getBlock('response') it('should have correct metadata', () => { expect(block?.type).toBe('response') expect(block?.name).toBe('Response') expect(block?.category).toBe('blocks') }) it('should have dataMode dropdown with builder and editor options', () => { const dataModeSubBlock = block?.subBlocks.find((sb) => sb.id === 'dataMode') expect(dataModeSubBlock).toBeDefined() expect(dataModeSubBlock?.type).toBe('dropdown') const options = dataModeSubBlock?.options as Array<{ label: string; id: string }> expect(options?.map((o) => o.id)).toContain('structured') expect(options?.map((o) => o.id)).toContain('json') }) it('should have conditional subBlocks based on dataMode', () => { const builderDataSubBlock = block?.subBlocks.find((sb) => sb.id === 'builderData') const dataSubBlock = block?.subBlocks.find((sb) => sb.id === 'data') expect(builderDataSubBlock?.condition).toEqual({ field: 'dataMode', value: 'structured' }) expect(dataSubBlock?.condition).toEqual({ field: 'dataMode', value: 'json' }) }) it('should have empty tools access', () => { expect(block?.tools.access).toEqual([]) }) }) describe('StarterBlock', () => { const block = getBlock('starter') it('should have correct metadata', () => { expect(block?.type).toBe('starter') expect(block?.name).toBe('Starter') expect(block?.category).toBe('blocks') expect(block?.hideFromToolbar).toBe(true) }) it('should have startWorkflow dropdown', () => { const startWorkflowSubBlock = block?.subBlocks.find((sb) => sb.id === 'startWorkflow') expect(startWorkflowSubBlock).toBeDefined() expect(startWorkflowSubBlock?.type).toBe('dropdown') const options = startWorkflowSubBlock?.options as Array<{ label: string; id: string }> expect(options?.map((o) => o.id)).toContain('manual') expect(options?.map((o) => o.id)).toContain('chat') }) it('should have empty outputs since it initiates workflow', () => { expect(Object.keys(block?.outputs || {}).length).toBe(0) }) }) describe('RouterBlock', () => { const block = getBlock('router') it('should have correct metadata', () => { expect(block?.type).toBe('router') expect(block?.name).toBe('Router (Legacy)') expect(block?.category).toBe('blocks') expect(block?.authMode).toBe(AuthMode.ApiKey) }) it('should have required prompt subBlock', () => { const promptSubBlock = block?.subBlocks.find((sb) => sb.id === 'prompt') expect(promptSubBlock).toBeDefined() expect(promptSubBlock?.type).toBe('long-input') expect(promptSubBlock?.required).toBe(true) }) it('should have model combobox with default value', () => { const modelSubBlock = block?.subBlocks.find((sb) => sb.id === 'model') expect(modelSubBlock).toBeDefined() expect(modelSubBlock?.type).toBe('combobox') expect(modelSubBlock?.required).toBe(true) expect(modelSubBlock?.defaultValue).toBe('claude-sonnet-5') }) it('should have LLM tool access', () => { expect(block?.tools.access).toContain('openai_chat') expect(block?.tools.access).toContain('anthropic_chat') expect(block?.tools.access).toContain('google_chat') }) it('should have tools.config with tool selector function', () => { expect(block?.tools.config).toBeDefined() expect(typeof block?.tools.config?.tool).toBe('function') }) }) }) describe('SubBlock Validation', () => { it('should have non-empty ids for all subBlocks', () => { const blocks = getAllBlocks() for (const block of blocks) { for (const subBlock of block.subBlocks) { expect(subBlock.id).toBeDefined() expect(typeof subBlock.id).toBe('string') expect(subBlock.id.length).toBeGreaterThan(0) } } }) it('should have valid subBlock types', () => { const validTypes = [ 'short-input', 'long-input', 'dropdown', 'combobox', 'slider', 'table', 'code', 'switch', 'tool-input', 'checkbox-list', 'grouped-checkbox-list', 'condition-input', 'eval-input', 'time-input', 'oauth-input', 'webhook-config', 'schedule-info', 'file-selector', 'sheet-selector', 'project-selector', 'channel-selector', 'user-selector', 'folder-selector', 'knowledge-base-selector', 'knowledge-tag-filters', 'document-selector', 'document-tag-entry', 'mcp-server-selector', 'mcp-tool-selector', 'mcp-dynamic-args', 'input-format', 'response-format', 'file-upload', 'input-mapping', 'variables-input', 'messages-input', 'workflow-selector', 'workflow-input-mapper', 'text', 'router-input', 'table-selector', 'column-selector', 'filter-builder', 'sort-builder', 'skill-input', 'modal', ] const blocks = getAllBlocks() for (const block of blocks) { for (const subBlock of block.subBlocks) { expect(validTypes).toContain(subBlock.type) } } }) it('should have valid mode values for subBlocks', () => { const validModes = ['basic', 'advanced', 'both', 'trigger', 'trigger-advanced', undefined] const blocks = getAllBlocks() for (const block of blocks) { for (const subBlock of block.subBlocks) { expect(validModes).toContain(subBlock.mode) } } }) }) describe('Input/Output Validation', () => { it('should have valid input types', () => { const validTypes = ['string', 'number', 'boolean', 'json', 'array', 'file'] const blocks = getAllBlocks() for (const block of blocks) { for (const [_, inputConfig] of Object.entries(block.inputs)) { expect(validTypes).toContain(inputConfig.type) } } }) it('should have valid output types', () => { const validPrimitiveTypes = [ 'string', 'number', 'boolean', 'json', 'array', 'file', 'file[]', 'any', ] const blocks = getAllBlocks() for (const block of blocks) { for (const [key, outputConfig] of Object.entries(block.outputs)) { if (key === 'visualization') continue if (typeof outputConfig === 'string') { expect(validPrimitiveTypes).toContain(outputConfig) } else if (typeof outputConfig === 'object' && outputConfig !== null) { if ('type' in outputConfig) { expect(validPrimitiveTypes).toContain(outputConfig.type) } } } } }) }) describe('AuthMode Validation', () => { it('should have valid authMode when defined', () => { const validAuthModes = [AuthMode.OAuth, AuthMode.ApiKey, AuthMode.BotToken, undefined] const blocks = getAllBlocks() for (const block of blocks) { expect(validAuthModes).toContain(block.authMode) } }) }) describe('Edge Cases', () => { it('should handle blocks with no inputs', () => { const conditionBlock = getBlock('condition') expect(conditionBlock?.inputs).toBeDefined() expect(Object.keys(conditionBlock?.inputs || {}).length).toBe(0) }) it('should handle blocks with no outputs', () => { const starterBlock = getBlock('starter') expect(starterBlock?.outputs).toBeDefined() expect(Object.keys(starterBlock?.outputs || {}).length).toBe(0) }) it('should handle blocks with no tool access', () => { const conditionBlock = getBlock('condition') expect(conditionBlock?.tools.access).toEqual([]) }) it('should handle blocks with multiple tool access', () => { const routerBlock = getBlock('router') expect(routerBlock?.tools.access.length).toBeGreaterThan(1) }) it('should handle blocks with tools.config', () => { const routerBlock = getBlock('router') expect(routerBlock?.tools.config).toBeDefined() expect(typeof routerBlock?.tools.config?.tool).toBe('function') }) it('should handle blocks with triggerAllowed flag', () => { const gmailBlock = getBlock('gmail') expect(gmailBlock?.triggerAllowed).toBe(true) const functionBlock = getBlock('function') expect(functionBlock?.triggerAllowed).toBeUndefined() }) it('should handle blocks with hideFromToolbar flag', () => { const starterBlock = getBlock('starter') expect(starterBlock?.hideFromToolbar).toBe(true) const functionBlock = getBlock('function') expect(functionBlock?.hideFromToolbar).toBeUndefined() }) it('should handle blocks with docsLink', () => { const functionBlock = getBlock('function') expect(functionBlock?.docsLink).toBe('https://docs.sim.ai/workflows/blocks/function') const apiBlock = getBlock('api') expect(apiBlock?.docsLink).toBe('https://docs.sim.ai/workflows/blocks/api') }) }) describe('generateRouterPrompt', () => { it('should generate a base prompt with routing instructions', () => { const prompt = generateRouterPrompt('Route to the correct agent') expect(prompt).toContain('You are an intelligent routing agent') expect(prompt).toContain('Route to the correct agent') expect(prompt).toContain('Response Format') }) it('should include target blocks information when provided', () => { const targetBlocks = [ { id: 'block-1', type: 'agent', title: 'Customer Support Agent', description: 'Handles customer inquiries', subBlocks: { systemPrompt: 'You are a helpful customer support agent.' }, }, { id: 'block-2', type: 'agent', title: 'Sales Agent', description: 'Handles sales inquiries', subBlocks: { systemPrompt: 'You are a sales agent.' }, }, ] const prompt = generateRouterPrompt('Route to the correct agent', targetBlocks) expect(prompt).toContain('Available Target Blocks') expect(prompt).toContain('block-1') expect(prompt).toContain('Customer Support Agent') expect(prompt).toContain('block-2') expect(prompt).toContain('Sales Agent') }) it('should include current state when provided', () => { const targetBlocks = [ { id: 'block-1', type: 'agent', title: 'Agent', currentState: { status: 'active', count: 5 }, }, ] const prompt = generateRouterPrompt('Route based on state', targetBlocks) expect(prompt).toContain('Current State') expect(prompt).toContain('active') expect(prompt).toContain('5') }) it('should handle empty target blocks array', () => { const prompt = generateRouterPrompt('Route to agent', []) expect(prompt).toContain('You are an intelligent routing agent') expect(prompt).toContain('Route to agent') }) it('should handle empty prompt string', () => { const prompt = generateRouterPrompt('') expect(prompt).toContain('You are an intelligent routing agent') expect(prompt).toContain('Routing Request:') }) }) describe('Block Category Counts', () => { it('should have more blocks in tools category than triggers', () => { const toolsBlocks = getBlocksByCategory('tools') const triggersBlocks = getBlocksByCategory('triggers') expect(toolsBlocks.length).toBeGreaterThan(triggersBlocks.length) }) it('should have a reasonable total number of blocks', () => { const allBlocks = getAllBlocks() expect(allBlocks.length).toBeGreaterThan(50) }) }) describe('SubBlock Features', () => { it('should have wandConfig on code subBlocks where applicable', () => { const functionBlock = getBlock('function') const codeSubBlock = functionBlock?.subBlocks.find((sb) => sb.id === 'code') expect(codeSubBlock?.wandConfig).toBeDefined() expect(codeSubBlock?.wandConfig?.enabled).toBe(true) expect(codeSubBlock?.wandConfig?.prompt).toBeDefined() }) it('should have correct slider configurations', () => { const routerBlock = getBlock('router') const temperatureSubBlock = routerBlock?.subBlocks.find((sb) => sb.id === 'temperature') expect(temperatureSubBlock?.type).toBe('slider') expect(temperatureSubBlock?.min).toBe(0) expect(temperatureSubBlock?.max).toBe(2) }) it('should mark generator provider dropdowns as command-searchable', () => { const imageGeneratorBlock = getBlock('image_generator_v2') const videoGeneratorBlock = getBlock('video_generator_v3') const imageProviderSubBlock = imageGeneratorBlock?.subBlocks.find( (sb) => sb.id === 'provider' ) const videoProviderSubBlock = videoGeneratorBlock?.subBlocks.find( (sb) => sb.id === 'provider' ) const imageProviderOptions = imageProviderSubBlock?.options const videoProviderOptions = videoProviderSubBlock?.options expect(imageGeneratorBlock?.hideFromToolbar).not.toBe(true) expect(videoGeneratorBlock?.hideFromToolbar).not.toBe(true) expect(imageProviderSubBlock?.commandSearchable).toBe(true) expect(videoProviderSubBlock?.commandSearchable).toBe(true) expect(imageProviderSubBlock?.value?.()).toBe('falai') expect(videoProviderSubBlock?.value?.()).toBe('falai') expect( Array.isArray(imageProviderOptions) ? imageProviderOptions.map((option) => option.id) : [] ).toContain('falai') expect( Array.isArray(videoProviderOptions) ? videoProviderOptions.map((option) => option.id) : [] ).toContain('falai') expect(getBlock('image_generator')?.hideFromToolbar).toBe(true) expect(getBlock('video_generator_v2')?.hideFromToolbar).toBe(true) }) it('should mark the agent model combobox as command-searchable', () => { const agentBlock = getBlock('agent') const modelSubBlock = agentBlock?.subBlocks.find((sb) => sb.id === 'model') expect(agentBlock?.hideFromToolbar).not.toBe(true) expect(modelSubBlock?.type).toBe('combobox') expect(modelSubBlock?.commandSearchable).toBe(true) }) it('should hide generator API keys on hosted only for Fal.ai providers', () => { for (const blockType of ['image_generator_v2', 'video_generator_v3']) { const block = getBlock(blockType) const apiKeySubBlocks = block?.subBlocks.filter((sb) => sb.id === 'apiKey') ?? [] const falApiKeySubBlock = apiKeySubBlocks.find( (sb) => sb.condition?.field === 'provider' && sb.condition.value === 'falai' ) const nonFalApiKeySubBlock = apiKeySubBlocks.find( (sb) => sb.condition?.field === 'provider' && sb.condition.value === 'falai' && sb.condition.not === true ) expect(falApiKeySubBlock?.hideWhenHosted).toBe(true) expect(nonFalApiKeySubBlock).toBeDefined() expect(nonFalApiKeySubBlock?.hideWhenHosted).not.toBe(true) } }) }) describe('Block Consistency', () => { it('should have consistent registry keys matching block types', () => { for (const block of getAllBlocks()) { const canonical = getBlock(block.type) if (canonical?.preview) { // Preview blocks surface in getAllBlocks() as projection clones // (hidden or "(Preview)"-suffixed); getBlock stays the pure config. expect(canonical.type).toBe(block.type) continue } expect(canonical).toBe(block) } }) it('should have non-empty descriptions for all blocks', () => { const blocks = getAllBlocks() for (const block of blocks) { expect(block.description.trim().length).toBeGreaterThan(0) } }) it('should have non-empty names for all blocks', () => { const blocks = getAllBlocks() for (const block of blocks) { expect(block.name.trim().length).toBeGreaterThan(0) } }) }) describe('Canonical Param Validation', () => { /** * Helper to serialize a condition for comparison */ function serializeCondition(condition: unknown): string { if (!condition) return '' return JSON.stringify(condition) } it('should not have canonicalParamId that matches any subBlock id within the same block', () => { const blocks = getAllBlocks() const errors: string[] = [] for (const block of blocks) { // Exclude trigger-mode subBlocks — they operate in a separate rendering context // and their IDs don't participate in canonical param resolution const nonTriggerSubBlocks = block.subBlocks.filter( (sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' ) const allSubBlockIds = new Set(nonTriggerSubBlocks.map((sb) => sb.id)) const canonicalParamIds = new Set( nonTriggerSubBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId) ) for (const canonicalId of canonicalParamIds) { if (allSubBlockIds.has(canonicalId!)) { // Check if the matching subBlock also has a canonicalParamId pointing to itself const matchingSubBlock = nonTriggerSubBlocks.find( (sb) => sb.id === canonicalId && !sb.canonicalParamId ) if (matchingSubBlock) { errors.push( `Block "${block.type}": canonicalParamId "${canonicalId}" clashes with subBlock id "${canonicalId}"` ) } } } } if (errors.length > 0) { throw new Error(`Canonical param ID clashes detected:\n${errors.join('\n')}`) } }) it('should have unique subBlock IDs within the same condition context', () => { const blocks = getAllBlocks() const errors: string[] = [] for (const block of blocks) { // Group subBlocks by their condition (only for static/JSON conditions, not functions) const subBlocksByCondition = new Map< string, Array<{ id: string; mode?: string; hasCanonical: boolean }> >() for (const subBlock of block.subBlocks) { // Skip subBlocks with function conditions - we can't evaluate them statically // These are valid when the function returns different conditions at runtime if (typeof subBlock.condition === 'function') { continue } const conditionKey = serializeCondition(subBlock.condition) if (!subBlocksByCondition.has(conditionKey)) { subBlocksByCondition.set(conditionKey, []) } subBlocksByCondition.get(conditionKey)!.push({ id: subBlock.id, mode: subBlock.mode, hasCanonical: Boolean(subBlock.canonicalParamId), }) } // Check for duplicate IDs within the same condition (excluding canonical pairs and mode swaps) for (const [conditionKey, subBlocks] of subBlocksByCondition) { const idCounts = new Map() for (const sb of subBlocks) { idCounts.set(sb.id, (idCounts.get(sb.id) || 0) + 1) } for (const [id, count] of idCounts) { if (count > 1) { const duplicates = subBlocks.filter((sb) => sb.id === id) // Categorize modes const basicModes = duplicates.filter( (sb) => !sb.mode || sb.mode === 'basic' || sb.mode === 'both' ) const advancedModes = duplicates.filter((sb) => sb.mode === 'advanced') const triggerModes = duplicates.filter((sb) => sb.mode === 'trigger') // Valid pattern 1: basic/advanced mode swap (with or without canonicalParamId) if ( basicModes.length === 1 && advancedModes.length === 1 && triggerModes.length === 0 ) { continue // This is a valid basic/advanced mode swap pair } // Valid pattern 2: basic/trigger mode separation (trigger version for trigger mode) // One basic/both + one or more trigger versions is valid if ( basicModes.length <= 1 && advancedModes.length === 0 && triggerModes.length >= 1 ) { continue // This is a valid pattern where trigger mode has its own subBlock } // Valid pattern 3: All duplicates have canonicalParamId (they form a canonical group) const allHaveCanonical = duplicates.every((sb) => sb.hasCanonical) if (allHaveCanonical) { continue // Validated separately by canonical pair tests } // Invalid: duplicates without proper pairing const condition = conditionKey || '(no condition)' const modeBreakdown = duplicates.map((d) => d.mode || 'basic/both').join(', ') errors.push( `Block "${block.type}": Duplicate subBlock id "${id}" with condition ${condition} (count: ${count}, modes: ${modeBreakdown})` ) } } } } if (errors.length > 0) { throw new Error(`Duplicate subBlock IDs detected:\n${errors.join('\n')}`) } }) it('should have properly formed canonical pairs (matching conditions)', () => { const blocks = getAllBlocks() const errors: string[] = [] for (const block of blocks) { // Group subBlocks by canonicalParamId const canonicalGroups = new Map< string, Array<{ id: string; mode?: string; condition: unknown; isStaticCondition: boolean }> >() for (const subBlock of block.subBlocks) { // Skip trigger-mode subBlocks — they operate in a separate rendering context if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') continue if (subBlock.canonicalParamId) { if (!canonicalGroups.has(subBlock.canonicalParamId)) { canonicalGroups.set(subBlock.canonicalParamId, []) } canonicalGroups.get(subBlock.canonicalParamId)!.push({ id: subBlock.id, mode: subBlock.mode, condition: subBlock.condition, isStaticCondition: typeof subBlock.condition !== 'function', }) } } // Validate each canonical group for (const [canonicalId, members] of canonicalGroups) { // Only validate condition matching for static conditions const staticMembers = members.filter((m) => m.isStaticCondition) if (staticMembers.length > 1) { const conditions = staticMembers.map((m) => serializeCondition(m.condition)) const uniqueConditions = new Set(conditions) if (uniqueConditions.size > 1) { errors.push( `Block "${block.type}": Canonical param "${canonicalId}" has members with different conditions: ${[...uniqueConditions].join(' vs ')}` ) } } // Check for proper basic/advanced pairing const basicMembers = members.filter((m) => !m.mode || m.mode === 'basic') const advancedMembers = members.filter((m) => m.mode === 'advanced') if (basicMembers.length > 1) { errors.push( `Block "${block.type}": Canonical param "${canonicalId}" has ${basicMembers.length} basic mode members (should have at most 1)` ) } if (basicMembers.length === 0 && advancedMembers.length === 0) { errors.push( `Block "${block.type}": Canonical param "${canonicalId}" has no basic or advanced mode members` ) } } } if (errors.length > 0) { throw new Error(`Canonical pair validation errors:\n${errors.join('\n')}`) } }) it('should have unique canonicalParamIds per operation/condition context', () => { const blocks = getAllBlocks() const errors: string[] = [] for (const block of blocks) { // Group by condition + canonicalParamId to detect same canonical used for different operations const canonicalByCondition = new Map>() for (const subBlock of block.subBlocks) { if (subBlock.canonicalParamId) { // Skip function conditions - we can't evaluate them statically if (typeof subBlock.condition === 'function') { continue } // Skip trigger-mode subBlocks — they operate in a separate rendering context if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') { continue } const conditionKey = serializeCondition(subBlock.condition) if (!canonicalByCondition.has(subBlock.canonicalParamId)) { canonicalByCondition.set(subBlock.canonicalParamId, new Set()) } canonicalByCondition.get(subBlock.canonicalParamId)!.add(conditionKey) } } // Check that each canonicalParamId is only used for one condition for (const [canonicalId, conditions] of canonicalByCondition) { if (conditions.size > 1) { errors.push( `Block "${block.type}": Canonical param "${canonicalId}" is used across ${conditions.size} different conditions. Each operation should have its own unique canonicalParamId.` ) } } } if (errors.length > 0) { throw new Error(`Canonical param reuse across conditions:\n${errors.join('\n')}`) } }) it('should have inputs containing canonical param IDs instead of raw subBlock IDs', () => { const blocks = getAllBlocks() const errors: string[] = [] for (const block of blocks) { if (!block.inputs) continue // Find all canonical groups (subBlocks with canonicalParamId) // Skip trigger-mode subBlocks — they operate in a separate rendering context // and are not wired to the block's inputs section const canonicalGroups = new Map() for (const subBlock of block.subBlocks) { if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') continue if (subBlock.canonicalParamId) { if (!canonicalGroups.has(subBlock.canonicalParamId)) { canonicalGroups.set(subBlock.canonicalParamId, []) } canonicalGroups.get(subBlock.canonicalParamId)!.push(subBlock.id) } } const inputKeys = Object.keys(block.inputs) for (const [canonicalId, rawSubBlockIds] of canonicalGroups) { // Check that the canonical param ID is in inputs if (!inputKeys.includes(canonicalId)) { errors.push( `Block "${block.type}": inputs section is missing canonical param "${canonicalId}"` ) } // Check that raw subBlock IDs are NOT in inputs (they get deleted after transformation) for (const rawId of rawSubBlockIds) { if (rawId !== canonicalId && inputKeys.includes(rawId)) { errors.push( `Block "${block.type}": inputs section contains raw subBlock id "${rawId}" which should be replaced by canonical param "${canonicalId}"` ) } } } } if (errors.length > 0) { throw new Error(`Inputs section validation errors:\n${errors.join('\n')}`) } }) it('should have params function using canonical IDs instead of raw subBlock IDs', () => { const blocks = getAllBlocks() const errors: string[] = [] for (const block of blocks) { // Check if block has a params function const paramsFunc = block.tools?.config?.params if (!paramsFunc || typeof paramsFunc !== 'function') continue // Get the function source code, stripping comments to avoid false positives const rawFuncSource = paramsFunc.toString() // Remove single-line comments (// ...) and multi-line comments (/* ... */) const funcSource = rawFuncSource .replace(/\/\/[^\n]*/g, '') // Remove single-line comments .replace(/\/\*[\s\S]*?\*\//g, '') // Remove multi-line comments // Find all canonical groups (subBlocks with canonicalParamId) // Skip trigger-mode subBlocks — they are not passed through params function const canonicalGroups = new Map() for (const subBlock of block.subBlocks) { if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') continue if (subBlock.canonicalParamId) { if (!canonicalGroups.has(subBlock.canonicalParamId)) { canonicalGroups.set(subBlock.canonicalParamId, []) } canonicalGroups.get(subBlock.canonicalParamId)!.push(subBlock.id) } } // Check for raw subBlock IDs being used in the params function for (const [canonicalId, rawSubBlockIds] of canonicalGroups) { for (const rawId of rawSubBlockIds) { // Skip if the rawId is the same as the canonicalId (self-referential, which is allowed in some cases) if (rawId === canonicalId) continue // Check if the params function references the raw subBlock ID // Look for patterns like: params.rawId, { rawId }, destructuring rawId const patterns = [ new RegExp(`params\\.${rawId}\\b`), // params.rawId new RegExp(`\\{[^}]*\\b${rawId}\\b[^}]*\\}\\s*=\\s*params`), // { rawId } = params new RegExp(`\\b${rawId}\\s*[,}]`), // rawId in destructuring ] for (const pattern of patterns) { if (pattern.test(funcSource)) { errors.push( `Block "${block.type}": params function references raw subBlock id "${rawId}" which is deleted after canonical transformation. Use canonical param "${canonicalId}" instead.` ) break } } } } } if (errors.length > 0) { throw new Error(`Params function validation errors:\n${errors.join('\n')}`) } }) it('should have consistent required status across canonical param groups', () => { const blocks = getAllBlocks() const errors: string[] = [] for (const block of blocks) { // Find all canonical groups (subBlocks with canonicalParamId) // Skip trigger-mode subBlocks — they operate in a separate rendering context // and may have different required semantics from their block counterparts const canonicalGroups = new Map() for (const subBlock of block.subBlocks) { if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') continue if (subBlock.canonicalParamId) { if (!canonicalGroups.has(subBlock.canonicalParamId)) { canonicalGroups.set(subBlock.canonicalParamId, []) } canonicalGroups.get(subBlock.canonicalParamId)!.push(subBlock) } } // For each canonical group, check that required status is consistent for (const [canonicalId, subBlocks] of canonicalGroups) { if (subBlocks.length < 2) continue // Single subblock, no consistency check needed // Get required status for each subblock (handling both boolean and condition object) const requiredStatuses = subBlocks.map((sb) => { // If required is a condition object or function, we can't statically determine it // so we skip those cases if (typeof sb.required === 'object' || typeof sb.required === 'function') { return 'dynamic' } return sb.required === true ? 'required' : 'optional' }) // Filter out dynamic cases const staticStatuses = requiredStatuses.filter((s) => s !== 'dynamic') if (staticStatuses.length < 2) continue // Not enough static statuses to compare // Check if all static statuses are the same const hasRequired = staticStatuses.includes('required') const hasOptional = staticStatuses.includes('optional') if (hasRequired && hasOptional) { const requiredSubBlocks = subBlocks .filter((sb, i) => requiredStatuses[i] === 'required') .map((sb) => `${sb.id} (${sb.mode || 'both'})`) const optionalSubBlocks = subBlocks .filter((sb, i) => requiredStatuses[i] === 'optional') .map((sb) => `${sb.id} (${sb.mode || 'both'})`) errors.push( `Block "${block.type}": canonical param "${canonicalId}" has inconsistent required status. ` + `Required: [${requiredSubBlocks.join(', ')}], Optional: [${optionalSubBlocks.join(', ')}]. ` + `All subBlocks in a canonical group should have the same required status.` ) } } } if (errors.length > 0) { throw new Error(`Required status consistency errors:\n${errors.join('\n')}`) } }) }) })