diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5c32872..95f389a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Comprehensive Expression Format Validation System**: Three-tier validation strategy for n8n expressions + - **Universal Expression Validator**: 100% reliable detection of expression format issues + - Enforces required `=` prefix for all expressions `{{ }}` + - Validates expression syntax (bracket matching, empty expressions) + - Detects common mistakes (template literals, nested brackets, double prefixes) + - Provides confidence score of 1.0 for universal rules + - **Confidence-Based Node-Specific Recommendations**: Intelligent resource locator suggestions + - Confidence scoring system (0.0 to 1.0) for field-specific recommendations + - High confidence (โ‰ฅ0.8): Exact field matches for known nodes (GitHub owner/repository, Slack channels) + - Medium confidence (โ‰ฅ0.5): Field pattern matches (fields ending in Id, Key, Name) + - Factors: exact field match, field patterns, value patterns, node category + - **Resource Locator Format Detection**: Identifies fields needing `__rl` structure + - Validates resource locator mode (id, url, expression, name, list) + - Auto-fixes missing prefixes in resource locator values + - Provides clear JSON examples showing correct format + - **Enhanced Safety Features**: + - Recursion depth protection (MAX_RECURSION_DEPTH = 100) prevents infinite loops + - Pattern matching precision using exact/prefix matching instead of includes() + - Circular reference detection with WeakSet + - **Separation of Concerns**: Clean architecture for maintainability + - Universal rules separated from node-specific intelligence + - Confidence-based application of suggestions + - Future-proof design that works with any n8n node + ## [2.12.1] - 2025-09-22 ### Fixed diff --git a/scripts/test-expression-format-validation.js b/scripts/test-expression-format-validation.js new file mode 100644 index 0000000..9d81353 --- /dev/null +++ b/scripts/test-expression-format-validation.js @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +/** + * Test script for expression format validation + * Tests the validation of expression prefixes and resource locator formats + */ + +const { WorkflowValidator } = require('../dist/services/workflow-validator.js'); +const { NodeRepository } = require('../dist/database/node-repository.js'); +const { EnhancedConfigValidator } = require('../dist/services/enhanced-config-validator.js'); +const { createDatabaseAdapter } = require('../dist/database/database-adapter.js'); +const path = require('path'); + +async function runTests() { + // Initialize database + const dbPath = path.join(__dirname, '..', 'data', 'nodes.db'); + const adapter = await createDatabaseAdapter(dbPath); + const db = adapter; + + const nodeRepository = new NodeRepository(db); + const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator); + + console.log('\n๐Ÿงช Testing Expression Format Validation\n'); + console.log('=' .repeat(60)); + + // Test 1: Email node with missing = prefix + console.log('\n๐Ÿ“ Test 1: Email Send node - Missing = prefix'); + console.log('-'.repeat(40)); + + const emailWorkflowIncorrect = { + nodes: [ + { + id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0', + name: 'Error Handler', + type: 'n8n-nodes-base.emailSend', + typeVersion: 2.1, + position: [-128, 400], + parameters: { + fromEmail: '{{ $env.ADMIN_EMAIL }}', // INCORRECT - missing = + toEmail: 'admin@company.com', + subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', + options: {} + }, + credentials: { + smtp: { + id: '7AQ08VMFHubmfvzR', + name: 'romuald@aiadvisors.pl' + } + } + } + ], + connections: {} + }; + + const result1 = await validator.validateWorkflow(emailWorkflowIncorrect); + + if (result1.errors.some(e => e.message.includes('Expression format'))) { + console.log('โœ… ERROR DETECTED (correct behavior):'); + const formatError = result1.errors.find(e => e.message.includes('Expression format')); + console.log('\n' + formatError.message); + } else { + console.log('โŒ No expression format error detected (should have detected missing prefix)'); + } + + // Test 2: Email node with correct = prefix + console.log('\n๐Ÿ“ Test 2: Email Send node - Correct = prefix'); + console.log('-'.repeat(40)); + + const emailWorkflowCorrect = { + nodes: [ + { + id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0', + name: 'Error Handler', + type: 'n8n-nodes-base.emailSend', + typeVersion: 2.1, + position: [-128, 400], + parameters: { + fromEmail: '={{ $env.ADMIN_EMAIL }}', // CORRECT - has = + toEmail: 'admin@company.com', + subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', + options: {} + } + } + ], + connections: {} + }; + + const result2 = await validator.validateWorkflow(emailWorkflowCorrect); + + if (result2.errors.some(e => e.message.includes('Expression format'))) { + console.log('โŒ Unexpected expression format error (should accept = prefix)'); + } else { + console.log('โœ… No expression format errors (correct!)'); + } + + // Test 3: GitHub node without resource locator format + console.log('\n๐Ÿ“ Test 3: GitHub node - Missing resource locator format'); + console.log('-'.repeat(40)); + + const githubWorkflowIncorrect = { + nodes: [ + { + id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491', + name: 'Send Welcome Comment', + type: 'n8n-nodes-base.github', + typeVersion: 1.1, + position: [-240, 96], + parameters: { + operation: 'createComment', + owner: '{{ $vars.GITHUB_OWNER }}', // INCORRECT - needs RL format + repository: '{{ $vars.GITHUB_REPO }}', // INCORRECT - needs RL format + issueNumber: null, + body: '๐Ÿ‘‹ Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // INCORRECT - missing = + }, + credentials: { + githubApi: { + id: 'edgpwh6ldYN07MXx', + name: 'GitHub account' + } + } + } + ], + connections: {} + }; + + const result3 = await validator.validateWorkflow(githubWorkflowIncorrect); + + const formatErrors = result3.errors.filter(e => e.message.includes('Expression format')); + console.log(`\nFound ${formatErrors.length} expression format errors:`); + + if (formatErrors.length >= 3) { + console.log('โœ… All format issues detected:'); + formatErrors.forEach((error, index) => { + const field = error.message.match(/Field '([^']+)'/)?.[1] || 'unknown'; + console.log(` ${index + 1}. Field '${field}' - ${error.message.includes('resource locator') ? 'Needs RL format' : 'Missing = prefix'}`); + }); + } else { + console.log('โŒ Not all format issues detected'); + } + + // Test 4: GitHub node with correct resource locator format + console.log('\n๐Ÿ“ Test 4: GitHub node - Correct resource locator format'); + console.log('-'.repeat(40)); + + const githubWorkflowCorrect = { + nodes: [ + { + id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491', + name: 'Send Welcome Comment', + type: 'n8n-nodes-base.github', + typeVersion: 1.1, + position: [-240, 96], + parameters: { + operation: 'createComment', + owner: { + __rl: true, + value: '={{ $vars.GITHUB_OWNER }}', // CORRECT - RL format with = + mode: 'expression' + }, + repository: { + __rl: true, + value: '={{ $vars.GITHUB_REPO }}', // CORRECT - RL format with = + mode: 'expression' + }, + issueNumber: 123, + body: '=๐Ÿ‘‹ Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // CORRECT - has = + } + } + ], + connections: {} + }; + + const result4 = await validator.validateWorkflow(githubWorkflowCorrect); + + const formatErrors4 = result4.errors.filter(e => e.message.includes('Expression format')); + if (formatErrors4.length === 0) { + console.log('โœ… No expression format errors (correct!)'); + } else { + console.log(`โŒ Unexpected expression format errors: ${formatErrors4.length}`); + formatErrors4.forEach(e => console.log(' - ' + e.message.split('\n')[0])); + } + + // Test 5: Mixed content expressions + console.log('\n๐Ÿ“ Test 5: Mixed content with expressions'); + console.log('-'.repeat(40)); + + const mixedContentWorkflow = { + nodes: [ + { + id: '1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4, + position: [0, 0], + parameters: { + url: 'https://api.example.com/users/{{ $json.userId }}', // INCORRECT + headers: { + 'Authorization': '=Bearer {{ $env.API_TOKEN }}' // CORRECT + } + } + } + ], + connections: {} + }; + + const result5 = await validator.validateWorkflow(mixedContentWorkflow); + + const urlError = result5.errors.find(e => e.message.includes('url') && e.message.includes('Expression format')); + if (urlError) { + console.log('โœ… Mixed content error detected for URL field'); + console.log(' Should be: "=https://api.example.com/users/{{ $json.userId }}"'); + } else { + console.log('โŒ Mixed content error not detected'); + } + + console.log('\n' + '='.repeat(60)); + console.log('\nโœจ Expression Format Validation Summary:'); + console.log(' - Detects missing = prefix in expressions'); + console.log(' - Identifies fields needing resource locator format'); + console.log(' - Provides clear correction examples'); + console.log(' - Handles mixed literal and expression content'); + + // Close database + db.close(); +} + +runTests().catch(error => { + console.error('Test failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/services/confidence-scorer.ts b/src/services/confidence-scorer.ts new file mode 100644 index 0000000..902590e --- /dev/null +++ b/src/services/confidence-scorer.ts @@ -0,0 +1,211 @@ +/** + * Confidence Scorer for node-specific validations + * + * Provides confidence scores for node-specific recommendations, + * allowing users to understand the reliability of suggestions. + */ + +export interface ConfidenceScore { + value: number; // 0.0 to 1.0 + reason: string; + factors: ConfidenceFactor[]; +} + +export interface ConfidenceFactor { + name: string; + weight: number; + matched: boolean; + description: string; +} + +export class ConfidenceScorer { + /** + * Calculate confidence score for resource locator recommendation + */ + static scoreResourceLocatorRecommendation( + fieldName: string, + nodeType: string, + value: string + ): ConfidenceScore { + const factors: ConfidenceFactor[] = []; + let totalWeight = 0; + let matchedWeight = 0; + + // Factor 1: Exact field name match (highest confidence) + const exactFieldMatch = this.checkExactFieldMatch(fieldName, nodeType); + factors.push({ + name: 'exact-field-match', + weight: 0.5, + matched: exactFieldMatch, + description: `Field name '${fieldName}' is known to use resource locator in ${nodeType}` + }); + + // Factor 2: Field name pattern (medium confidence) + const patternMatch = this.checkFieldPattern(fieldName); + factors.push({ + name: 'field-pattern', + weight: 0.3, + matched: patternMatch, + description: `Field name '${fieldName}' matches common resource locator patterns` + }); + + // Factor 3: Value pattern (low confidence) + const valuePattern = this.checkValuePattern(value); + factors.push({ + name: 'value-pattern', + weight: 0.1, + matched: valuePattern, + description: 'Value contains patterns typical of resource identifiers' + }); + + // Factor 4: Node type category (medium confidence) + const nodeCategory = this.checkNodeCategory(nodeType); + factors.push({ + name: 'node-category', + weight: 0.1, + matched: nodeCategory, + description: `Node type '${nodeType}' typically uses resource locators` + }); + + // Calculate final score + for (const factor of factors) { + totalWeight += factor.weight; + if (factor.matched) { + matchedWeight += factor.weight; + } + } + + const score = totalWeight > 0 ? matchedWeight / totalWeight : 0; + + // Determine reason based on score + let reason: string; + if (score >= 0.8) { + reason = 'High confidence: Multiple strong indicators suggest resource locator format'; + } else if (score >= 0.5) { + reason = 'Medium confidence: Some indicators suggest resource locator format'; + } else if (score >= 0.3) { + reason = 'Low confidence: Weak indicators for resource locator format'; + } else { + reason = 'Very low confidence: Minimal evidence for resource locator format'; + } + + return { + value: score, + reason, + factors + }; + } + + /** + * Known field mappings with exact matches + */ + private static readonly EXACT_FIELD_MAPPINGS: Record = { + 'github': ['owner', 'repository', 'user', 'organization'], + 'googlesheets': ['sheetId', 'documentId', 'spreadsheetId'], + 'googledrive': ['fileId', 'folderId', 'driveId'], + 'slack': ['channel', 'user', 'channelId', 'userId'], + 'notion': ['databaseId', 'pageId', 'blockId'], + 'airtable': ['baseId', 'tableId', 'viewId'] + }; + + private static checkExactFieldMatch(fieldName: string, nodeType: string): boolean { + const nodeBase = nodeType.split('.').pop()?.toLowerCase() || ''; + + for (const [pattern, fields] of Object.entries(this.EXACT_FIELD_MAPPINGS)) { + if (nodeBase === pattern || nodeBase.startsWith(`${pattern}-`)) { + return fields.includes(fieldName); + } + } + + return false; + } + + /** + * Common patterns in field names that suggest resource locators + */ + private static readonly FIELD_PATTERNS = [ + /^.*Id$/i, // ends with Id + /^.*Ids$/i, // ends with Ids + /^.*Key$/i, // ends with Key + /^.*Name$/i, // ends with Name + /^.*Path$/i, // ends with Path + /^.*Url$/i, // ends with Url + /^.*Uri$/i, // ends with Uri + /^(table|database|collection|bucket|folder|file|document|sheet|board|project|issue|user|channel|team|organization|repository|owner)$/i + ]; + + private static checkFieldPattern(fieldName: string): boolean { + return this.FIELD_PATTERNS.some(pattern => pattern.test(fieldName)); + } + + /** + * Check if the value looks like it contains identifiers + */ + private static checkValuePattern(value: string): boolean { + // Remove = prefix if present for analysis + const content = value.startsWith('=') ? value.substring(1) : value; + + // Skip if not an expression + if (!content.includes('{{') || !content.includes('}}')) { + return false; + } + + // Check for patterns that suggest IDs or resource references + const patterns = [ + /\{\{.*\.(id|Id|ID|key|Key|name|Name|path|Path|url|Url|uri|Uri).*\}\}/i, + /\{\{.*_(id|Id|ID|key|Key|name|Name|path|Path|url|Url|uri|Uri).*\}\}/i, + /\{\{.*(id|Id|ID|key|Key|name|Name|path|Path|url|Url|uri|Uri).*\}\}/i + ]; + + return patterns.some(pattern => pattern.test(content)); + } + + /** + * Node categories that commonly use resource locators + */ + private static readonly RESOURCE_HEAVY_NODES = [ + 'github', 'gitlab', 'bitbucket', // Version control + 'googlesheets', 'googledrive', 'dropbox', // Cloud storage + 'slack', 'discord', 'telegram', // Communication + 'notion', 'airtable', 'baserow', // Databases + 'jira', 'asana', 'trello', 'monday', // Project management + 'salesforce', 'hubspot', 'pipedrive', // CRM + 'stripe', 'paypal', 'square', // Payment + 'aws', 'gcp', 'azure', // Cloud providers + 'mysql', 'postgres', 'mongodb', 'redis' // Databases + ]; + + private static checkNodeCategory(nodeType: string): boolean { + const nodeBase = nodeType.split('.').pop()?.toLowerCase() || ''; + + return this.RESOURCE_HEAVY_NODES.some(category => + nodeBase.includes(category) + ); + } + + /** + * Get confidence level as a string + */ + static getConfidenceLevel(score: number): 'high' | 'medium' | 'low' | 'very-low' { + if (score >= 0.8) return 'high'; + if (score >= 0.5) return 'medium'; + if (score >= 0.3) return 'low'; + return 'very-low'; + } + + /** + * Should apply recommendation based on confidence and threshold + */ + static shouldApplyRecommendation( + score: number, + threshold: 'strict' | 'normal' | 'relaxed' = 'normal' + ): boolean { + const thresholds = { + strict: 0.8, // Only apply high confidence recommendations + normal: 0.5, // Apply medium and high confidence + relaxed: 0.3 // Apply low, medium, and high confidence + }; + + return score >= thresholds[threshold]; + } +} \ No newline at end of file diff --git a/src/services/expression-format-validator.ts b/src/services/expression-format-validator.ts new file mode 100644 index 0000000..ff7b084 --- /dev/null +++ b/src/services/expression-format-validator.ts @@ -0,0 +1,340 @@ +/** + * Expression Format Validator for n8n expressions + * + * Combines universal expression validation with node-specific intelligence + * to provide comprehensive expression format validation. Uses the + * UniversalExpressionValidator for 100% reliable base validation and adds + * node-specific resource locator detection on top. + */ + +import { UniversalExpressionValidator, UniversalValidationResult } from './universal-expression-validator'; +import { ConfidenceScorer } from './confidence-scorer'; + +export interface ExpressionFormatIssue { + fieldPath: string; + currentValue: any; + correctedValue: any; + issueType: 'missing-prefix' | 'needs-resource-locator' | 'invalid-rl-structure' | 'mixed-format'; + explanation: string; + severity: 'error' | 'warning'; + confidence?: number; // 0.0 to 1.0, only for node-specific recommendations +} + +export interface ResourceLocatorField { + __rl: true; + value: string; + mode: string; +} + +export interface ValidationContext { + nodeType: string; + nodeName: string; + nodeId?: string; +} + +export class ExpressionFormatValidator { + private static readonly VALID_RL_MODES = ['id', 'url', 'expression', 'name', 'list'] as const; + private static readonly MAX_RECURSION_DEPTH = 100; + private static readonly EXPRESSION_PREFIX = '='; // Keep for resource locator generation + + /** + * Known fields that commonly use resource locator format + * Map of node type patterns to field names + */ + private static readonly RESOURCE_LOCATOR_FIELDS: Record = { + 'github': ['owner', 'repository', 'user', 'organization'], + 'googleSheets': ['sheetId', 'documentId', 'spreadsheetId', 'rangeDefinition'], + 'googleDrive': ['fileId', 'folderId', 'driveId'], + 'slack': ['channel', 'user', 'channelId', 'userId', 'teamId'], + 'notion': ['databaseId', 'pageId', 'blockId'], + 'airtable': ['baseId', 'tableId', 'viewId'], + 'monday': ['boardId', 'itemId', 'groupId'], + 'hubspot': ['contactId', 'companyId', 'dealId'], + 'salesforce': ['recordId', 'objectName'], + 'jira': ['projectKey', 'issueKey', 'boardId'], + 'gitlab': ['projectId', 'mergeRequestId', 'issueId'], + 'mysql': ['table', 'database', 'schema'], + 'postgres': ['table', 'database', 'schema'], + 'mongodb': ['collection', 'database'], + 's3': ['bucketName', 'key', 'fileName'], + 'ftp': ['path', 'fileName'], + 'ssh': ['path', 'fileName'], + 'redis': ['key'], + }; + + + /** + * Determine if a field should use resource locator format based on node type and field name + */ + private static shouldUseResourceLocator(fieldName: string, nodeType: string): boolean { + // Extract the base node type (e.g., 'github' from 'n8n-nodes-base.github') + const nodeBase = nodeType.split('.').pop()?.toLowerCase() || ''; + + // Check if this node type has resource locator fields + for (const [pattern, fields] of Object.entries(this.RESOURCE_LOCATOR_FIELDS)) { + // Use exact match or prefix matching for precision + // This prevents false positives like 'postgresqlAdvanced' matching 'postgres' + if ((nodeBase === pattern || nodeBase.startsWith(`${pattern}-`)) && fields.includes(fieldName)) { + return true; + } + } + + // Don't apply resource locator to generic fields + return false; + } + + /** + * Check if a value is a valid resource locator object + */ + private static isResourceLocator(value: any): value is ResourceLocatorField { + if (typeof value !== 'object' || value === null || value.__rl !== true) { + return false; + } + + if (!('value' in value) || !('mode' in value)) { + return false; + } + + // Validate mode is one of the allowed values + if (typeof value.mode !== 'string' || !this.VALID_RL_MODES.includes(value.mode as any)) { + return false; + } + + return true; + } + + /** + * Generate the corrected value for an expression + */ + private static generateCorrection( + value: string, + needsResourceLocator: boolean + ): any { + const correctedValue = value.startsWith(this.EXPRESSION_PREFIX) + ? value + : `${this.EXPRESSION_PREFIX}${value}`; + + if (needsResourceLocator) { + return { + __rl: true, + value: correctedValue, + mode: 'expression' + }; + } + + return correctedValue; + } + + /** + * Validate and fix expression format for a single value + */ + static validateAndFix( + value: any, + fieldPath: string, + context: ValidationContext + ): ExpressionFormatIssue | null { + // Skip non-string values unless they're resource locators + if (typeof value !== 'string' && !this.isResourceLocator(value)) { + return null; + } + + // Handle resource locator objects + if (this.isResourceLocator(value)) { + // Use universal validator for the value inside RL + const universalResults = UniversalExpressionValidator.validate(value.value); + const invalidResult = universalResults.find(r => !r.isValid && r.needsPrefix); + + if (invalidResult) { + return { + fieldPath, + currentValue: value, + correctedValue: { + ...value, + value: UniversalExpressionValidator.getCorrectedValue(value.value) + }, + issueType: 'missing-prefix', + explanation: `Resource locator value: ${invalidResult.explanation}`, + severity: 'error' + }; + } + return null; + } + + // First, use universal validator for 100% reliable validation + const universalResults = UniversalExpressionValidator.validate(value); + const invalidResults = universalResults.filter(r => !r.isValid); + + // If universal validator found issues, report them + if (invalidResults.length > 0) { + // Prioritize prefix issues + const prefixIssue = invalidResults.find(r => r.needsPrefix); + if (prefixIssue) { + // Check if this field should use resource locator format with confidence scoring + const fieldName = fieldPath.split('.').pop() || ''; + const confidenceScore = ConfidenceScorer.scoreResourceLocatorRecommendation( + fieldName, + context.nodeType, + value + ); + + // Only suggest resource locator for high confidence matches when there's a prefix issue + if (confidenceScore.value >= 0.8) { + return { + fieldPath, + currentValue: value, + correctedValue: this.generateCorrection(value, true), + issueType: 'needs-resource-locator', + explanation: `Field '${fieldName}' contains expression but needs resource locator format with '${this.EXPRESSION_PREFIX}' prefix for evaluation.`, + severity: 'error', + confidence: confidenceScore.value + }; + } else { + return { + fieldPath, + currentValue: value, + correctedValue: UniversalExpressionValidator.getCorrectedValue(value), + issueType: 'missing-prefix', + explanation: prefixIssue.explanation, + severity: 'error' + }; + } + } + + // Report other validation issues + const firstIssue = invalidResults[0]; + return { + fieldPath, + currentValue: value, + correctedValue: value, + issueType: 'mixed-format', + explanation: firstIssue.explanation, + severity: 'error' + }; + } + + // Universal validation passed, now check for node-specific improvements + // Only if the value has expressions + const hasExpression = universalResults.some(r => r.hasExpression); + if (hasExpression && typeof value === 'string') { + const fieldName = fieldPath.split('.').pop() || ''; + const confidenceScore = ConfidenceScorer.scoreResourceLocatorRecommendation( + fieldName, + context.nodeType, + value + ); + + // Only suggest resource locator for medium-high confidence as a warning + if (confidenceScore.value >= 0.5) { + // Has prefix but should use resource locator format + return { + fieldPath, + currentValue: value, + correctedValue: this.generateCorrection(value, true), + issueType: 'needs-resource-locator', + explanation: `Field '${fieldName}' should use resource locator format for better compatibility. (Confidence: ${Math.round(confidenceScore.value * 100)}%)`, + severity: 'warning', + confidence: confidenceScore.value + }; + } + } + + return null; + } + + /** + * Validate all expressions in a node's parameters recursively + */ + static validateNodeParameters( + parameters: any, + context: ValidationContext + ): ExpressionFormatIssue[] { + const issues: ExpressionFormatIssue[] = []; + const visited = new WeakSet(); + + this.validateRecursive(parameters, '', context, issues, visited); + + return issues; + } + + /** + * Recursively validate parameters for expression format issues + */ + private static validateRecursive( + obj: any, + path: string, + context: ValidationContext, + issues: ExpressionFormatIssue[], + visited: WeakSet, + depth = 0 + ): void { + // Prevent excessive recursion + if (depth > this.MAX_RECURSION_DEPTH) { + issues.push({ + fieldPath: path, + currentValue: obj, + correctedValue: obj, + issueType: 'mixed-format', + explanation: `Maximum recursion depth (${this.MAX_RECURSION_DEPTH}) exceeded. Object may have circular references or be too deeply nested.`, + severity: 'warning' + }); + return; + } + + // Handle circular references + if (obj && typeof obj === 'object') { + if (visited.has(obj)) return; + visited.add(obj); + } + + // Check current value + const issue = this.validateAndFix(obj, path, context); + if (issue) { + issues.push(issue); + } + + // Recurse into objects and arrays + if (Array.isArray(obj)) { + obj.forEach((item, index) => { + const newPath = path ? `${path}[${index}]` : `[${index}]`; + this.validateRecursive(item, newPath, context, issues, visited, depth + 1); + }); + } else if (obj && typeof obj === 'object') { + // Skip resource locator internals if already validated + if (this.isResourceLocator(obj)) { + return; + } + + Object.entries(obj).forEach(([key, value]) => { + // Skip special keys + if (key.startsWith('__')) return; + + const newPath = path ? `${path}.${key}` : key; + this.validateRecursive(value, newPath, context, issues, visited, depth + 1); + }); + } + } + + /** + * Generate a detailed error message with examples + */ + static formatErrorMessage(issue: ExpressionFormatIssue, context: ValidationContext): string { + let message = `Expression format ${issue.severity} in node '${context.nodeName}':\n`; + message += `Field '${issue.fieldPath}' ${issue.explanation}\n\n`; + + message += `Current (incorrect):\n`; + if (typeof issue.currentValue === 'string') { + message += `"${issue.fieldPath}": "${issue.currentValue}"\n\n`; + } else { + message += `"${issue.fieldPath}": ${JSON.stringify(issue.currentValue, null, 2)}\n\n`; + } + + message += `Fixed (correct):\n`; + if (typeof issue.correctedValue === 'string') { + message += `"${issue.fieldPath}": "${issue.correctedValue}"`; + } else { + message += `"${issue.fieldPath}": ${JSON.stringify(issue.correctedValue, null, 2)}`; + } + + return message; + } +} \ No newline at end of file diff --git a/src/services/universal-expression-validator.ts b/src/services/universal-expression-validator.ts new file mode 100644 index 0000000..6805d1e --- /dev/null +++ b/src/services/universal-expression-validator.ts @@ -0,0 +1,272 @@ +/** + * Universal Expression Validator + * + * Validates n8n expressions based on universal rules that apply to ALL expressions, + * regardless of node type or field. This provides 100% reliable detection of + * expression format issues without needing node-specific knowledge. + */ + +export interface UniversalValidationResult { + isValid: boolean; + hasExpression: boolean; + needsPrefix: boolean; + isMixedContent: boolean; + confidence: 1.0; // Universal rules have 100% confidence + suggestion?: string; + explanation: string; +} + +export class UniversalExpressionValidator { + private static readonly EXPRESSION_PATTERN = /\{\{[\s\S]+?\}\}/; + private static readonly EXPRESSION_PREFIX = '='; + + /** + * Universal Rule 1: Any field containing {{ }} MUST have = prefix + * This is an absolute rule in n8n - no exceptions + */ + static validateExpressionPrefix(value: any): UniversalValidationResult { + // Only validate strings + if (typeof value !== 'string') { + return { + isValid: true, + hasExpression: false, + needsPrefix: false, + isMixedContent: false, + confidence: 1.0, + explanation: 'Not a string value' + }; + } + + const hasExpression = this.EXPRESSION_PATTERN.test(value); + + if (!hasExpression) { + return { + isValid: true, + hasExpression: false, + needsPrefix: false, + isMixedContent: false, + confidence: 1.0, + explanation: 'No n8n expression found' + }; + } + + const hasPrefix = value.startsWith(this.EXPRESSION_PREFIX); + const isMixedContent = this.hasMixedContent(value); + + if (!hasPrefix) { + return { + isValid: false, + hasExpression: true, + needsPrefix: true, + isMixedContent, + confidence: 1.0, + suggestion: `${this.EXPRESSION_PREFIX}${value}`, + explanation: isMixedContent + ? 'Mixed literal text and expression requires = prefix for expression evaluation' + : 'Expression requires = prefix to be evaluated' + }; + } + + return { + isValid: true, + hasExpression: true, + needsPrefix: false, + isMixedContent, + confidence: 1.0, + explanation: 'Expression is properly formatted with = prefix' + }; + } + + /** + * Check if a string contains both literal text and expressions + * Examples: + * - "Hello {{ $json.name }}" -> mixed content + * - "{{ $json.value }}" -> pure expression + * - "https://api.com/{{ $json.id }}" -> mixed content + */ + private static hasMixedContent(value: string): boolean { + // Remove the = prefix if present for analysis + const content = value.startsWith(this.EXPRESSION_PREFIX) + ? value.substring(1) + : value; + + // Check if there's any content outside of {{ }} + const withoutExpressions = content.replace(/\{\{[\s\S]+?\}\}/g, ''); + return withoutExpressions.trim().length > 0; + } + + /** + * Universal Rule 2: Expression syntax validation + * Check for common syntax errors that prevent evaluation + */ + static validateExpressionSyntax(value: string): UniversalValidationResult { + // First, check if there's any expression pattern at all + const hasAnyBrackets = value.includes('{{') || value.includes('}}'); + + if (!hasAnyBrackets) { + return { + isValid: true, + hasExpression: false, + needsPrefix: false, + isMixedContent: false, + confidence: 1.0, + explanation: 'No expression to validate' + }; + } + + // Check for unclosed brackets in the entire string + const openCount = (value.match(/\{\{/g) || []).length; + const closeCount = (value.match(/\}\}/g) || []).length; + + if (openCount !== closeCount) { + return { + isValid: false, + hasExpression: true, + needsPrefix: false, + isMixedContent: false, + confidence: 1.0, + explanation: `Unmatched expression brackets: ${openCount} opening, ${closeCount} closing` + }; + } + + // Extract properly matched expressions for further validation + const expressions = value.match(/\{\{[\s\S]+?\}\}/g) || []; + + for (const expr of expressions) { + // Check for empty expressions + const content = expr.slice(2, -2).trim(); + if (!content) { + return { + isValid: false, + hasExpression: true, + needsPrefix: false, + isMixedContent: false, + confidence: 1.0, + explanation: 'Empty expression {{ }} is not valid' + }; + } + } + + return { + isValid: true, + hasExpression: expressions.length > 0, + needsPrefix: false, + isMixedContent: this.hasMixedContent(value), + confidence: 1.0, + explanation: 'Expression syntax is valid' + }; + } + + /** + * Universal Rule 3: Common n8n expression patterns + * Validate against known n8n expression patterns + */ + static validateCommonPatterns(value: string): UniversalValidationResult { + if (!this.EXPRESSION_PATTERN.test(value)) { + return { + isValid: true, + hasExpression: false, + needsPrefix: false, + isMixedContent: false, + confidence: 1.0, + explanation: 'No expression to validate' + }; + } + + const expressions = value.match(/\{\{[\s\S]+?\}\}/g) || []; + const warnings: string[] = []; + + for (const expr of expressions) { + const content = expr.slice(2, -2).trim(); + + // Check for common mistakes + if (content.includes('${') && content.includes('}')) { + warnings.push(`Template literal syntax \${} found - use n8n syntax instead: ${expr}`); + } + + if (content.startsWith('=')) { + warnings.push(`Double prefix detected in expression: ${expr}`); + } + + if (content.includes('{{') || content.includes('}}')) { + warnings.push(`Nested brackets detected: ${expr}`); + } + } + + if (warnings.length > 0) { + return { + isValid: false, + hasExpression: true, + needsPrefix: false, + isMixedContent: false, + confidence: 1.0, + explanation: warnings.join('; ') + }; + } + + return { + isValid: true, + hasExpression: true, + needsPrefix: false, + isMixedContent: this.hasMixedContent(value), + confidence: 1.0, + explanation: 'Expression patterns are valid' + }; + } + + /** + * Perform all universal validations + */ + static validate(value: any): UniversalValidationResult[] { + const results: UniversalValidationResult[] = []; + + // Run all universal validators + const prefixResult = this.validateExpressionPrefix(value); + if (!prefixResult.isValid) { + results.push(prefixResult); + } + + if (typeof value === 'string') { + const syntaxResult = this.validateExpressionSyntax(value); + if (!syntaxResult.isValid) { + results.push(syntaxResult); + } + + const patternResult = this.validateCommonPatterns(value); + if (!patternResult.isValid) { + results.push(patternResult); + } + } + + // If no issues found, return a success result + if (results.length === 0) { + results.push({ + isValid: true, + hasExpression: prefixResult.hasExpression, + needsPrefix: false, + isMixedContent: prefixResult.isMixedContent, + confidence: 1.0, + explanation: prefixResult.hasExpression + ? 'Expression is valid' + : 'No expression found' + }); + } + + return results; + } + + /** + * Get a corrected version of the value + */ + static getCorrectedValue(value: string): string { + if (!this.EXPRESSION_PATTERN.test(value)) { + return value; + } + + if (!value.startsWith(this.EXPRESSION_PREFIX)) { + return `${this.EXPRESSION_PREFIX}${value}`; + } + + return value; + } +} \ No newline at end of file diff --git a/src/services/workflow-validator.ts b/src/services/workflow-validator.ts index 0cec406..76e4a9f 100644 --- a/src/services/workflow-validator.ts +++ b/src/services/workflow-validator.ts @@ -6,6 +6,7 @@ import { NodeRepository } from '../database/node-repository'; import { EnhancedConfigValidator } from './enhanced-config-validator'; import { ExpressionValidator } from './expression-validator'; +import { ExpressionFormatValidator } from './expression-format-validator'; import { Logger } from '../utils/logger'; const logger = new Logger({ prefix: '[WorkflowValidator]' }); @@ -991,6 +992,39 @@ export class WorkflowValidator { message: `Expression warning: ${warning}` }); }); + + // Validate expression format (check for missing = prefix and resource locator format) + const formatContext = { + nodeType: node.type, + nodeName: node.name, + nodeId: node.id + }; + + const formatIssues = ExpressionFormatValidator.validateNodeParameters( + node.parameters, + formatContext + ); + + // Add format errors and warnings + formatIssues.forEach(issue => { + const formattedMessage = ExpressionFormatValidator.formatErrorMessage(issue, formatContext); + + if (issue.severity === 'error') { + result.errors.push({ + type: 'error', + nodeId: node.id, + nodeName: node.name, + message: formattedMessage + }); + } else { + result.warnings.push({ + type: 'warning', + nodeId: node.id, + nodeName: node.name, + message: formattedMessage + }); + } + }); } } diff --git a/src/types/n8n-api.ts b/src/types/n8n-api.ts index 78d88c5..95e991b 100644 --- a/src/types/n8n-api.ts +++ b/src/types/n8n-api.ts @@ -1,6 +1,16 @@ // n8n API Types - Ported from n8n-manager-for-ai-agents // These types define the structure of n8n API requests and responses +// Resource Locator Types +export interface ResourceLocatorValue { + __rl: true; + value: string; + mode: 'id' | 'url' | 'expression' | string; +} + +// Expression Format Types +export type ExpressionValue = string | ResourceLocatorValue; + // Workflow Node Types export interface WorkflowNode { id: string; diff --git a/tests/unit/services/confidence-scorer.test.ts b/tests/unit/services/confidence-scorer.test.ts new file mode 100644 index 0000000..9113b23 --- /dev/null +++ b/tests/unit/services/confidence-scorer.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { ConfidenceScorer } from '../../../src/services/confidence-scorer'; + +describe('ConfidenceScorer', () => { + describe('scoreResourceLocatorRecommendation', () => { + it('should give high confidence for exact field matches', () => { + const score = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'owner', + 'n8n-nodes-base.github', + '={{ $json.owner }}' + ); + + expect(score.value).toBeGreaterThanOrEqual(0.5); + expect(score.factors.find(f => f.name === 'exact-field-match')?.matched).toBe(true); + }); + + it('should give medium confidence for field pattern matches', () => { + const score = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'customerId', + 'n8n-nodes-base.customApi', + '={{ $json.id }}' + ); + + expect(score.value).toBeGreaterThan(0); + expect(score.value).toBeLessThan(0.8); + expect(score.factors.find(f => f.name === 'field-pattern')?.matched).toBe(true); + }); + + it('should give low confidence for unrelated fields', () => { + const score = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'message', + 'n8n-nodes-base.emailSend', + '={{ $json.content }}' + ); + + expect(score.value).toBeLessThan(0.3); + }); + + it('should consider value patterns', () => { + const score = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'target', + 'n8n-nodes-base.httpRequest', + '={{ $json.userId }}' + ); + + const valueFactor = score.factors.find(f => f.name === 'value-pattern'); + expect(valueFactor?.matched).toBe(true); + }); + + it('should consider node category', () => { + const scoreGitHub = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'field', + 'n8n-nodes-base.github', + '={{ $json.value }}' + ); + + const scoreEmail = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'field', + 'n8n-nodes-base.emailSend', + '={{ $json.value }}' + ); + + expect(scoreGitHub.value).toBeGreaterThan(scoreEmail.value); + }); + + it('should handle GitHub repository field with high confidence', () => { + const score = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'repository', + 'n8n-nodes-base.github', + '={{ $vars.GITHUB_REPO }}' + ); + + expect(score.value).toBeGreaterThanOrEqual(0.5); + expect(ConfidenceScorer.getConfidenceLevel(score.value)).not.toBe('very-low'); + }); + + it('should handle Slack channel field with high confidence', () => { + const score = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'channel', + 'n8n-nodes-base.slack', + '={{ $json.channelId }}' + ); + + expect(score.value).toBeGreaterThanOrEqual(0.5); + }); + }); + + describe('getConfidenceLevel', () => { + it('should return correct confidence levels', () => { + expect(ConfidenceScorer.getConfidenceLevel(0.9)).toBe('high'); + expect(ConfidenceScorer.getConfidenceLevel(0.8)).toBe('high'); + expect(ConfidenceScorer.getConfidenceLevel(0.6)).toBe('medium'); + expect(ConfidenceScorer.getConfidenceLevel(0.5)).toBe('medium'); + expect(ConfidenceScorer.getConfidenceLevel(0.4)).toBe('low'); + expect(ConfidenceScorer.getConfidenceLevel(0.3)).toBe('low'); + expect(ConfidenceScorer.getConfidenceLevel(0.2)).toBe('very-low'); + expect(ConfidenceScorer.getConfidenceLevel(0)).toBe('very-low'); + }); + }); + + describe('shouldApplyRecommendation', () => { + it('should apply based on threshold', () => { + // Strict threshold (0.8) + expect(ConfidenceScorer.shouldApplyRecommendation(0.9, 'strict')).toBe(true); + expect(ConfidenceScorer.shouldApplyRecommendation(0.7, 'strict')).toBe(false); + + // Normal threshold (0.5) + expect(ConfidenceScorer.shouldApplyRecommendation(0.6, 'normal')).toBe(true); + expect(ConfidenceScorer.shouldApplyRecommendation(0.4, 'normal')).toBe(false); + + // Relaxed threshold (0.3) + expect(ConfidenceScorer.shouldApplyRecommendation(0.4, 'relaxed')).toBe(true); + expect(ConfidenceScorer.shouldApplyRecommendation(0.2, 'relaxed')).toBe(false); + }); + + it('should use normal threshold by default', () => { + expect(ConfidenceScorer.shouldApplyRecommendation(0.6)).toBe(true); + expect(ConfidenceScorer.shouldApplyRecommendation(0.4)).toBe(false); + }); + }); + + describe('confidence factors', () => { + it('should include all expected factors', () => { + const score = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'testField', + 'n8n-nodes-base.testNode', + '={{ $json.test }}' + ); + + expect(score.factors).toHaveLength(4); + expect(score.factors.map(f => f.name)).toContain('exact-field-match'); + expect(score.factors.map(f => f.name)).toContain('field-pattern'); + expect(score.factors.map(f => f.name)).toContain('value-pattern'); + expect(score.factors.map(f => f.name)).toContain('node-category'); + }); + + it('should have reasonable weights', () => { + const score = ConfidenceScorer.scoreResourceLocatorRecommendation( + 'testField', + 'n8n-nodes-base.testNode', + '={{ $json.test }}' + ); + + const totalWeight = score.factors.reduce((sum, f) => sum + f.weight, 0); + expect(totalWeight).toBeCloseTo(1.0, 1); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/expression-format-validator.test.ts b/tests/unit/services/expression-format-validator.test.ts new file mode 100644 index 0000000..0d9b43a --- /dev/null +++ b/tests/unit/services/expression-format-validator.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect } from 'vitest'; +import { ExpressionFormatValidator } from '../../../src/services/expression-format-validator'; + +describe('ExpressionFormatValidator', () => { + describe('validateAndFix', () => { + const context = { + nodeType: 'n8n-nodes-base.httpRequest', + nodeName: 'HTTP Request', + nodeId: 'test-id-1' + }; + + describe('Simple string expressions', () => { + it('should detect missing = prefix for expression', () => { + const value = '{{ $env.API_KEY }}'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'apiKey', context); + + expect(issue).toBeTruthy(); + expect(issue?.issueType).toBe('missing-prefix'); + expect(issue?.correctedValue).toBe('={{ $env.API_KEY }}'); + expect(issue?.severity).toBe('error'); + }); + + it('should accept expression with = prefix', () => { + const value = '={{ $env.API_KEY }}'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'apiKey', context); + + expect(issue).toBeNull(); + }); + + it('should detect mixed content without prefix', () => { + const value = 'Bearer {{ $env.TOKEN }}'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'authorization', context); + + expect(issue).toBeTruthy(); + expect(issue?.issueType).toBe('missing-prefix'); + expect(issue?.correctedValue).toBe('=Bearer {{ $env.TOKEN }}'); + }); + + it('should accept mixed content with prefix', () => { + const value = '=Bearer {{ $env.TOKEN }}'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'authorization', context); + + expect(issue).toBeNull(); + }); + + it('should ignore plain strings without expressions', () => { + const value = 'https://api.example.com'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'url', context); + + expect(issue).toBeNull(); + }); + }); + + describe('Resource Locator fields', () => { + const githubContext = { + nodeType: 'n8n-nodes-base.github', + nodeName: 'GitHub', + nodeId: 'github-1' + }; + + it('should detect expression in owner field needing resource locator', () => { + const value = '{{ $vars.GITHUB_OWNER }}'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); + + expect(issue).toBeTruthy(); + expect(issue?.issueType).toBe('needs-resource-locator'); + expect(issue?.correctedValue).toEqual({ + __rl: true, + value: '={{ $vars.GITHUB_OWNER }}', + mode: 'expression' + }); + expect(issue?.severity).toBe('error'); + }); + + it('should accept resource locator with expression', () => { + const value = { + __rl: true, + value: '={{ $vars.GITHUB_OWNER }}', + mode: 'expression' + }; + const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); + + expect(issue).toBeNull(); + }); + + it('should detect missing prefix in resource locator value', () => { + const value = { + __rl: true, + value: '{{ $vars.GITHUB_OWNER }}', + mode: 'expression' + }; + const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); + + expect(issue).toBeTruthy(); + expect(issue?.issueType).toBe('missing-prefix'); + expect(issue?.correctedValue.value).toBe('={{ $vars.GITHUB_OWNER }}'); + }); + + it('should warn if expression has prefix but should use RL format', () => { + const value = '={{ $vars.GITHUB_OWNER }}'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext); + + expect(issue).toBeTruthy(); + expect(issue?.issueType).toBe('needs-resource-locator'); + expect(issue?.severity).toBe('warning'); + }); + }); + + describe('Multiple expressions', () => { + it('should detect multiple expressions without prefix', () => { + const value = '{{ $json.first }} - {{ $json.last }}'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'fullName', context); + + expect(issue).toBeTruthy(); + expect(issue?.issueType).toBe('missing-prefix'); + expect(issue?.correctedValue).toBe('={{ $json.first }} - {{ $json.last }}'); + }); + + it('should accept multiple expressions with prefix', () => { + const value = '={{ $json.first }} - {{ $json.last }}'; + const issue = ExpressionFormatValidator.validateAndFix(value, 'fullName', context); + + expect(issue).toBeNull(); + }); + }); + + describe('Edge cases', () => { + it('should handle null values', () => { + const issue = ExpressionFormatValidator.validateAndFix(null, 'field', context); + expect(issue).toBeNull(); + }); + + it('should handle undefined values', () => { + const issue = ExpressionFormatValidator.validateAndFix(undefined, 'field', context); + expect(issue).toBeNull(); + }); + + it('should handle empty strings', () => { + const issue = ExpressionFormatValidator.validateAndFix('', 'field', context); + expect(issue).toBeNull(); + }); + + it('should handle numbers', () => { + const issue = ExpressionFormatValidator.validateAndFix(42, 'field', context); + expect(issue).toBeNull(); + }); + + it('should handle booleans', () => { + const issue = ExpressionFormatValidator.validateAndFix(true, 'field', context); + expect(issue).toBeNull(); + }); + + it('should handle arrays', () => { + const issue = ExpressionFormatValidator.validateAndFix(['item1', 'item2'], 'field', context); + expect(issue).toBeNull(); + }); + }); + }); + + describe('validateNodeParameters', () => { + const context = { + nodeType: 'n8n-nodes-base.emailSend', + nodeName: 'Send Email', + nodeId: 'email-1' + }; + + it('should validate all parameters recursively', () => { + const parameters = { + fromEmail: '{{ $env.SENDER_EMAIL }}', + toEmail: 'user@example.com', + subject: 'Test {{ $json.type }}', + body: { + html: '

