props-resolver.test.ts•26.9 kB
import { ApFile, PieceAuth, Property } from '@activepieces/pieces-framework'
import { FlowActionType, FlowTriggerType, GenericStepOutput, PropertyExecutionType, PropertySettings, StepOutputStatus } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { StepExecutionPath } from '../../src/lib/handler/context/step-execution-path'
import { propsProcessor } from '../../src/lib/variables/props-processor'
import { createPropsResolver } from '../../src/lib/variables/props-resolver'
const propsResolverService = createPropsResolver({
projectId: 'PROJECT_ID',
engineToken: 'WORKER_TOKEN',
apiUrl: 'http://127.0.0.1:3000',
})
const executionState = FlowExecutorContext.empty()
.upsertStep(
'trigger',
GenericStepOutput.create({
type: FlowTriggerType.PIECE,
status: StepOutputStatus.SUCCEEDED,
input: {},
output: {
items: [5, 'a'],
name: 'John',
price: 6.4,
users: [
{
name: 'Alice',
},
{
name: 'Bob',
},
],
lastNames: [
'Smith',
'Doe',
],
},
}),
)
.upsertStep('step_1',
GenericStepOutput.create({
type: FlowActionType.PIECE,
status: StepOutputStatus.SUCCEEDED,
input: {},
output: {
success: true,
},
}))
.upsertStep('step_2', GenericStepOutput.create({
type: FlowActionType.PIECE,
status: StepOutputStatus.SUCCEEDED,
input: {},
output: 'memory://{"fileName":"hello.png","data":"iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z"}',
}))
describe('Props resolver', () => {
test('Test resolve inside nested loops', async () => {
const modifiedExecutionState = executionState.upsertStep('step_3', GenericStepOutput.create({
type: FlowActionType.LOOP_ON_ITEMS,
status: StepOutputStatus.SUCCEEDED,
input: {},
output: {
iterations: [
{
'step_8': GenericStepOutput.create({
type: FlowActionType.PIECE,
status: StepOutputStatus.SUCCEEDED,
input: {},
output: {
delayForInMs: 20000,
success: true,
},
}),
'step_4': GenericStepOutput.create({
type: FlowActionType.LOOP_ON_ITEMS,
status: StepOutputStatus.SUCCEEDED,
input: {},
output: {
iterations: [
{
'step_7': GenericStepOutput.create({
'type': FlowActionType.PIECE,
'status': StepOutputStatus.SUCCEEDED,
'input': {
'unit': 'seconds',
'delayFor': '20',
},
'output': {
'delayForInMs': 20000,
'success': true,
},
}),
},
],
item: 1,
index: 0,
},
}),
},
],
item: 1,
index: 0,
},
})).setCurrentPath(StepExecutionPath.empty()
.loopIteration({
loopName: 'step_3',
iteration: 0,
})
.loopIteration({
loopName: 'step_4',
iteration: 0,
}),
)
const { resolvedInput: secondLevelResolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{step_7.delayForInMs}}', executionState: modifiedExecutionState })
expect(secondLevelResolvedInput).toEqual(20000)
const { resolvedInput: firstLevelResolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{step_8.delayForInMs}}', executionState: modifiedExecutionState })
expect(firstLevelResolvedInput).toEqual(20000)
})
test('Test resolve text with no variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: 'Hello world!', executionState })
expect(resolvedInput).toEqual(
'Hello world!',
)
})
test('Test resolve text with double variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: 'Price is {{ trigger.price }}', executionState })
expect(resolvedInput,
).toEqual('Price is 6.4')
})
test('Test resolve object steps variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{ {"where": "a"} }}', executionState })
expect(resolvedInput).toEqual(
{
where: 'a',
},
)
})
test('Test resolve object steps variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger}}', executionState })
expect(resolvedInput).toEqual(
{
items: [5, 'a'],
name: 'John',
price: 6.4,
users: [
{
name: 'Alice',
},
{
name: 'Bob',
},
],
lastNames: [
'Smith',
'Doe',
],
},
)
})
test('flatten array path', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{flattenNestedKeys(trigger, [\'users\',\'name\'])}}', executionState })
expect(resolvedInput).toEqual(['Alice', 'Bob'])
})
test('merge multiple flatten array paths', async ()=>{
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{flattenNestedKeys(trigger, [\'users\',\'name\'])}} {{trigger.lastNames}}', executionState })
expect(resolvedInput).toEqual(['Alice Smith', 'Bob Doe'])
})
test('Test resolve steps variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.name}}', executionState })
expect(resolvedInput).toEqual(
'John',
)
})
test('Test resolve multiple variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.name}} {{trigger.name}}', executionState })
expect(
resolvedInput,
).toEqual('John John')
})
test('Test resolve variable array items', async () => {
const { resolvedInput } = await propsResolverService.resolve({
unresolvedInput:
'{{trigger.items[0]}} {{trigger.items[1]}}',
executionState,
})
expect(
resolvedInput,
).toEqual('5 a')
})
test('Test resolve array variable', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.items}}', executionState })
expect(resolvedInput).toEqual(
[5, 'a'],
)
})
test('Test resolve integer from variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.items[0]}}', executionState })
expect(
resolvedInput,
).toEqual(5)
})
test('Test resolve text with undefined variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({
unresolvedInput:
'test {{configs.bar}} {{trigger.items[4]}}',
executionState,
})
expect(
resolvedInput,
).toEqual('test ')
})
test('Test resolve empty text', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '', executionState })
expect(resolvedInput).toEqual('')
})
test('Test resolve empty variable operator', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{}}', executionState })
expect(resolvedInput).toEqual('')
})
test('Test resolve object', async () => {
const { resolvedInput } = await propsResolverService.resolve({
unresolvedInput:
{
input: {
foo: 'bar',
nums: [1, 2, '{{trigger.items[0]}}'],
var: '{{trigger.price}}',
},
},
executionState,
})
expect(
resolvedInput,
).toEqual({ input: { foo: 'bar', nums: [1, 2, 5], var: 6.4 } })
})
test('Test resolve boolean from variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{step_1.success}}', executionState })
expect(resolvedInput).toEqual(
true,
)
})
test('Test resolve addition from variables', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{trigger.price + 2 - 3}}', executionState })
expect(resolvedInput).toEqual(
6.4 + 2 - 3,
)
})
test('Test resolve text with array variable', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: 'items are {{trigger.items}}', executionState })
expect(
resolvedInput,
).toEqual('items are [5,"a"]')
})
test('Test resolve text with object variable', async () => {
const { resolvedInput } = await propsResolverService.resolve({
unresolvedInput:
'values from trigger step: {{trigger}}',
executionState,
})
expect(
resolvedInput,
).toEqual('values from trigger step: {"items":[5,"a"],"name":"John","price":6.4,"users":[{"name":"Alice"},{"name":"Bob"}],"lastNames":["Smith","Doe"]}')
})
test('Test use built-in Math Min function', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{Math.min(trigger.price + 2 - 3, 2)}}', executionState })
expect(resolvedInput).toEqual(
2,
)
})
test('Test use built-in Math Max function', async () => {
const { resolvedInput } = await propsResolverService.resolve({ unresolvedInput: '{{Math.max(trigger.price + 2, 2)}}', executionState })
expect(resolvedInput).toEqual(
8.4,
)
})
it('should not compress memory file in native value in non-logs mode', async () => {
const input = {
base64: 'memory://{"fileName":"hello.png","data":"iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z"}',
}
const { resolvedInput } = await propsResolverService.resolve({
unresolvedInput: input,
executionState,
})
expect(resolvedInput).toEqual({
base64: 'memory://{"fileName":"hello.png","data":"iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z"}',
})
})
it('should not compress memory file in referenced value in non-logs mode', async () => {
const input = {
base64: '{{step_2}}',
}
const { resolvedInput } = await propsResolverService.resolve({
unresolvedInput: input,
executionState,
})
expect(resolvedInput).toEqual({
base64: 'memory://{"fileName":"hello.png","data":"iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z"}',
})
})
it('should return base64 from base64 with mime only', async () => {
const input = {
base64WithMime: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z',
base64: 'iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z',
}
const props = {
base64WithMime: Property.File({
displayName: 'Base64',
required: true,
}),
base64: Property.File({
displayName: 'Base64',
required: true,
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput).toEqual({
base64: null,
base64WithMime: new ApFile('unknown.png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z', 'base64'), 'png'),
})
expect(errors).toEqual({
'base64': [
'Expected file url or base64 with mimeType, received: iVBORw0KGgoAAAANSUhEUgAAAiAAAAC4CAYAAADaI1cbAAA0h0lEQVR4AezdA5AlPx7A8Zxt27Z9r5PB2SidWTqbr26S9Hr/tm3btu3723eDJD3r15ec17vzXr+Z',
],
})
})
it('should resolve files inside the array properties', async () => {
const input = {
documents: [
{
file: 'https://cdn.activepieces.com/brand/logo.svg?token=123',
},
],
}
const props = {
documents: Property.Array({
displayName: 'Documents',
required: true,
properties: {
file: Property.File({
displayName: 'File',
required: true,
}),
},
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.documents[0].file).toBeDefined()
expect(processedInput.documents[0].file.extension).toBe('svg')
expect(processedInput.documents[0].file.filename).toBe('logo.svg')
expect(errors).toEqual({})
})
it('should return error for invalid file inside the array properties', async () => {
const input = {
documents: [
{
file: 'invalid-url',
},
],
}
const props = {
documents: Property.Array({
displayName: 'Documents',
required: true,
properties: {
file: Property.File({
displayName: 'File',
required: true,
}),
},
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.documents[0].file).toBeNull()
expect(errors).toEqual({
'documents': {
properties: [{
file: [
'Expected file url or base64 with mimeType, received: invalid-url',
],
}],
},
})
})
it('should return images for image url', async () => {
const input = {
file: 'https://cdn.activepieces.com/brand/logo.svg?token=123',
}
const props = {
file: Property.File({
displayName: 'File',
required: true,
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.file).toBeDefined()
expect(processedInput.file.extension).toBe('svg')
expect(processedInput.file.filename).toBe('logo.svg')
expect(errors).toEqual({})
})
// Test with invalid url
it('should return error for invalid data', async () => {
const input = {
file: 'https://google.com',
nullFile: null,
nullOptionalFile: null,
}
const props = {
file: Property.File({
displayName: 'File',
required: true,
}),
nullFile: Property.File({
displayName: 'File',
required: true,
}),
nullOptionalFile: Property.File({
displayName: 'File',
required: false,
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.file).toBeDefined()
expect(processedInput.file.extension).toBe('html')
expect(processedInput.file.filename).toBe('unknown.html')
expect(processedInput.nullFile).toBeNull()
expect(processedInput.nullOptionalFile).toBeNull()
expect(errors).toEqual({
'nullFile': [
'Expected file url or base64 with mimeType, received: null',
],
})
})
it('should return casted number for text', async () => {
const input = {
price: '0',
auth: {
age: '12',
},
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, {
price: Property.Number({
displayName: 'Price',
required: true,
}),
}, PieceAuth.CustomAuth({
required: true,
props: {
age: Property.Number({
displayName: 'age',
required: true,
}),
},
}), true, {})
expect(processedInput).toEqual({
auth: {
age: 12,
},
price: 0,
})
expect(errors).toEqual({})
})
it('should not error if auth configured, but no auth provided in input', async () => {
const input = {
price: '0',
}
const props = {
price: Property.Number({
displayName: 'Price',
required: true,
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.CustomAuth({
required: true,
props: {},
}), false, {})
expect(processedInput).toEqual({
price: 0,
})
expect(errors).toEqual({})
})
it('should flatten arrays inside DYNAMIC properties', async () => {
const input = {
dynamicProp: {
items: {
id: [1, 2],
name: ['Item 1', 'Item 2'],
},
},
}
const propertySettings: Record<string, PropertySettings> = {
dynamicProp: {
type: PropertyExecutionType.MANUAL,
schema: {
items: Property.Array({
displayName: 'Items',
required: true,
properties: {
id: Property.Number({
displayName: 'ID',
required: true,
}),
name: Property.LongText({
displayName: 'Name',
required: true,
}),
},
}),
},
},
}
const props = {
dynamicProp: Property.DynamicProperties({
displayName: 'Dynamic Property',
required: true,
props: async () => {
return {}
},
refreshers: [],
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, propertySettings)
expect(processedInput.dynamicProp.items).toEqual([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
])
expect(errors).toEqual({})
})
})
describe('Array Flatter Processor', () => {
it('should flatten array of objects', async () => {
const input = {
items: {
id: [1, 2],
name: ['Item 1', 'Item 2'],
},
}
const props = {
items: Property.Array({
displayName: 'Items',
required: true,
properties: {
id: Property.Number({
displayName: 'ID',
required: true,
}),
name: Property.LongText({
displayName: 'Name',
required: true,
}),
},
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.items).toEqual([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
])
expect(errors).toEqual({})
})
it('should handle non-array properties gracefully', async () => {
const input = {
items: {
id: [1, 2],
name: 'Single Item', // Non-array property
},
}
const props = {
items: Property.Array({
displayName: 'Items',
required: true,
properties: {
id: Property.Number({
displayName: 'ID',
required: true,
}),
name: Property.LongText({
displayName: 'Name',
required: true,
}),
},
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.items).toEqual([
{ id: 1, name: 'Single Item' },
{ id: 2, name: 'Single Item' },
])
expect(errors).toEqual({})
})
it('should handle arrays of unequal length', async () => {
const input = {
items: {
id: [1, 2, 3], // Longer array
name: ['Item 1', 'Item 2'], // Shorter array
},
}
const props = {
items: Property.Array({
displayName: 'Items',
required: true,
properties: {
id: Property.Number({
displayName: 'ID',
required: true,
}),
name: Property.LongText({
displayName: 'Name',
required: false,
}),
},
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.items).toEqual([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: undefined }, // Handle missing name
])
expect(errors).toEqual({})
})
it('should convert number to string for ShortText properties', async () => {
const input = {
items: {
id: 123,
name: 'Item Name',
},
}
const props = {
items: Property.Array({
displayName: 'Items',
required: true,
properties: {
id: Property.ShortText({
displayName: 'ID',
required: true,
}),
name: Property.LongText({
displayName: 'Name',
required: true,
}),
},
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.items).toEqual([
{ id: '123', name: 'Item Name' },
])
expect(errors).toEqual({})
})
it('should handle arrays with string values', async () => {
const input = {
items: {
id: '1',
name: 'item1',
},
}
const props = {
items: Property.Array({
displayName: 'Items',
required: true,
properties: {
id: Property.ShortText({
displayName: 'ID',
required: true,
}),
name: Property.LongText({
displayName: 'Name',
required: true,
}),
},
}),
}
const { processedInput, errors } = await propsProcessor.applyProcessorsAndValidators(input, props, PieceAuth.None(), false, {})
expect(processedInput.items).toEqual([
{ id: '1', name: 'item1' },
])
expect(errors).toEqual({})
})
})