Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/sim/executor/handlers/api/api-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,21 @@ describe('ApiBlockHandler', () => {
)
})

it('should handle malformed JSON body by keeping original string', async () => {
const inputs = {
url: 'https://example.com/api',
body: '{invalid json body',
}

await handler.execute(mockContext, mockBlock, inputs)

expect(mockExecuteTool).toHaveBeenCalledWith(
'http_request',
expect.objectContaining({ body: '{invalid json body' }),
{ executionContext: mockContext }
)
})

it('should handle null body by converting to undefined', async () => {
const inputs = {
url: 'https://example.com/api',
Expand Down
11 changes: 5 additions & 6 deletions apps/sim/executor/handlers/api/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { BlockType, HTTP } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { parseJSON } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
Expand Down Expand Up @@ -53,12 +54,10 @@ export class ApiBlockHandler implements BlockHandler {

if (processedInputs.body !== undefined) {
if (typeof processedInputs.body === 'string') {
try {
const trimmedBody = processedInputs.body.trim()
if (trimmedBody.startsWith('{') || trimmedBody.startsWith('[')) {
processedInputs.body = JSON.parse(trimmedBody)
}
} catch (e) {}
const trimmedBody = processedInputs.body.trim()
if (trimmedBody.startsWith('{') || trimmedBody.startsWith('[')) {
processedInputs.body = parseJSON(trimmedBody, processedInputs.body)
}
} else if (processedInputs.body === null) {
processedInputs.body = undefined
}
Expand Down
35 changes: 35 additions & 0 deletions apps/sim/executor/handlers/generic/generic-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import '@sim/testing/mocks/executor'

import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { getBlock } from '@/blocks/index'
import { BlockType } from '@/executor/constants'
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
import type { ExecutionContext } from '@/executor/types'
Expand Down Expand Up @@ -144,4 +145,38 @@ describe('GenericBlockHandler', () => {
'Block execution of Some Custom Tool failed with no error message'
)
})

it('should handle malformed json field input by keeping original string', async () => {
const mockGetBlock = vi.mocked(getBlock)
mockGetBlock.mockReturnValue({
type: 'custom-type',
name: 'Custom Block',
description: 'Test block',
category: 'tools',
bgColor: '#000',
icon: () => null,
subBlocks: [],
tools: {
access: ['some_custom_tool'],
config: {
tool: () => 'some_custom_tool',
params: (p: Record<string, unknown>) => p,
},
},
inputs: {
data: { type: 'json' },
},
outputs: {},
} as unknown as ReturnType<typeof getBlock>)

const inputs = { data: '{not valid json' }

await handler.execute(mockContext, mockBlock, inputs)

expect(mockExecuteTool).toHaveBeenCalledWith(
'some_custom_tool',
expect.objectContaining({ data: '{not valid json' }),
{ executionContext: mockContext }
)
})
})
10 changes: 2 additions & 8 deletions apps/sim/executor/handlers/generic/generic-handler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { getBlock } from '@/blocks/index'
import { isMcpTool } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { parseJSON } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
Expand Down Expand Up @@ -45,13 +45,7 @@ export class GenericBlockHandler implements BlockHandler {
if (typeof value === 'string' && value.trim().length > 0) {
const inputType = typeof inputSchema === 'object' ? inputSchema.type : inputSchema
if (inputType === 'json' || inputType === 'array') {
try {
finalInputs[key] = JSON.parse(value.trim())
} catch (error) {
logger.warn(`Failed to parse ${inputType} field "${key}":`, {
error: toError(error).message,
})
}
finalInputs[key] = parseJSON(value, value)
}
Comment on lines 47 to 49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Warn log removed for malformed JSON fields. The previous try/catch logged logger.warn(\Failed to parse ${inputType} field "${key}":`, { error })on parse failure. Replacing it withparseJSON(value, value)— which silently returns the fallback — drops that diagnostic. The same pattern applies inresponse-handler.tsandhuman-in-the-loop-handler.ts, both of which also had logger.warn('Failed to parse JSON data, returning as string:', error)` before. With these warnings gone, malformed-JSON inputs in production are now invisible in logs until a downstream symptom appears.

}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import '@sim/testing/mocks/executor'

import { urlsMock, urlsMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'

vi.mock('@/lib/core/utils/urls', () => urlsMock)

const { mockGeneratePauseContextId, mockMapNodeMetadataToPauseScopes } = vi.hoisted(() => ({
mockGeneratePauseContextId: vi.fn(() => 'test-pause-context-id'),
mockMapNodeMetadataToPauseScopes: vi.fn(() => ({
parallelScope: undefined,
loopScope: undefined,
})),
}))

vi.mock('@/executor/human-in-the-loop/utils', () => ({
generatePauseContextId: mockGeneratePauseContextId,
mapNodeMetadataToPauseScopes: mockMapNodeMetadataToPauseScopes,
}))

vi.mock('@/executor/utils/builder-data', () => ({
convertBuilderDataToJson: vi.fn(() => ({ key: 'value' })),
convertPropertyValue: vi.fn((prop: any) => prop.value),
}))

vi.mock('@/executor/utils/block-data', () => ({
collectBlockData: vi.fn(() => ({
blockData: {},
blockNameMapping: {},
})),
}))

const mockExecuteTool = executeTool as Mock

describe('HumanInTheLoopBlockHandler', () => {
let handler: HumanInTheLoopBlockHandler
let mockBlock: SerializedBlock
let mockContext: ExecutionContext

beforeEach(() => {
vi.clearAllMocks()

handler = new HumanInTheLoopBlockHandler()

mockBlock = {
id: 'hitl-block-1',
metadata: { id: BlockType.HUMAN_IN_THE_LOOP, name: 'Test HITL Block' },
position: { x: 0, y: 0 },
config: { tool: BlockType.HUMAN_IN_THE_LOOP, params: {} },
inputs: {},
outputs: {},
enabled: true,
}

mockContext = {
workflowId: 'test-workflow-id',
blockStates: new Map(),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
}

urlsMockFns.mockGetBaseUrl.mockReturnValue('http://localhost:3000')
mockExecuteTool.mockResolvedValue({ success: true, output: {} })
mockGeneratePauseContextId.mockReturnValue('test-pause-context-id')
mockMapNodeMetadataToPauseScopes.mockReturnValue({
parallelScope: undefined,
loopScope: undefined,
})
})

it('should return true for human-in-the-loop blocks', () => {
expect(handler.canHandle(mockBlock)).toBe(true)
})

it('should return false for non-hitl blocks', () => {
const nonHitlBlock: SerializedBlock = {
...mockBlock,
metadata: { id: 'other-block' },
}
expect(handler.canHandle(nonHitlBlock)).toBe(false)
})

it('should execute with human operation and return correct response shape', async () => {
const inputs = {
operation: 'human',
inputFormat: [{ id: 'field-1', name: 'username', label: 'Username', type: 'string' }],
builderData: [{ id: '1', name: 'result', type: 'string', value: 'test' }],
}

const result = await handler.execute(mockContext, mockBlock, inputs)

expect(result.response).toBeDefined()
expect(result.response.status).toBe(200)
expect(result.response.headers).toHaveProperty('Content-Type')
expect(result.response.data).toHaveProperty('operation', 'human')
expect(result.response.data).toHaveProperty('responseStructure')
expect(result.response.data).toHaveProperty('inputFormat')
expect(result.response.data).toHaveProperty('submission', null)
expect(result._pauseMetadata).toBeDefined()
expect(result._pauseMetadata.pauseKind).toBe('human')
})

it('should handle malformed JSON data in api operation mode', async () => {
const inputs = {
operation: 'api',
dataMode: 'json',
data: '{invalid json}',
}

const result = await handler.execute(mockContext, mockBlock, inputs)

expect(result).toBeDefined()
expect(result.response).toBeDefined()
expect(result.response.data).toBe('{invalid json}')
})

it('should handle valid JSON data in api operation mode', async () => {
const inputs = {
operation: 'api',
dataMode: 'json',
data: '{"message":"hello"}',
}

const result = await handler.execute(mockContext, mockBlock, inputs)

expect(result.response.data).toMatchObject({ message: 'hello' })
})

it('should return error response on execution failure', async () => {
const inputs = {
operation: 'human',
inputFormat: 'not-an-array',
builderData: 'not-an-array',
}

mockMapNodeMetadataToPauseScopes.mockImplementation(() => {
throw new Error('Metadata mapping failed')
})

const result = await handler.execute(mockContext, mockBlock, inputs)

expect(result.response).toBeDefined()
expect(result.response.status).toBe(500)
expect(result.response.data).toHaveProperty('error')
expect(result.response.data.message).toBe('Metadata mapping failed')
})

it('should include resume links when executionId and workflowId exist', async () => {
const contextWithExecution: ExecutionContext = {
...mockContext,
executionId: 'exec-123',
}

const inputs = {
operation: 'human',
inputFormat: [],
}

const result = await handler.execute(contextWithExecution, mockBlock, inputs)

expect(result.response.data._resume).toBeDefined()
expect(result.url).toBeDefined()
expect(result.resumeEndpoint).toBeDefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { convertBuilderDataToJson, convertPropertyValue } from '@/executor/utils/builder-data'
import { parseObjectStrings } from '@/executor/utils/json'
import { parseJSON, parseObjectStrings } from '@/executor/utils/json'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'

Expand Down Expand Up @@ -253,13 +253,9 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {

if (dataMode === 'json' && inputs.data) {
if (typeof inputs.data === 'string') {
try {
return JSON.parse(inputs.data)
} catch (error) {
logger.warn('Failed to parse JSON data, returning as string:', error)
return inputs.data
}
} else if (typeof inputs.data === 'object' && inputs.data !== null) {
return parseJSON(inputs.data, inputs.data)
}
if (typeof inputs.data === 'object' && inputs.data !== null) {
return inputs.data
}
return inputs.data
Expand Down
Loading