diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 0fdd912b30..53e3f14b2e 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -391,6 +391,225 @@ describe('Function Execute API Route', () => { }) }) + describe('Enhanced Error Handling', () => { + it('should provide detailed syntax error with line content', async () => { + // Mock VM Script to throw a syntax error + const mockScript = vi.fn().mockImplementation(() => { + const error = new Error('Invalid or unexpected token') + error.name = 'SyntaxError' + error.stack = `user-function.js:5 + description: "This has a missing closing quote + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +SyntaxError: Invalid or unexpected token + at new Script (node:vm:117:7) + at POST (/path/to/route.ts:123:24)` + throw error + }) + + vi.doMock('vm', () => ({ + createContext: mockCreateContext, + Script: mockScript, + })) + + const req = createMockRequest('POST', { + code: 'const obj = {\n name: "test",\n description: "This has a missing closing quote\n};\nreturn obj;', + timeout: 5000, + }) + + const { POST } = await import('./route') + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toContain('Syntax Error') + expect(data.error).toContain('Line 3') + expect(data.error).toContain('description: "This has a missing closing quote') + expect(data.error).toContain('Invalid or unexpected token') + expect(data.error).toContain('(Check for missing quotes, brackets, or semicolons)') + + // Check debug information + expect(data.debug).toBeDefined() + expect(data.debug.line).toBe(3) + expect(data.debug.errorType).toBe('SyntaxError') + expect(data.debug.lineContent).toBe('description: "This has a missing closing quote') + }) + + it('should provide detailed runtime error with line and column', async () => { + // Create the error object first + const runtimeError = new Error("Cannot read properties of null (reading 'someMethod')") + runtimeError.name = 'TypeError' + runtimeError.stack = `TypeError: Cannot read properties of null (reading 'someMethod') + at user-function.js:4:16 + at user-function.js:9:3 + at Script.runInContext (node:vm:147:14)` + + // Mock successful script creation but runtime error + const mockScript = vi.fn().mockImplementation(() => ({ + runInContext: vi.fn().mockRejectedValue(runtimeError), + })) + + vi.doMock('vm', () => ({ + createContext: mockCreateContext, + Script: mockScript, + })) + + const req = createMockRequest('POST', { + code: 'const obj = null;\nreturn obj.someMethod();', + timeout: 5000, + }) + + const { POST } = await import('./route') + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toContain('Type Error') + expect(data.error).toContain('Line 2') + expect(data.error).toContain('return obj.someMethod();') + expect(data.error).toContain('Cannot read properties of null') + + // Check debug information + expect(data.debug).toBeDefined() + expect(data.debug.line).toBe(2) + expect(data.debug.column).toBe(16) + expect(data.debug.errorType).toBe('TypeError') + expect(data.debug.lineContent).toBe('return obj.someMethod();') + }) + + it('should handle ReferenceError with enhanced details', async () => { + // Create the error object first + const referenceError = new Error('undefinedVariable is not defined') + referenceError.name = 'ReferenceError' + referenceError.stack = `ReferenceError: undefinedVariable is not defined + at user-function.js:4:8 + at Script.runInContext (node:vm:147:14)` + + const mockScript = vi.fn().mockImplementation(() => ({ + runInContext: vi.fn().mockRejectedValue(referenceError), + })) + + vi.doMock('vm', () => ({ + createContext: mockCreateContext, + Script: mockScript, + })) + + const req = createMockRequest('POST', { + code: 'const x = 42;\nreturn undefinedVariable + x;', + timeout: 5000, + }) + + const { POST } = await import('./route') + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toContain('Reference Error') + expect(data.error).toContain('Line 2') + expect(data.error).toContain('return undefinedVariable + x;') + expect(data.error).toContain('undefinedVariable is not defined') + }) + + it('should handle errors without line content gracefully', async () => { + const mockScript = vi.fn().mockImplementation(() => { + const error = new Error('Generic error without stack trace') + error.name = 'Error' + // No stack trace + throw error + }) + + vi.doMock('vm', () => ({ + createContext: mockCreateContext, + Script: mockScript, + })) + + const req = createMockRequest('POST', { + code: 'return "test";', + timeout: 5000, + }) + + const { POST } = await import('./route') + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toBe('Generic error without stack trace') + + // Should still have debug info, but without line details + expect(data.debug).toBeDefined() + expect(data.debug.errorType).toBe('Error') + expect(data.debug.line).toBeUndefined() + expect(data.debug.lineContent).toBeUndefined() + }) + + it('should extract line numbers from different stack trace formats', async () => { + const mockScript = vi.fn().mockImplementation(() => { + const error = new Error('Test error') + error.name = 'Error' + error.stack = `Error: Test error + at user-function.js:7:25 + at async function + at Script.runInContext (node:vm:147:14)` + throw error + }) + + vi.doMock('vm', () => ({ + createContext: mockCreateContext, + Script: mockScript, + })) + + const req = createMockRequest('POST', { + code: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nreturn a + b + c + d;', + timeout: 5000, + }) + + const { POST } = await import('./route') + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + + // Line 7 in VM should map to line 5 in user code (7 - 3 + 1 = 5) + expect(data.debug.line).toBe(5) + expect(data.debug.column).toBe(25) + expect(data.debug.lineContent).toBe('return a + b + c + d;') + }) + + it('should provide helpful suggestions for common syntax errors', async () => { + const mockScript = vi.fn().mockImplementation(() => { + const error = new Error('Unexpected end of input') + error.name = 'SyntaxError' + error.stack = 'user-function.js:4\nSyntaxError: Unexpected end of input' + throw error + }) + + vi.doMock('vm', () => ({ + createContext: mockCreateContext, + Script: mockScript, + })) + + const req = createMockRequest('POST', { + code: 'const obj = {\n name: "test"\n// Missing closing brace', + timeout: 5000, + }) + + const { POST } = await import('./route') + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toContain('Syntax Error') + expect(data.error).toContain('Unexpected end of input') + expect(data.error).toContain('(Check for missing closing brackets or braces)') + }) + }) + describe('Utility Functions', () => { it('should properly escape regex special characters', async () => { // This tests the escapeRegExp function indirectly diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 5bf0340a33..774b5c76d8 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -8,6 +8,210 @@ export const maxDuration = 60 const logger = createLogger('FunctionExecuteAPI') +/** + * Enhanced error information interface + */ +interface EnhancedError { + message: string + line?: number + column?: number + stack?: string + name: string + originalError: any + lineContent?: string +} + +/** + * Extract enhanced error information from VM execution errors + */ +function extractEnhancedError( + error: any, + userCodeStartLine: number, + userCode?: string +): EnhancedError { + const enhanced: EnhancedError = { + message: error.message || 'Unknown error', + name: error.name || 'Error', + originalError: error, + } + + if (error.stack) { + enhanced.stack = error.stack + + // Parse stack trace to extract line and column information + // Handle both compilation errors and runtime errors + const stackLines: string[] = error.stack.split('\n') + + for (const line of stackLines) { + // Pattern 1: Compilation errors - "user-function.js:6" + let match = line.match(/user-function\.js:(\d+)(?::(\d+))?/) + + // Pattern 2: Runtime errors - "at user-function.js:5:12" + if (!match) { + match = line.match(/at\s+user-function\.js:(\d+):(\d+)/) + } + + // Pattern 3: Generic patterns for any line containing our filename + if (!match) { + match = line.match(/user-function\.js:(\d+)(?::(\d+))?/) + } + + if (match) { + const stackLine = Number.parseInt(match[1], 10) + const stackColumn = match[2] ? Number.parseInt(match[2], 10) : undefined + + // Adjust line number to account for wrapper code + // The user code starts at a specific line in our wrapper + const adjustedLine = stackLine - userCodeStartLine + 1 + + // Check if this is a syntax error in wrapper code caused by incomplete user code + const isWrapperSyntaxError = + stackLine > userCodeStartLine && + error.name === 'SyntaxError' && + (error.message.includes('Unexpected token') || + error.message.includes('Unexpected end of input')) + + if (isWrapperSyntaxError && userCode) { + // Map wrapper syntax errors to the last line of user code + const codeLines = userCode.split('\n') + const lastUserLine = codeLines.length + enhanced.line = lastUserLine + enhanced.column = codeLines[lastUserLine - 1]?.length || 0 + enhanced.lineContent = codeLines[lastUserLine - 1]?.trim() + break + } + + if (adjustedLine > 0) { + enhanced.line = adjustedLine + enhanced.column = stackColumn + + // Extract the actual line content from user code + if (userCode) { + const codeLines = userCode.split('\n') + if (adjustedLine <= codeLines.length) { + enhanced.lineContent = codeLines[adjustedLine - 1]?.trim() + } + } + break + } + + if (stackLine <= userCodeStartLine) { + // Error is in wrapper code itself + enhanced.line = stackLine + enhanced.column = stackColumn + break + } + } + } + + // Clean up stack trace to show user-relevant information + const cleanedStackLines: string[] = stackLines + .filter( + (line: string) => + line.includes('user-function.js') || + (!line.includes('vm.js') && !line.includes('internal/')) + ) + .map((line: string) => line.replace(/\s+at\s+/, ' at ')) + + if (cleanedStackLines.length > 0) { + enhanced.stack = cleanedStackLines.join('\n') + } + } + + // Keep original message without adding error type prefix + // The error type will be added later in createUserFriendlyErrorMessage + + return enhanced +} + +/** + * Create a detailed error message for users + */ +function createUserFriendlyErrorMessage( + enhanced: EnhancedError, + requestId: string, + userCode?: string +): string { + let errorMessage = enhanced.message + + // Add line and column information if available + if (enhanced.line !== undefined) { + let lineInfo = `Line ${enhanced.line}${enhanced.column !== undefined ? `:${enhanced.column}` : ''}` + + // Add the actual line content if available + if (enhanced.lineContent) { + lineInfo += `: \`${enhanced.lineContent}\`` + } + + errorMessage = `${lineInfo} - ${errorMessage}` + } else { + // If no line number, try to extract it from stack trace for display + if (enhanced.stack) { + const stackMatch = enhanced.stack.match(/user-function\.js:(\d+)(?::(\d+))?/) + if (stackMatch) { + const line = Number.parseInt(stackMatch[1], 10) + const column = stackMatch[2] ? Number.parseInt(stackMatch[2], 10) : undefined + let lineInfo = `Line ${line}${column ? `:${column}` : ''}` + + // Try to get line content if we have userCode + if (userCode) { + const codeLines = userCode.split('\n') + // Note: stackMatch gives us VM line number, need to adjust + // This is a fallback case, so we might not have perfect line mapping + if (line <= codeLines.length) { + const lineContent = codeLines[line - 1]?.trim() + if (lineContent) { + lineInfo += `: \`${lineContent}\`` + } + } + } + + errorMessage = `${lineInfo} - ${errorMessage}` + } + } + } + + // Add error type prefix with consistent naming + if (enhanced.name !== 'Error') { + const errorTypePrefix = + enhanced.name === 'SyntaxError' + ? 'Syntax Error' + : enhanced.name === 'TypeError' + ? 'Type Error' + : enhanced.name === 'ReferenceError' + ? 'Reference Error' + : enhanced.name + + // Only add prefix if not already present + if (!errorMessage.toLowerCase().includes(errorTypePrefix.toLowerCase())) { + errorMessage = `${errorTypePrefix}: ${errorMessage}` + } + } + + // For syntax errors, provide additional context + if (enhanced.name === 'SyntaxError') { + if (errorMessage.includes('Invalid or unexpected token')) { + errorMessage += ' (Check for missing quotes, brackets, or semicolons)' + } else if (errorMessage.includes('Unexpected end of input')) { + errorMessage += ' (Check for missing closing brackets or braces)' + } else if (errorMessage.includes('Unexpected token')) { + // Check if this might be due to incomplete code + if ( + enhanced.lineContent && + ((enhanced.lineContent.includes('(') && !enhanced.lineContent.includes(')')) || + (enhanced.lineContent.includes('[') && !enhanced.lineContent.includes(']')) || + (enhanced.lineContent.includes('{') && !enhanced.lineContent.includes('}'))) + ) { + errorMessage += ' (Check for missing closing parentheses, brackets, or braces)' + } else { + errorMessage += ' (Check your syntax)' + } + } + } + + return errorMessage +} + /** * Resolves environment variables and tags in code * @param code - Code with variables @@ -121,6 +325,8 @@ export async function POST(req: NextRequest) { const requestId = crypto.randomUUID().slice(0, 8) const startTime = Date.now() let stdout = '' + let userCodeStartLine = 3 // Default value for error reporting + let resolvedCode = '' // Store resolved code for error reporting try { const body = await req.json() @@ -149,13 +355,15 @@ export async function POST(req: NextRequest) { }) // Resolve variables in the code with workflow environment variables - const { resolvedCode, contextVariables } = resolveCodeVariables( + const codeResolution = resolveCodeVariables( code, executionParams, envVars, blockData, blockNameMapping ) + resolvedCode = codeResolution.resolvedCode + const contextVariables = codeResolution.contextVariables const executionMethod = 'vm' // Default execution method @@ -301,16 +509,12 @@ export async function POST(req: NextRequest) { // timeout, // displayErrors: true, // }) - // logger.info(`[${requestId}] VM execution result`, { - // result, - // stdout, - // }) // } // } else { logger.info(`[${requestId}] Using VM for code execution`, { resolvedCode, executionParams, - envVars, + hasEnvVars: Object.keys(envVars).length > 0, }) // Create a secure context with console logging @@ -336,28 +540,40 @@ export async function POST(req: NextRequest) { }, }) - const script = new Script(` - (async () => { - try { - ${ - isCustomTool - ? `// For custom tools, make parameters directly accessible - ${Object.keys(executionParams) - .map((key) => `const ${key} = params.${key};`) - .join('\n ')}` - : '' - } - ${resolvedCode} - } catch (error) { - console.error(error); - throw error; - } - })() - `) + // Calculate line offset for user code to provide accurate error reporting + const wrapperLines = ['(async () => {', ' try {'] + + // Add custom tool parameter declarations if needed + if (isCustomTool) { + wrapperLines.push(' // For custom tools, make parameters directly accessible') + Object.keys(executionParams).forEach((key) => { + wrapperLines.push(` const ${key} = params.${key};`) + }) + } + + userCodeStartLine = wrapperLines.length + 1 // +1 because user code starts on next line + + // Build the complete script with proper formatting for line numbers + const fullScript = [ + ...wrapperLines, + ` ${resolvedCode.split('\n').join('\n ')}`, // Indent user code + ' } catch (error) {', + ' console.error(error);', + ' throw error;', + ' }', + '})()', + ].join('\n') + + const script = new Script(fullScript, { + filename: 'user-function.js', // This filename will appear in stack traces + lineOffset: 0, // Start line numbering from 0 + columnOffset: 0, // Start column numbering from 0 + }) const result = await script.runInContext(context, { timeout, displayErrors: true, + breakOnSigint: true, // Allow breaking on SIGINT for better debugging }) // } @@ -384,14 +600,40 @@ export async function POST(req: NextRequest) { executionTime, }) + const enhancedError = extractEnhancedError(error, userCodeStartLine, resolvedCode) + const userFriendlyErrorMessage = createUserFriendlyErrorMessage( + enhancedError, + requestId, + resolvedCode + ) + + // Log enhanced error details for debugging + logger.error(`[${requestId}] Enhanced error details`, { + originalMessage: error.message, + enhancedMessage: userFriendlyErrorMessage, + line: enhancedError.line, + column: enhancedError.column, + lineContent: enhancedError.lineContent, + errorType: enhancedError.name, + userCodeStartLine, + }) + const errorResponse = { success: false, - error: error.message || 'Code execution failed', + error: userFriendlyErrorMessage, output: { result: null, stdout, executionTime, }, + // Include debug information in development or for debugging + debug: { + line: enhancedError.line, + column: enhancedError.column, + errorType: enhancedError.name, + lineContent: enhancedError.lineContent, + stack: enhancedError.stack, + }, } return NextResponse.json(errorResponse, { status: 500 }) diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index 273316c1e2..7318e74818 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -164,6 +164,197 @@ describe('Function Execute Tool', () => { }) }) + describe('Enhanced Error Handling', () => { + test('should handle enhanced syntax error with line content', async () => { + // Setup enhanced error response with debug information + tester.setup( + { + success: false, + error: + 'Syntax Error: Line 3: `description: "This has a missing closing quote` - Invalid or unexpected token (Check for missing quotes, brackets, or semicolons)', + output: { + result: null, + stdout: '', + executionTime: 5, + }, + debug: { + line: 3, + column: undefined, + errorType: 'SyntaxError', + lineContent: 'description: "This has a missing closing quote', + stack: 'user-function.js:5\n description: "This has a missing closing quote\n...', + }, + }, + { ok: false, status: 500 } + ) + + // Execute the tool with syntax error + const result = await tester.execute({ + code: 'const obj = {\n name: "test",\n description: "This has a missing closing quote\n};\nreturn obj;', + }) + + // Check enhanced error handling + expect(result.success).toBe(false) + expect(result.error).toContain('Syntax Error') + expect(result.error).toContain('Line 3') + expect(result.error).toContain('description: "This has a missing closing quote') + expect(result.error).toContain('Invalid or unexpected token') + expect(result.error).toContain('(Check for missing quotes, brackets, or semicolons)') + }) + + test('should handle enhanced runtime error with line and column', async () => { + // Setup enhanced runtime error response + tester.setup( + { + success: false, + error: + "Type Error: Line 2:16: `return obj.someMethod();` - Cannot read properties of null (reading 'someMethod')", + output: { + result: null, + stdout: 'ERROR: {}\n', + executionTime: 12, + }, + debug: { + line: 2, + column: 16, + errorType: 'TypeError', + lineContent: 'return obj.someMethod();', + stack: 'TypeError: Cannot read properties of null...', + }, + }, + { ok: false, status: 500 } + ) + + // Execute the tool with runtime error + const result = await tester.execute({ + code: 'const obj = null;\nreturn obj.someMethod();', + }) + + // Check enhanced error handling + expect(result.success).toBe(false) + expect(result.error).toContain('Type Error') + expect(result.error).toContain('Line 2:16') + expect(result.error).toContain('return obj.someMethod();') + expect(result.error).toContain('Cannot read properties of null') + }) + + test('should handle enhanced error information in tool response', async () => { + // Setup enhanced error response with full debug info + tester.setup( + { + success: false, + error: 'Reference Error: Line 1: `return undefinedVar` - undefinedVar is not defined', + output: { + result: null, + stdout: '', + executionTime: 3, + }, + debug: { + line: 1, + column: 7, + errorType: 'ReferenceError', + lineContent: 'return undefinedVar', + stack: 'ReferenceError: undefinedVar is not defined...', + }, + }, + { ok: false, status: 500 } + ) + + // Execute the tool with reference error + const result = await tester.execute({ + code: 'return undefinedVar', + }) + + // Check that the tool properly captures enhanced error + expect(result.success).toBe(false) + expect(result.error).toBe( + 'Reference Error: Line 1: `return undefinedVar` - undefinedVar is not defined' + ) + }) + + test('should preserve debug information in error object', async () => { + // Setup enhanced error response + tester.setup( + { + success: false, + error: 'Syntax Error: Line 2 - Invalid syntax', + debug: { + line: 2, + column: 5, + errorType: 'SyntaxError', + lineContent: 'invalid syntax here', + stack: 'SyntaxError: Invalid syntax...', + }, + }, + { ok: false, status: 500 } + ) + + // Execute the tool + const result = await tester.execute({ + code: 'valid line\ninvalid syntax here', + }) + + // Check that enhanced error information is available + expect(result.success).toBe(false) + expect(result.error).toBe('Syntax Error: Line 2 - Invalid syntax') + + // Note: In this test framework, debug information would be available + // in the response object, but the tool transforms it into the error message + }) + + test('should handle enhanced error without line information', async () => { + // Setup error response without line information + tester.setup( + { + success: false, + error: 'Generic error message', + debug: { + errorType: 'Error', + stack: 'Error: Generic error message...', + }, + }, + { ok: false, status: 500 } + ) + + // Execute the tool + const result = await tester.execute({ + code: 'return "test";', + }) + + // Check error handling without enhanced line info + expect(result.success).toBe(false) + expect(result.error).toBe('Generic error message') + }) + + test('should provide line-specific error message when available', async () => { + // Setup enhanced error response with line info + tester.setup( + { + success: false, + error: + 'Type Error: Line 5:20: `obj.nonExistentMethod()` - obj.nonExistentMethod is not a function', + debug: { + line: 5, + column: 20, + errorType: 'TypeError', + lineContent: 'obj.nonExistentMethod()', + }, + }, + { ok: false, status: 500 } + ) + + // Execute the tool + const result = await tester.execute({ + code: 'const obj = {};\nobj.nonExistentMethod();', + }) + + // Check that enhanced error message is provided + expect(result.success).toBe(false) + expect(result.error).toContain('Line 5:20') + expect(result.error).toContain('obj.nonExistentMethod()') + }) + }) + describe('Edge Cases', () => { test('should handle empty code input', async () => { // Execute with empty code - this should still pass through to the API diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index af015aae68..60ad055976 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -70,7 +70,21 @@ export const functionExecuteTool: ToolConfig { + // If we have enhanced error information, create a more detailed message + if (error.enhancedError && error.line) { + return `Line ${error.line}${error.column ? `:${error.column}` : ''} - ${error.message}` + } return error.message || 'Code execution failed' }, }