diff --git a/apps/sim/executor/variables/resolvers/block.test.ts b/apps/sim/executor/variables/resolvers/block.test.ts index 83e6cf1a73..dac00ee0b0 100644 --- a/apps/sim/executor/variables/resolvers/block.test.ts +++ b/apps/sim/executor/variables/resolvers/block.test.ts @@ -6,10 +6,14 @@ 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 + }> = [] +) { return { version: '1.0', blocks: blocks.map((b) => ({ @@ -17,7 +21,7 @@ function createTestWorkflow(blocks: Array<{ id: string; name?: string; type?: st 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, })), @@ -126,7 +130,7 @@ describe('BlockResolver', () => { expect(resolver.resolve('', 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', { @@ -136,6 +140,48 @@ describe('BlockResolver', () => { expect(resolver.resolve('', 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('', ctx)).toThrow( + /"invalidField" doesn't exist on block "source"/ + ) + expect(() => resolver.resolve('', 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('', ctx)).toBe('value') + expect(resolver.resolve('', ctx)).toBeUndefined() + }) + it.concurrent('should return undefined for non-existent block', () => { const workflow = createTestWorkflow([{ id: 'existing' }]) const resolver = new BlockResolver(workflow) diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index 4c46b0d299..7786a27d6d 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -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 | 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 | undefined): string[] { + if (!outputs) return [] + return Object.keys(outputs) +} export class BlockResolver implements Resolver { private nameToBlockId: Map + private blockById: Map 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) } @@ -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 } @@ -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: -> New: // Only apply fallback if: @@ -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 }