Skip to content
Draft
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
93 changes: 87 additions & 6 deletions apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,69 @@
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'

const executorMocks = vi.hoisted(() => ({
execute: vi.fn(),
lastArgs: undefined as any,
}))

vi.mock('@/executor', () => ({
Executor: vi.fn().mockImplementation((args: any) => {
executorMocks.lastArgs = args
return { execute: executorMocks.execute }
}),
}))

vi.mock('@/serializer', () => ({
Serializer: class {
serializeWorkflow() {
return {
version: 'test',
blocks: [],
connections: [],
loops: {},
parallels: {},
}
}
},
}))

vi.mock('@/lib/auth/internal', () => ({
generateInternalToken: vi.fn().mockResolvedValue('test-token'),
}))

vi.mock('@/executor/utils/lazy-cleanup', () => ({
lazyCleanupInputMapping: vi.fn(async (_workflowId: string, _blockId: string, mapping: any) => {
return mapping
}),
}))

vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: {
getState: () => ({ workflows: {} }),
},
}))

// Mock fetch globally
global.fetch = vi.fn()

describe('WorkflowBlockHandler', () => {
let handler: WorkflowBlockHandler
let WorkflowBlockHandler: typeof import('@/executor/handlers/workflow/workflow-handler').WorkflowBlockHandler
let handler: InstanceType<typeof WorkflowBlockHandler>
let mockBlock: SerializedBlock
let mockContext: ExecutionContext
let mockFetch: Mock

beforeAll(async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
;({ WorkflowBlockHandler } = await import('@/executor/handlers/workflow/workflow-handler'))
})

afterAll(() => {
vi.unstubAllEnvs()
})

beforeEach(() => {
// Mock window.location.origin for getBaseUrl()
;(global as any).window = {
Expand Down Expand Up @@ -58,6 +105,7 @@ describe('WorkflowBlockHandler', () => {

// Reset all mocks
vi.clearAllMocks()
executorMocks.lastArgs = undefined

// Setup default fetch mock
mockFetch.mockResolvedValue({
Expand All @@ -70,10 +118,10 @@ describe('WorkflowBlockHandler', () => {
blocks: [
{
id: 'starter',
metadata: { id: BlockType.STARTER, name: 'Starter' },
type: BlockType.STARTER,
name: 'Starter',
position: { x: 0, y: 0 },
config: { tool: BlockType.STARTER, params: {} },
inputs: {},
subBlocks: {},
outputs: {},
enabled: true,
},
Expand Down Expand Up @@ -145,6 +193,39 @@ describe('WorkflowBlockHandler', () => {
'Error in child workflow "child-workflow-id": Network error'
)
})

it('normalizes stringified JSON values in inputMapping before starting child workflow', async () => {
executorMocks.execute.mockResolvedValue({
success: true,
output: { ok: true },
} as any)

const inputs = {
workflowId: 'child-workflow-id',
inputMapping: {
conversation_id: '149',
sender: '{"id":10,"email":"user@example.com"}',
is_active: 'true',
metadata: '{"nested":"[1,2]"}',
nullish: 'null',
invalid: '{bad',
},
}

await expect(handler.execute(mockContext, mockBlock, inputs)).resolves.toMatchObject({
success: true,
childWorkflowName: 'Child Workflow',
})

expect(executorMocks.lastArgs?.workflowInput).toEqual({
conversation_id: 149,
sender: { id: 10, email: 'user@example.com' },
is_active: true,
metadata: { nested: [1, 2] },
nullish: null,
invalid: '{bad',
})
})
})

describe('loadChildWorkflow', () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/executor/handlers/workflow/workflow-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
StreamingExecution,
} from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { parseJSON } from '@/executor/utils/json'
import { parseJSON, parseObjectStrings } from '@/executor/utils/json'
import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
import { Serializer } from '@/serializer'
import type { SerializedBlock } from '@/serializer/types'
Expand Down Expand Up @@ -81,7 +81,7 @@ export class WorkflowBlockHandler implements BlockHandler {
`Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}`
)

let childWorkflowInput: Record<string, any> = {}
let childWorkflowInput: any = {}

if (inputs.inputMapping !== undefined && inputs.inputMapping !== null) {
const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping)
Expand All @@ -93,7 +93,7 @@ export class WorkflowBlockHandler implements BlockHandler {
normalized,
childWorkflow.rawBlocks || {}
)
childWorkflowInput = cleanedMapping as Record<string, any>
childWorkflowInput = parseObjectStrings(cleanedMapping)
} else {
childWorkflowInput = {}
}
Expand Down
76 changes: 76 additions & 0 deletions apps/sim/executor/utils/start-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,5 +215,81 @@ describe('start-block utilities', () => {

expect(output.customField).toBe('defaultValue')
})

it.concurrent('preserves coerced types for unified start payload', () => {
const block = createBlock('start_trigger', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'conversation_id', type: 'number' },
{ name: 'sender', type: 'object' },
{ name: 'is_active', type: 'boolean' },
],
},
},
})

const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const

const output = buildStartBlockOutput({
resolution,
workflowInput: {
conversation_id: '149',
sender: '{"id":10,"email":"user@example.com"}',
is_active: 'true',
},
})

expect(output.conversation_id).toBe(149)
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
expect(output.is_active).toBe(true)
})

it.concurrent(
'prefers coerced inputFormat values over duplicated top-level workflowInput keys',
() => {
const block = createBlock('start_trigger', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'conversation_id', type: 'number' },
{ name: 'sender', type: 'object' },
{ name: 'is_active', type: 'boolean' },
],
},
},
})

const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const

const output = buildStartBlockOutput({
resolution,
workflowInput: {
input: {
conversation_id: '149',
sender: '{"id":10,"email":"user@example.com"}',
is_active: 'false',
},
conversation_id: '150',
sender: '{"id":99,"email":"wrong@example.com"}',
is_active: 'true',
extra: 'keep-me',
},
})

expect(output.conversation_id).toBe(149)
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
expect(output.is_active).toBe(false)
expect(output.extra).toBe('keep-me')
}
)
})
})
2 changes: 2 additions & 0 deletions apps/sim/executor/utils/start-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ function buildUnifiedStartOutput(
hasStructured: boolean
): NormalizedBlockOutput {
const output: NormalizedBlockOutput = {}
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null

if (hasStructured) {
for (const [key, value] of Object.entries(structuredInput)) {
Expand All @@ -271,6 +272,7 @@ function buildUnifiedStartOutput(
if (isPlainObject(workflowInput)) {
for (const [key, value] of Object.entries(workflowInput)) {
if (key === 'onUploadError') continue
if (structuredKeys?.has(key)) continue
// Runtime values override defaults (except undefined/null which mean "not provided")
if (value !== undefined && value !== null) {
output[key] = value
Expand Down
Loading