Skip to content
Merged
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
58 changes: 52 additions & 6 deletions apps/sim/executor/variables/resolvers/block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@ import type { ResolutionContext } from './reference'

vi.mock('@sim/logger', () => loggerMock)

/**
* Creates a minimal workflow for testing.
*/
function createTestWorkflow(blocks: Array<{ id: string; name?: string; type?: string }> = []) {
function createTestWorkflow(
blocks: Array<{
id: string
name?: string
type?: string
outputs?: Record<string, any>
}> = []
) {
return {
version: '1.0',
blocks: blocks.map((b) => ({
id: b.id,
position: { x: 0, y: 0 },
config: { tool: b.type ?? 'function', params: {} },
inputs: {},
outputs: {},
outputs: b.outputs ?? {},
metadata: { id: b.type ?? 'function', name: b.name ?? b.id },
enabled: true,
})),
Expand Down Expand Up @@ -126,7 +130,7 @@ describe('BlockResolver', () => {
expect(resolver.resolve('<source.items.1.id>', ctx)).toBe(2)
})

it.concurrent('should return undefined for non-existent path', () => {
it.concurrent('should return undefined for non-existent path when no schema defined', () => {
const workflow = createTestWorkflow([{ id: 'source' }])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {
Expand All @@ -136,6 +140,48 @@ describe('BlockResolver', () => {
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
})

it.concurrent('should throw error for path not in output schema', () => {
const workflow = createTestWorkflow([
{
id: 'source',
outputs: {
validField: { type: 'string', description: 'A valid field' },
nested: {
child: { type: 'number', description: 'Nested child' },
},
},
},
])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {
source: { validField: 'value', nested: { child: 42 } },
})

expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(
/"invalidField" doesn't exist on block "source"/
)
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
})

it.concurrent('should return undefined for path in schema but missing in data', () => {
const workflow = createTestWorkflow([
{
id: 'source',
outputs: {
requiredField: { type: 'string', description: 'Always present' },
optionalField: { type: 'string', description: 'Sometimes missing' },
},
},
])
const resolver = new BlockResolver(workflow)
const ctx = createTestContext('current', {
source: { requiredField: 'value' },
})

expect(resolver.resolve('<source.requiredField>', ctx)).toBe('value')
expect(resolver.resolve('<source.optionalField>', ctx)).toBeUndefined()
})

it.concurrent('should return undefined for non-existent block', () => {
const workflow = createTestWorkflow([{ id: 'existing' }])
const resolver = new BlockResolver(workflow)
Expand Down
76 changes: 72 additions & 4 deletions apps/sim/executor/variables/resolvers/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,75 @@ import {
type ResolutionContext,
type Resolver,
} from '@/executor/variables/resolvers/reference'
import type { SerializedWorkflow } from '@/serializer/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'

function isPathInOutputSchema(
outputs: Record<string, any> | undefined,
pathParts: string[]
): boolean {
if (!outputs || pathParts.length === 0) {
return true
}

let current: any = outputs
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i]

if (/^\d+$/.test(part)) {
continue
}

if (current === null || current === undefined) {
return false
}

if (part in current) {
current = current[part]
continue
}

if (current.properties && part in current.properties) {
current = current.properties[part]
continue
}

if (current.type === 'array' && current.items) {
if (current.items.properties && part in current.items.properties) {
current = current.items.properties[part]
continue
}
if (part in current.items) {
current = current.items[part]
continue
}
}

if ('type' in current && typeof current.type === 'string') {
if (!current.properties && !current.items) {
return false
}
}

return false
}

return true
}

function getSchemaFieldNames(outputs: Record<string, any> | undefined): string[] {
if (!outputs) return []
return Object.keys(outputs)
}

export class BlockResolver implements Resolver {
private nameToBlockId: Map<string, string>
private blockById: Map<string, SerializedBlock>

constructor(private workflow: SerializedWorkflow) {
this.nameToBlockId = new Map()
this.blockById = new Map()
for (const block of workflow.blocks) {
this.blockById.set(block.id, block)
if (block.metadata?.name) {
this.nameToBlockId.set(normalizeName(block.metadata.name), block.id)
}
Expand Down Expand Up @@ -47,7 +108,9 @@ export class BlockResolver implements Resolver {
return undefined
}

const block = this.blockById.get(blockId)
const output = this.getBlockOutput(blockId, context)

if (output === undefined) {
return undefined
}
Expand All @@ -63,9 +126,6 @@ export class BlockResolver implements Resolver {
return result
}

// If failed, check if we should try backwards compatibility fallback
const block = this.workflow.blocks.find((b) => b.id === blockId)

// Response block backwards compatibility:
// Old: <responseBlock.response.data> -> New: <responseBlock.data>
// Only apply fallback if:
Expand Down Expand Up @@ -108,6 +168,14 @@ export class BlockResolver implements Resolver {
}
}

const schemaFields = getSchemaFieldNames(block?.outputs)
if (schemaFields.length > 0 && !isPathInOutputSchema(block?.outputs, pathParts)) {
throw new Error(
`"${pathParts.join('.')}" doesn't exist on block "${blockName}". ` +
`Available fields: ${schemaFields.join(', ')}`
)
}

return undefined
}

Expand Down