Hello {{ $json.name }}

', + text: 'Hello {{ $json.name }}' + }, + options: { + replyTo: '={{ $env.REPLY_EMAIL }}' + } + }; + + const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); + + expect(issues).toHaveLength(4); + expect(issues.map(i => i.fieldPath)).toContain('fromEmail'); + expect(issues.map(i => i.fieldPath)).toContain('subject'); + expect(issues.map(i => i.fieldPath)).toContain('body.html'); + expect(issues.map(i => i.fieldPath)).toContain('body.text'); + }); + + it('should handle arrays with expressions', () => { + const parameters = { + recipients: [ + '{{ $json.email1 }}', + 'static@example.com', + '={{ $json.email2 }}' + ] + }; + + const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); + + expect(issues).toHaveLength(1); + expect(issues[0].fieldPath).toBe('recipients[0]'); + expect(issues[0].correctedValue).toBe('={{ $json.email1 }}'); + }); + + it('should handle nested objects', () => { + const parameters = { + config: { + database: { + host: '{{ $env.DB_HOST }}', + port: 5432, + name: 'mydb' + } + } + }; + + const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); + + expect(issues).toHaveLength(1); + expect(issues[0].fieldPath).toBe('config.database.host'); + }); + + it('should skip circular references', () => { + const circular: any = { a: 1 }; + circular.self = circular; + + const parameters = { + normal: '{{ $json.value }}', + circular + }; + + const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); + + // Should only find the issue in 'normal', not crash on circular + expect(issues).toHaveLength(1); + expect(issues[0].fieldPath).toBe('normal'); + }); + + it('should handle maximum recursion depth', () => { + // Create a deeply nested object (105 levels deep, exceeding the limit of 100) + let deepObject: any = { value: '{{ $json.data }}' }; + let current = deepObject; + for (let i = 0; i < 105; i++) { + current.nested = { value: `{{ $json.level${i} }}` }; + current = current.nested; + } + + const parameters = { + deep: deepObject + }; + + const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); + + // Should find expression format issues up to the depth limit + const depthWarning = issues.find(i => i.explanation.includes('Maximum recursion depth')); + expect(depthWarning).toBeTruthy(); + expect(depthWarning?.severity).toBe('warning'); + + // Should still find some expression format errors before hitting the limit + const formatErrors = issues.filter(i => i.issueType === 'missing-prefix'); + expect(formatErrors.length).toBeGreaterThan(0); + expect(formatErrors.length).toBeLessThanOrEqual(100); // Should not exceed the depth limit + }); + }); + + describe('formatErrorMessage', () => { + const context = { + nodeType: 'n8n-nodes-base.github', + nodeName: 'Create Issue', + nodeId: 'github-1' + }; + + it('should format error message for missing prefix', () => { + const issue = { + fieldPath: 'title', + currentValue: '{{ $json.title }}', + correctedValue: '={{ $json.title }}', + issueType: 'missing-prefix' as const, + explanation: "Expression missing required '=' prefix.", + severity: 'error' as const + }; + + const message = ExpressionFormatValidator.formatErrorMessage(issue, context); + + expect(message).toContain("Expression format error in node 'Create Issue'"); + expect(message).toContain('Field \'title\''); + expect(message).toContain('Current (incorrect):'); + expect(message).toContain('"title": "{{ $json.title }}"'); + expect(message).toContain('Fixed (correct):'); + expect(message).toContain('"title": "={{ $json.title }}"'); + }); + + it('should format error message for resource locator', () => { + const issue = { + fieldPath: 'owner', + currentValue: '{{ $vars.OWNER }}', + correctedValue: { + __rl: true, + value: '={{ $vars.OWNER }}', + mode: 'expression' + }, + issueType: 'needs-resource-locator' as const, + explanation: 'Field needs resource locator format.', + severity: 'error' as const + }; + + const message = ExpressionFormatValidator.formatErrorMessage(issue, context); + + expect(message).toContain("Expression format error in node 'Create Issue'"); + expect(message).toContain('Current (incorrect):'); + expect(message).toContain('"owner": "{{ $vars.OWNER }}"'); + expect(message).toContain('Fixed (correct):'); + expect(message).toContain('"__rl": true'); + expect(message).toContain('"value": "={{ $vars.OWNER }}"'); + expect(message).toContain('"mode": "expression"'); + }); + }); + + describe('Real-world examples', () => { + it('should validate Email Send node example', () => { + const context = { + nodeType: 'n8n-nodes-base.emailSend', + nodeName: 'Error Handler', + nodeId: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0' + }; + + const parameters = { + fromEmail: '{{ $env.ADMIN_EMAIL }}', + toEmail: 'admin@company.com', + subject: 'GitHub Issue Workflow Error - HIGH PRIORITY', + options: {} + }; + + const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); + + expect(issues).toHaveLength(1); + expect(issues[0].fieldPath).toBe('fromEmail'); + expect(issues[0].correctedValue).toBe('={{ $env.ADMIN_EMAIL }}'); + }); + + it('should validate GitHub node example', () => { + const context = { + nodeType: 'n8n-nodes-base.github', + nodeName: 'Send Welcome Comment', + nodeId: '3c742ca1-af8f-4d80-a47e-e68fb1ced491' + }; + + const parameters = { + operation: 'createComment', + owner: '{{ $vars.GITHUB_OWNER }}', + repository: '{{ $vars.GITHUB_REPO }}', + issueNumber: null, + body: '๐Ÿ‘‹ Hi @{{ $(\'Extract Issue Data\').first().json.author }}!\n\nThank you for creating this issue.' + }; + + const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); + + expect(issues.length).toBeGreaterThan(0); + expect(issues.some(i => i.fieldPath === 'owner')).toBe(true); + expect(issues.some(i => i.fieldPath === 'repository')).toBe(true); + expect(issues.some(i => i.fieldPath === 'body')).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/universal-expression-validator.test.ts b/tests/unit/services/universal-expression-validator.test.ts new file mode 100644 index 0000000..fd1b86e --- /dev/null +++ b/tests/unit/services/universal-expression-validator.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { UniversalExpressionValidator } from '../../../src/services/universal-expression-validator'; + +describe('UniversalExpressionValidator', () => { + describe('validateExpressionPrefix', () => { + it('should detect missing prefix in pure expression', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix('{{ $json.value }}'); + + expect(result.isValid).toBe(false); + expect(result.hasExpression).toBe(true); + expect(result.needsPrefix).toBe(true); + expect(result.isMixedContent).toBe(false); + expect(result.confidence).toBe(1.0); + expect(result.suggestion).toBe('={{ $json.value }}'); + }); + + it('should detect missing prefix in mixed content', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix( + 'Hello {{ $json.name }}' + ); + + expect(result.isValid).toBe(false); + expect(result.hasExpression).toBe(true); + expect(result.needsPrefix).toBe(true); + expect(result.isMixedContent).toBe(true); + expect(result.confidence).toBe(1.0); + expect(result.suggestion).toBe('=Hello {{ $json.name }}'); + }); + + it('should accept properly prefixed expression', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix('={{ $json.value }}'); + + expect(result.isValid).toBe(true); + expect(result.hasExpression).toBe(true); + expect(result.needsPrefix).toBe(false); + expect(result.confidence).toBe(1.0); + }); + + it('should accept properly prefixed mixed content', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix( + '=Hello {{ $json.name }}!' + ); + + expect(result.isValid).toBe(true); + expect(result.hasExpression).toBe(true); + expect(result.isMixedContent).toBe(true); + expect(result.confidence).toBe(1.0); + }); + + it('should ignore non-string values', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix(123); + + expect(result.isValid).toBe(true); + expect(result.hasExpression).toBe(false); + expect(result.confidence).toBe(1.0); + }); + + it('should ignore strings without expressions', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix('plain text'); + + expect(result.isValid).toBe(true); + expect(result.hasExpression).toBe(false); + expect(result.confidence).toBe(1.0); + }); + }); + + describe('validateExpressionSyntax', () => { + it('should detect unclosed brackets', () => { + const result = UniversalExpressionValidator.validateExpressionSyntax('={{ $json.value }'); + + expect(result.isValid).toBe(false); + expect(result.explanation).toContain('Unmatched expression brackets'); + }); + + it('should detect empty expressions', () => { + const result = UniversalExpressionValidator.validateExpressionSyntax('={{ }}'); + + expect(result.isValid).toBe(false); + expect(result.explanation).toContain('Empty expression'); + }); + + it('should accept valid syntax', () => { + const result = UniversalExpressionValidator.validateExpressionSyntax('={{ $json.value }}'); + + expect(result.isValid).toBe(true); + expect(result.hasExpression).toBe(true); + }); + + it('should handle multiple expressions', () => { + const result = UniversalExpressionValidator.validateExpressionSyntax( + '={{ $json.first }} and {{ $json.second }}' + ); + + expect(result.isValid).toBe(true); + expect(result.hasExpression).toBe(true); + expect(result.isMixedContent).toBe(true); + }); + }); + + describe('validateCommonPatterns', () => { + it('should detect template literal syntax', () => { + const result = UniversalExpressionValidator.validateCommonPatterns('={{ ${json.value} }}'); + + expect(result.isValid).toBe(false); + expect(result.explanation).toContain('Template literal syntax'); + }); + + it('should detect double prefix', () => { + const result = UniversalExpressionValidator.validateCommonPatterns('={{ =$json.value }}'); + + expect(result.isValid).toBe(false); + expect(result.explanation).toContain('Double prefix'); + }); + + it('should detect nested brackets', () => { + const result = UniversalExpressionValidator.validateCommonPatterns( + '={{ $json.items[{{ $json.index }}] }}' + ); + + expect(result.isValid).toBe(false); + expect(result.explanation).toContain('Nested brackets'); + }); + + it('should accept valid patterns', () => { + const result = UniversalExpressionValidator.validateCommonPatterns( + '={{ $json.items[$json.index] }}' + ); + + expect(result.isValid).toBe(true); + }); + }); + + describe('validate (comprehensive)', () => { + it('should return all validation issues', () => { + const results = UniversalExpressionValidator.validate('{{ ${json.value} }}'); + + expect(results.length).toBeGreaterThan(0); + const issues = results.filter(r => !r.isValid); + expect(issues.length).toBeGreaterThan(0); + + // Should detect both missing prefix and template literal syntax + const prefixIssue = issues.find(i => i.needsPrefix); + const patternIssue = issues.find(i => i.explanation.includes('Template literal')); + + expect(prefixIssue).toBeTruthy(); + expect(patternIssue).toBeTruthy(); + }); + + it('should return success for valid expression', () => { + const results = UniversalExpressionValidator.validate('={{ $json.value }}'); + + expect(results).toHaveLength(1); + expect(results[0].isValid).toBe(true); + expect(results[0].confidence).toBe(1.0); + }); + + it('should handle non-expression strings', () => { + const results = UniversalExpressionValidator.validate('plain text'); + + expect(results).toHaveLength(1); + expect(results[0].isValid).toBe(true); + expect(results[0].hasExpression).toBe(false); + }); + }); + + describe('getCorrectedValue', () => { + it('should add prefix to expression', () => { + const corrected = UniversalExpressionValidator.getCorrectedValue('{{ $json.value }}'); + expect(corrected).toBe('={{ $json.value }}'); + }); + + it('should add prefix to mixed content', () => { + const corrected = UniversalExpressionValidator.getCorrectedValue( + 'Hello {{ $json.name }}' + ); + expect(corrected).toBe('=Hello {{ $json.name }}'); + }); + + it('should not modify already prefixed expressions', () => { + const corrected = UniversalExpressionValidator.getCorrectedValue('={{ $json.value }}'); + expect(corrected).toBe('={{ $json.value }}'); + }); + + it('should not modify non-expressions', () => { + const corrected = UniversalExpressionValidator.getCorrectedValue('plain text'); + expect(corrected).toBe('plain text'); + }); + }); + + describe('hasMixedContent', () => { + it('should detect URLs with expressions', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix( + 'https://api.example.com/users/{{ $json.id }}' + ); + expect(result.isMixedContent).toBe(true); + }); + + it('should detect text with expressions', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix( + 'Welcome {{ $json.name }} to our service' + ); + expect(result.isMixedContent).toBe(true); + }); + + it('should identify pure expressions', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix('{{ $json.value }}'); + expect(result.isMixedContent).toBe(false); + }); + + it('should identify pure expressions with spaces', () => { + const result = UniversalExpressionValidator.validateExpressionPrefix( + ' {{ $json.value }} ' + ); + expect(result.isMixedContent).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/workflow-validator-expression-format.test.ts b/tests/unit/services/workflow-validator-expression-format.test.ts new file mode 100644 index 0000000..af55ce3 --- /dev/null +++ b/tests/unit/services/workflow-validator-expression-format.test.ts @@ -0,0 +1,488 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { WorkflowValidator } from '../../../src/services/workflow-validator'; +import { NodeRepository } from '../../../src/database/node-repository'; +import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; + +// Mock the database +vi.mock('../../../src/database/node-repository'); + +describe('WorkflowValidator - Expression Format Validation', () => { + let validator: WorkflowValidator; + let mockNodeRepository: any; + + beforeEach(() => { + // Create mock repository + mockNodeRepository = { + findNodeByType: vi.fn().mockImplementation((type: string) => { + // Return mock nodes for common types + if (type === 'n8n-nodes-base.emailSend') { + return { + node_type: 'n8n-nodes-base.emailSend', + display_name: 'Email Send', + properties: {}, + version: 2.1 + }; + } + if (type === 'n8n-nodes-base.github') { + return { + node_type: 'n8n-nodes-base.github', + display_name: 'GitHub', + properties: {}, + version: 1.1 + }; + } + if (type === 'n8n-nodes-base.webhook') { + return { + node_type: 'n8n-nodes-base.webhook', + display_name: 'Webhook', + properties: {}, + version: 1 + }; + } + if (type === 'n8n-nodes-base.httpRequest') { + return { + node_type: 'n8n-nodes-base.httpRequest', + display_name: 'HTTP Request', + properties: {}, + version: 4 + }; + } + return null; + }), + searchNodes: vi.fn().mockReturnValue([]), + getAllNodes: vi.fn().mockReturnValue([]), + close: vi.fn() + }; + + validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator); + }); + + describe('Expression Format Detection', () => { + it('should detect missing = prefix in simple expressions', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Send Email', + type: 'n8n-nodes-base.emailSend', + position: [0, 0] as [number, number], + parameters: { + fromEmail: '{{ $env.SENDER_EMAIL }}', + toEmail: 'user@example.com', + subject: 'Test Email' + }, + typeVersion: 2.1 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + expect(result.valid).toBe(false); + + // Find expression format errors + const formatErrors = result.errors.filter(e => e.message.includes('Expression format error')); + expect(formatErrors).toHaveLength(1); + + const error = formatErrors[0]; + expect(error.message).toContain('Expression format error'); + expect(error.message).toContain('fromEmail'); + expect(error.message).toContain('{{ $env.SENDER_EMAIL }}'); + expect(error.message).toContain('={{ $env.SENDER_EMAIL }}'); + }); + + it('should detect missing resource locator format for GitHub fields', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'GitHub', + type: 'n8n-nodes-base.github', + position: [0, 0] as [number, number], + parameters: { + operation: 'createComment', + owner: '{{ $vars.GITHUB_OWNER }}', + repository: '{{ $vars.GITHUB_REPO }}', + issueNumber: 123, + body: 'Test comment' + }, + typeVersion: 1.1 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + expect(result.valid).toBe(false); + // Should have errors for both owner and repository + const ownerError = result.errors.find(e => e.message.includes('owner')); + const repoError = result.errors.find(e => e.message.includes('repository')); + + expect(ownerError).toBeTruthy(); + expect(repoError).toBeTruthy(); + expect(ownerError?.message).toContain('resource locator format'); + expect(ownerError?.message).toContain('__rl'); + }); + + it('should detect mixed content without prefix', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + position: [0, 0] as [number, number], + parameters: { + url: 'https://api.example.com/{{ $json.endpoint }}', + headers: { + Authorization: 'Bearer {{ $env.API_TOKEN }}' + } + }, + typeVersion: 4 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + expect(result.valid).toBe(false); + const errors = result.errors.filter(e => e.message.includes('Expression format')); + expect(errors.length).toBeGreaterThan(0); + + // Check for URL error + const urlError = errors.find(e => e.message.includes('url')); + expect(urlError).toBeTruthy(); + expect(urlError?.message).toContain('=https://api.example.com/{{ $json.endpoint }}'); + }); + + it('should accept properly formatted expressions', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Send Email', + type: 'n8n-nodes-base.emailSend', + position: [0, 0] as [number, number], + parameters: { + fromEmail: '={{ $env.SENDER_EMAIL }}', + toEmail: 'user@example.com', + subject: '=Test {{ $json.type }}' + }, + typeVersion: 2.1 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + // Should have no expression format errors + const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); + expect(formatErrors).toHaveLength(0); + }); + + it('should accept resource locator format', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'GitHub', + type: 'n8n-nodes-base.github', + position: [0, 0] as [number, number], + parameters: { + operation: 'createComment', + owner: { + __rl: true, + value: '={{ $vars.GITHUB_OWNER }}', + mode: 'expression' + }, + repository: { + __rl: true, + value: '={{ $vars.GITHUB_REPO }}', + mode: 'expression' + }, + issueNumber: 123, + body: '=Test comment from {{ $json.author }}' + }, + typeVersion: 1.1 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + // Should have no expression format errors + const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); + expect(formatErrors).toHaveLength(0); + }); + + it('should validate nested expressions in complex parameters', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + position: [0, 0] as [number, number], + parameters: { + method: 'POST', + url: 'https://api.example.com', + sendBody: true, + bodyParameters: { + parameters: [ + { + name: 'userId', + value: '{{ $json.id }}' + }, + { + name: 'timestamp', + value: '={{ $now }}' + } + ] + } + }, + typeVersion: 4 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + // Should detect the missing prefix in nested parameter + const errors = result.errors.filter(e => e.message.includes('Expression format')); + expect(errors.length).toBeGreaterThan(0); + + const nestedError = errors.find(e => e.message.includes('bodyParameters')); + expect(nestedError).toBeTruthy(); + }); + + it('should warn about RL format even with prefix', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'GitHub', + type: 'n8n-nodes-base.github', + position: [0, 0] as [number, number], + parameters: { + operation: 'createComment', + owner: '={{ $vars.GITHUB_OWNER }}', + repository: '={{ $vars.GITHUB_REPO }}', + issueNumber: 123, + body: 'Test' + }, + typeVersion: 1.1 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + // Should have warnings about using RL format + const warnings = result.warnings.filter(w => w.message.includes('resource locator format')); + expect(warnings.length).toBeGreaterThan(0); + }); + }); + + describe('Real-world workflow examples', () => { + it('should validate Email workflow with expression issues', async () => { + const workflow = { + name: 'Error Notification Workflow', + nodes: [ + { + id: 'webhook-1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + position: [250, 300] as [number, number], + parameters: { + path: 'error-handler', + httpMethod: 'POST' + }, + typeVersion: 1 + }, + { + id: 'email-1', + name: 'Error Handler', + type: 'n8n-nodes-base.emailSend', + position: [450, 300] as [number, number], + parameters: { + fromEmail: '{{ $env.ADMIN_EMAIL }}', + toEmail: 'admin@company.com', + subject: 'Error in {{ $json.workflow }}', + message: 'An error occurred: {{ $json.error }}', + options: { + replyTo: '={{ $env.SUPPORT_EMAIL }}' + } + }, + typeVersion: 2.1 + } + ], + connections: { + 'Webhook': { + main: [[{ node: 'Error Handler', type: 'main', index: 0 }]] + } + } + }; + + const result = await validator.validateWorkflow(workflow); + + // Should have multiple expression format errors + const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); + expect(formatErrors.length).toBeGreaterThanOrEqual(3); // fromEmail, subject, message + + // Check specific errors + const fromEmailError = formatErrors.find(e => e.message.includes('fromEmail')); + expect(fromEmailError).toBeTruthy(); + expect(fromEmailError?.message).toContain('={{ $env.ADMIN_EMAIL }}'); + }); + + it('should validate GitHub workflow with resource locator issues', async () => { + const workflow = { + name: 'GitHub Issue Handler', + nodes: [ + { + id: 'webhook-1', + name: 'Issue Webhook', + type: 'n8n-nodes-base.webhook', + position: [250, 300] as [number, number], + parameters: { + path: 'github-issue', + httpMethod: 'POST' + }, + typeVersion: 1 + }, + { + id: 'github-1', + name: 'Create Comment', + type: 'n8n-nodes-base.github', + position: [450, 300] as [number, number], + parameters: { + operation: 'createComment', + owner: '{{ $vars.GITHUB_OWNER }}', + repository: '{{ $vars.GITHUB_REPO }}', + issueNumber: '={{ $json.body.issue.number }}', + body: 'Thanks for the issue @{{ $json.body.issue.user.login }}!' + }, + typeVersion: 1.1 + } + ], + connections: { + 'Issue Webhook': { + main: [[{ node: 'Create Comment', type: 'main', index: 0 }]] + } + } + }; + + const result = await validator.validateWorkflow(workflow); + + // Should have errors for owner, repository, and body + const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); + expect(formatErrors.length).toBeGreaterThanOrEqual(3); + + // Check for resource locator suggestions + const ownerError = formatErrors.find(e => e.message.includes('owner')); + expect(ownerError?.message).toContain('__rl'); + expect(ownerError?.message).toContain('resource locator format'); + }); + + it('should provide clear fix examples in error messages', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Process Data', + type: 'n8n-nodes-base.httpRequest', + position: [0, 0] as [number, number], + parameters: { + url: 'https://api.example.com/users/{{ $json.userId }}' + }, + typeVersion: 4 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + const error = result.errors.find(e => e.message.includes('Expression format')); + expect(error).toBeTruthy(); + + // Error message should contain both incorrect and correct examples + expect(error?.message).toContain('Current (incorrect):'); + expect(error?.message).toContain('"url": "https://api.example.com/users/{{ $json.userId }}"'); + expect(error?.message).toContain('Fixed (correct):'); + expect(error?.message).toContain('"url": "=https://api.example.com/users/{{ $json.userId }}"'); + }); + }); + + describe('Integration with other validations', () => { + it('should validate expression format alongside syntax', async () => { + const workflow = { + nodes: [ + { + id: '1', + name: 'Test Node', + type: 'n8n-nodes-base.httpRequest', + position: [0, 0] as [number, number], + parameters: { + url: '{{ $json.url', // Syntax error: unclosed expression + headers: { + 'X-Token': '{{ $env.TOKEN }}' // Format error: missing prefix + } + }, + typeVersion: 4 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + // Should have both syntax and format errors + const syntaxErrors = result.errors.filter(e => e.message.includes('Unmatched expression brackets')); + const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); + + expect(syntaxErrors.length).toBeGreaterThan(0); + expect(formatErrors.length).toBeGreaterThan(0); + }); + + it('should not interfere with node validation', async () => { + // Test that expression format validation works alongside other validations + const workflow = { + nodes: [ + { + id: '1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + position: [0, 0] as [number, number], + parameters: { + url: '{{ $json.endpoint }}', // Expression format error + headers: { + Authorization: '={{ $env.TOKEN }}' // Correct format + } + }, + typeVersion: 4 + } + ], + connections: {} + }; + + const result = await validator.validateWorkflow(workflow); + + // Should have expression format error for url field + const formatErrors = result.errors.filter(e => e.message.includes('Expression format')); + expect(formatErrors).toHaveLength(1); + expect(formatErrors[0].message).toContain('url'); + + // The workflow should still have structure validation (no trigger warning, etc) + // This proves that expression validation doesn't interfere with other checks + expect(result.warnings.some(w => w.message.includes('trigger'))).toBe(true); + }); + }); +}); \ No newline at end of file