Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,312 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BranchCondition, BranchOperator } from '@activepieces/shared'
import { evaluateConditions } from '../../src/lib/handler/router-executor'
describe('Branch evaluateConditions', () => {
describe('DATE_IS_AFTER', () => {
test.each([
null,
undefined,
'not a date',
])('should return false when one of the values is not a date %p', (value) => {
const condition: BranchCondition = {
firstValue: value as string,
secondValue: '2021-01-01',
operator: BranchOperator.DATE_IS_AFTER,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should return true when first date is after second date', () => {
const condition: BranchCondition = {
firstValue: '2021-01-02',
secondValue: '2021-01-01',
operator: BranchOperator.DATE_IS_AFTER,
}
expect(evaluateConditions([[condition]])).toEqual(true)
})
test.each([
'2021-01-01',
'2021-01-02',
])('should return false when first date is before or equal to second date', (firstDate) => {
const condition: BranchCondition = {
firstValue: firstDate,
secondValue: '2021-01-02',
operator: BranchOperator.DATE_IS_AFTER,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should return false when the date is not in a supported format', () => {
const condition: BranchCondition = {
firstValue: '2021-01-02T00:00:00Z',
secondValue: '1st January 2021',
operator: BranchOperator.DATE_IS_AFTER,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should compare time', () => {
const condition: BranchCondition = {
firstValue: '2021-01-01T00:00:02Z',
secondValue: '2021-01-01T00:00:01Z',
operator: BranchOperator.DATE_IS_AFTER,
}
expect(evaluateConditions([[condition]])).toEqual(true)
})
})
describe('DATE_IS_BEFORE', () => {
test.each([
null,
undefined,
'not a date',
])('should return false when one of the values is not a date %p', (value) => {
const condition: BranchCondition = {
firstValue: value as string,
secondValue: '2021-01-01',
operator: BranchOperator.DATE_IS_BEFORE,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should return true when first date is before second date', () => {
const condition: BranchCondition = {
firstValue: '2021-01-01',
secondValue: '2021-01-02',
operator: BranchOperator.DATE_IS_BEFORE,
}
expect(evaluateConditions([[condition]])).toEqual(true)
})
test.each([
'2021-01-01',
'2021-01-02',
])('should return false when first date is after or equal to second date', (firstDate) => {
const condition: BranchCondition = {
firstValue: firstDate,
secondValue: '2021-01-01',
operator: BranchOperator.DATE_IS_BEFORE,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should return false when the date is not in a supported format', () => {
const condition: BranchCondition = {
firstValue: '2021-01-02T00:00:00Z',
secondValue: '2nd January 2021',
operator: BranchOperator.DATE_IS_BEFORE,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should compare time', () => {
const condition: BranchCondition = {
firstValue: '2021-01-01T00:00:01Z',
secondValue: '2021-01-01T00:00:02Z',
operator: BranchOperator.DATE_IS_BEFORE,
}
expect(evaluateConditions([[condition]])).toEqual(true)
})
})
describe('DATE_IS_EQUAL', () => {
test.each([
null,
undefined,
'not a date',
])('should return false when one of the values is not a date %p', (value) => {
const condition: BranchCondition = {
firstValue: value as string,
secondValue: '2021-01-01',
operator: BranchOperator.DATE_IS_EQUAL,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should return true when first date is equal to second date', () => {
const condition: BranchCondition = {
firstValue: '2021-01-01',
secondValue: '2021-01-01',
operator: BranchOperator.DATE_IS_EQUAL,
}
expect(evaluateConditions([[condition]])).toEqual(true)
})
test.each([
'2021-01-01',
'2021-01-03',
])('should return false when first date is after or before the second date', (firstDate) => {
const condition: BranchCondition = {
firstValue: firstDate,
secondValue: '2021-01-02',
operator: BranchOperator.DATE_IS_EQUAL,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should return false when the date is not in a supported format', () => {
const condition: BranchCondition = {
firstValue: '2021-01-02T00:00:00Z',
secondValue: '2nd January 2021',
operator: BranchOperator.DATE_IS_EQUAL,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test('should compare time', () => {
const condition: BranchCondition = {
firstValue: '2021-01-01T00:00:01Z',
secondValue: '2021-01-01T00:00:01Z',
operator: BranchOperator.DATE_IS_EQUAL,
}
expect(evaluateConditions([[condition]])).toEqual(true)
})
})
describe('LIST_IS_EMPTY', () => {
test.each([
[],
'[]',
])('should return true when list is empty %p', (input: any) => {
const condition: BranchCondition = {
firstValue: input,
operator: BranchOperator.LIST_IS_EMPTY,
}
expect(evaluateConditions([[condition]])).toEqual(true)
})
test.each([
[1],
'[1]',
])('should return false when list is not empty %p', (input: any) => {
const condition: BranchCondition = {
firstValue: input,
operator: BranchOperator.LIST_IS_EMPTY,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test.each([
null,
undefined,
'not a list',
{},
])('should return false when the value is not a list %p', (input: any) => {
const condition: BranchCondition = {
firstValue: input,
operator: BranchOperator.LIST_IS_EMPTY,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
})
describe('LIST_IS_NOT_EMPTY', () => {
test.each([
[1],
'[1]',
])('should return true when list is not empty %p', (input: any) => {
const condition: BranchCondition = {
firstValue: input,
operator: BranchOperator.LIST_IS_NOT_EMPTY,
}
expect(evaluateConditions([[condition]])).toEqual(true)
})
test.each([
[],
'[]',
])('should return false when list is empty %p', (input: any) => {
const condition: BranchCondition = {
firstValue: input,
operator: BranchOperator.LIST_IS_NOT_EMPTY,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
test.each([
null,
undefined,
'not a list',
{},
])('should return false when the value is not a list %p', (input: any) => {
const condition: BranchCondition = {
firstValue: input,
operator: BranchOperator.LIST_IS_NOT_EMPTY,
}
expect(evaluateConditions([[condition]])).toEqual(false)
})
})
describe('LIST_CONTAINS', () => {
test.each([
{ expected: true, list: ['apple', 'banana', 'cherry'], value: 'banana', caseSensitive: false },
{ expected: false, list: ['apple', 'banana', 'cherry'], value: 'Banana', caseSensitive: true },
{ expected: true, list: ['apple', 'banana', 'cherry'], value: 'Banana', caseSensitive: false },
{ expected: true, list: '["apple", "banana", "cherry"]', value: 'banana', caseSensitive: false },
{ expected: true, list: 'apple', value: 'apple', caseSensitive: false },
{ expected: true, list: [1, 2, 3, 4, 5], value: '4', caseSensitive: false },
{ expected: true, list: [1, 2, 3, 4, 5], value: 4, caseSensitive: false },
{ expected: true, list: [true, false, true], value: 'true', caseSensitive: false },
{ expected: true, list: [true, false, true], value: true, caseSensitive: false },
{ expected: true, list: ['true', 'false', 'true'], value: true, caseSensitive: false },
{ expected: true, list: ['true', 'false', 'true'], value: 'true', caseSensitive: false },
])('should return $expected for list $list containing $value (case sensitive: $caseSensitive)', ({ expected, list, value, caseSensitive }) => {
const condition: BranchCondition = {
firstValue: list as any,
secondValue: value as any,
operator: BranchOperator.LIST_CONTAINS,
caseSensitive,
}
expect(evaluateConditions([[condition]])).toEqual(expected)
})
})
describe('LIST_DOES_NOT_CONTAIN', () => {
test.each([
{ expected: true, list: ['apple', 'banana', 'cherry'], value: 'grape', caseSensitive: false },
{ expected: true, list: ['apple', 'banana', 'cherry'], value: 'Banana', caseSensitive: true },
{ expected: false, list: ['apple', 'banana', 'cherry'], value: 'Banana', caseSensitive: false },
{ expected: true, list: '["apple", "banana", "cherry"]', value: 'grape', caseSensitive: false },
{ expected: true, list: 'apple', value: 'grape', caseSensitive: false },
{ expected: true, list: [1, 2, 3, 4, 5], value: '6', caseSensitive: false },
{ expected: true, list: [1, 2, 3, 4, 5], value: 6, caseSensitive: false },
{ expected: false, list: [true, false, true], value: 'false', caseSensitive: false },
{ expected: false, list: [true, false, true], value: false, caseSensitive: false },
{ expected: false, list: ['true', 'false', 'true'], value: false, caseSensitive: false },
{ expected: false, list: ['true', 'false', 'true'], value: 'false', caseSensitive: false },
])('should return $expected for list $list not containing $value (case sensitive: $caseSensitive)', ({ expected, list, value, caseSensitive }) => {
const condition: BranchCondition = {
firstValue: list as any,
secondValue: value as any,
operator: BranchOperator.LIST_DOES_NOT_CONTAIN,
caseSensitive,
}
expect(evaluateConditions([[condition]])).toEqual(expected)
})
})
})

View File

@@ -0,0 +1,521 @@
import { BranchCondition, BranchOperator, FlowRunStatus, RouterExecutionType } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { flowExecutor } from '../../src/lib/handler/flow-executor'
import { buildCodeAction, buildRouterWithOneCondition, generateMockEngineConstants } from './test-helper'
function executeBranchActionWithOneCondition(condition: BranchCondition): Promise<FlowExecutorContext> {
return flowExecutor.execute({
action: buildRouterWithOneCondition({
conditions: [condition],
executionType: RouterExecutionType.EXECUTE_FIRST_MATCH,
children: [
buildCodeAction({
name: 'echo_step',
input: {
condition: true,
},
}),
buildCodeAction({
name: 'echo_step_1',
input: {
condition: false,
},
}),
],
}),
executionState: FlowExecutorContext.empty(),
constants: generateMockEngineConstants(),
})
}
describe('flow with branching different branches', () => {
it('should execute branch with text contains condition (case insensitive)', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_CONTAINS,
firstValue: 'test',
secondValue: 'TeSt',
caseSensitive: false,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text does not contain condition (case insensitive)', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_DOES_NOT_CONTAIN,
firstValue: 'test',
secondValue: 'ExAmPlE',
caseSensitive: false,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text exactly matches condition (case insensitive)', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'TeSt',
caseSensitive: false,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text does not exactly match condition (case insensitive)', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_DOES_NOT_EXACTLY_MATCH,
firstValue: 'test',
secondValue: 'ExAmPlE',
caseSensitive: false,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text starts with condition (case insensitive)', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_STARTS_WITH,
firstValue: 'test',
secondValue: 'tE',
caseSensitive: false,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text does not start with condition (case insensitive)', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_DOES_NOT_START_WITH,
firstValue: 'test',
secondValue: 'eS',
caseSensitive: false,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text ends with condition (case insensitive)', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_ENDS_WITH,
firstValue: 'test',
secondValue: 'sT',
caseSensitive: false,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text does not end with condition (case insensitive)', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_DOES_NOT_END_WITH,
firstValue: 'test',
secondValue: 'eS',
caseSensitive: false,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text contains condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_CONTAINS,
firstValue: 'test',
secondValue: 'test',
caseSensitive: true,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text does not contain condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_DOES_NOT_CONTAIN,
firstValue: 'test',
secondValue: 'example',
caseSensitive: true,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text exactly matches condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: true,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text does not exactly match condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_DOES_NOT_EXACTLY_MATCH,
firstValue: 'test',
secondValue: 'example',
caseSensitive: true,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text starts with condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_STARTS_WITH,
firstValue: 'test',
secondValue: 'te',
caseSensitive: true,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text does not start with condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_DOES_NOT_START_WITH,
firstValue: 'test',
secondValue: 'es',
caseSensitive: true,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text ends with condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_ENDS_WITH,
firstValue: 'test',
secondValue: 'st',
caseSensitive: true,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with text does not end with condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.TEXT_DOES_NOT_END_WITH,
firstValue: 'test',
secondValue: 'es',
caseSensitive: true,
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with exists condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.EXISTS,
firstValue: 'test',
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with does not exist condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.DOES_NOT_EXIST,
firstValue: '',
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with boolean is true condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.BOOLEAN_IS_TRUE,
firstValue: 'true',
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with boolean is false condition', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.BOOLEAN_IS_FALSE,
firstValue: '{{false}}',
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with two equal numbers', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.NUMBER_IS_EQUAL_TO,
firstValue: '1',
secondValue: '1',
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with the first number greater than the second one', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.NUMBER_IS_GREATER_THAN,
firstValue: '2',
secondValue: '1',
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should execute branch with the first number less than the second one', async () => {
const result = await executeBranchActionWithOneCondition(
{
operator: BranchOperator.NUMBER_IS_LESS_THAN,
firstValue: '1',
secondValue: '2',
},
)
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router.output).toEqual({
branches: [
{
branchIndex: 1,
branchName: 'Test Branch',
evaluation: true,
},
],
})
})
it('should skip router', async () => {
const result = await flowExecutor.execute({
action: buildRouterWithOneCondition({ children: [
buildCodeAction({ name: 'echo_step', input: {}, skip: true }),
], conditions: [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
], executionType: RouterExecutionType.EXECUTE_FIRST_MATCH, skip: true }), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.router).toBeUndefined()
})
})

View File

@@ -0,0 +1,75 @@
import { FlowAction, FlowRunStatus } from '@activepieces/shared'
import { codeExecutor } from '../../src/lib/handler/code-executor'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { flowExecutor } from '../../src/lib/handler/flow-executor'
import { buildCodeAction, generateMockEngineConstants } from './test-helper'
describe('codeExecutor', () => {
it('should execute code that echo parameters action successfully', async () => {
const result = await codeExecutor.handle({
action: buildCodeAction({
name: 'echo_step',
input: {
'key': '{{ 1 + 2 }}',
},
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.echo_step.output).toEqual({ 'key': 3 })
})
it('should execute code a code that throws an error', async () => {
const result = await codeExecutor.handle({
action: buildCodeAction({
name: 'runtime',
input: {},
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.FAILED,
failedStep: {
name: 'runtime',
displayName: 'Your Action Name',
message: expect.stringContaining('Custom Runtime Error'),
},
})
expect(result.steps.runtime.status).toEqual('FAILED')
expect(result.steps.runtime.errorMessage).toContain('Custom Runtime Error')
})
it('should skip code action', async () => {
const result = await flowExecutor.execute({
action: buildCodeAction({
name: 'echo_step',
input: {},
skip: true,
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.echo_step).toBeUndefined()
})
it('should skip flow action', async () => {
const flow: FlowAction = {
...buildCodeAction({
name: 'echo_step',
skip: true,
input: {},
}),
nextAction: {
...buildCodeAction({
name: 'echo_step_1',
input: {
'key': '{{ 1 + 2 }}',
},
}),
},
}
const result = await flowExecutor.execute({
action: flow, executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.echo_step).toBeUndefined()
expect(result.steps.echo_step_1.output).toEqual({ 'key': 3 })
})
})

View File

@@ -0,0 +1,81 @@
import { FlowRunStatus } from '@activepieces/shared'
import { codeExecutor } from '../../src/lib/handler/code-executor'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { pieceExecutor } from '../../src/lib/handler/piece-executor'
import { buildCodeAction, buildPieceAction, generateMockEngineConstants } from './test-helper'
describe('code piece with error handling', () => {
it('should continue on failure when execute code a code that throws an error', async () => {
const result = await codeExecutor.handle({
action: buildCodeAction({
name: 'runtime',
input: {},
errorHandlingOptions: {
continueOnFailure: {
value: true,
},
retryOnFailure: {
value: false,
},
},
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.runtime.status).toEqual('FAILED')
expect(result.steps.runtime.errorMessage).toContain('Custom Runtime Error')
})
})
describe('piece with error handling', () => {
it('should continue on failure when piece fails', async () => {
const result = await pieceExecutor.handle({
action: buildPieceAction({
name: 'send_http',
pieceName: '@activepieces/piece-http',
actionName: 'send_request',
input: {
'method': 'POST',
'url': 'https://cloud.activepieces.com/api/v1/flags',
'headers': {},
'queryParams': {},
'body_type': 'none',
'body': {},
},
errorHandlingOptions: {
continueOnFailure: {
value: true,
},
retryOnFailure: {
value: false,
},
},
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
const expectedError = {
response: {
status: 404,
body: {
statusCode: 404,
error: 'Not Found',
message: 'Route not found',
},
},
request: {},
}
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.send_http.status).toBe('FAILED')
expect(result.steps.send_http.errorMessage).toEqual(JSON.stringify(expectedError, null, 2))
}, 10000)
})

View File

@@ -0,0 +1,91 @@
import { FlowAction, FlowRunStatus, LoopStepOutput } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { flowExecutor } from '../../src/lib/handler/flow-executor'
import { buildCodeAction, buildSimpleLoopAction, generateMockEngineConstants } from './test-helper'
describe('flow with looping', () => {
it('should execute iterations', async () => {
const codeAction = buildCodeAction({
name: 'echo_step',
input: {
'index': '{{loop.index}}',
},
})
const result = await flowExecutor.execute({
action: buildSimpleLoopAction({
name: 'loop',
loopItems: '{{ [4,5,6] }}',
firstLoopAction: codeAction,
}),
executionState: FlowExecutorContext.empty(),
constants: generateMockEngineConstants(),
})
const loopOut = result.steps.loop as LoopStepOutput
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(loopOut.output?.iterations.length).toBe(3)
expect(loopOut.output?.index).toBe(3)
expect(loopOut.output?.item).toBe(6)
})
it('should execute iterations and fail on first iteration', async () => {
const generateArray = buildCodeAction({
name: 'echo_step',
input: {
'array': '{{ [4,5,6] }}',
},
nextAction: buildSimpleLoopAction({
name: 'loop',
loopItems: '{{ echo_step.array }}',
firstLoopAction: buildCodeAction({
name: 'runtime',
input: {},
}),
}),
})
const result = await flowExecutor.execute({
action: generateArray,
executionState: FlowExecutorContext.empty(),
constants: generateMockEngineConstants(),
})
const loopOut = result.steps.loop as LoopStepOutput
expect(result.verdict.status).toBe(FlowRunStatus.FAILED)
expect(loopOut.output?.iterations.length).toBe(1)
expect(loopOut.output?.index).toBe(1)
expect(loopOut.output?.item).toBe(4)
})
it('should skip loop', async () => {
const result = await flowExecutor.execute({
action: buildSimpleLoopAction({ name: 'loop', loopItems: '{{ [4,5,6] }}', skip: true }), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.loop).toBeUndefined()
})
it('should skip loop in flow', async () => {
const flow: FlowAction = {
...buildSimpleLoopAction({ name: 'loop', loopItems: '{{ [4,5,6] }}', skip: true }),
nextAction: {
...buildCodeAction({
name: 'echo_step',
skip: false,
input: {
'key': '{{ 1 + 2 }}',
},
}),
nextAction: undefined,
},
}
const result = await flowExecutor.execute({
action: flow, executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict.status).toBe(FlowRunStatus.RUNNING)
expect(result.steps.loop).toBeUndefined()
expect(result.steps.echo_step.output).toEqual({ 'key': 3 })
})
})

View File

@@ -0,0 +1,116 @@
import { FlowAction, FlowRunStatus } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { flowExecutor } from '../../src/lib/handler/flow-executor'
import { pieceExecutor } from '../../src/lib/handler/piece-executor'
import { buildPieceAction, generateMockEngineConstants } from './test-helper'
describe('pieceExecutor', () => {
it('should execute data mapper successfully', async () => {
const result = await pieceExecutor.handle({
action: buildPieceAction({
name: 'data_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
})
it('should execute fail gracefully when pieces fail', async () => {
const result = await pieceExecutor.handle({
action: buildPieceAction({
name: 'send_http',
pieceName: '@activepieces/piece-http',
actionName: 'send_request',
input: {
'url': 'https://cloud.activepieces.com/api/v1/asd',
'method': 'GET',
'headers': {},
'body_type': 'none',
'body': {},
'queryParams': {},
},
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
const expectedError = {
response: {
status: 404,
body: {
statusCode: 404,
error: 'Not Found',
message: 'Route not found',
},
},
request: {},
}
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.FAILED,
failedStep: {
name: 'send_http',
displayName: 'Your Action Name',
message: JSON.stringify(expectedError, null, 2),
},
})
expect(result.steps.send_http.status).toBe('FAILED')
expect(result.steps.send_http.errorMessage).toEqual(JSON.stringify(expectedError, null, 2))
}, 10000)
it('should skip piece action', async () => {
const result = await flowExecutor.execute({
action: buildPieceAction({
name: 'data_mapper',
input: {},
skip: true,
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper).toBeUndefined()
})
it('should skip piece action in flow', async () => {
const flow: FlowAction = {
...buildPieceAction({
name: 'data_mapper',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
skip: false,
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
}),
nextAction: {
...buildPieceAction({
name: 'send_http',
pieceName: '@activepieces/piece-http',
actionName: 'send_request',
input: {},
skip: true,
}),
nextAction: undefined,
},
}
const result = await flowExecutor.execute({
action: flow, executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
expect(result.steps.send_http).toBeUndefined()
})
})

View File

@@ -0,0 +1,59 @@
import { FlowRunStatus } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { flowExecutor } from '../../src/lib/handler/flow-executor'
import { buildPieceAction, generateMockEngineConstants } from './test-helper'
const failedHttpAction = buildPieceAction({
name: 'send_http',
pieceName: '@activepieces/piece-http',
actionName: 'send_request',
input: {
'url': 'https://cloud.activepieces.com/api/v1/asd',
'method': 'GET',
'headers': {},
'body_type': 'none',
'body': {},
'queryParams': {},
},
})
const successHttpAction = buildPieceAction({
name: 'send_http',
pieceName: '@activepieces/piece-http',
actionName: 'send_request',
input: {
'url': 'https://cloud.activepieces.com/api/v1/pieces',
'method': 'GET',
'headers': {},
'body_type': 'none',
'body': {},
'queryParams': {},
},
})
describe('flow retry', () => {
const context = FlowExecutorContext.empty()
it('should retry entire flow', async () => {
const failedResult = await flowExecutor.execute({
action: failedHttpAction, executionState: context, constants: generateMockEngineConstants(),
})
const retryEntireFlow = await flowExecutor.execute({
action: successHttpAction, executionState: context, constants: generateMockEngineConstants(),
})
expect(failedResult.verdict.status).toBe(FlowRunStatus.FAILED)
expect(retryEntireFlow.verdict.status).toBe(FlowRunStatus.RUNNING)
}, 10000)
it('should retry flow from failed step', async () => {
const failedResult = await flowExecutor.execute({
action: failedHttpAction, executionState: context, constants: generateMockEngineConstants(),
})
const retryFromFailed = await flowExecutor.execute({
action: successHttpAction, executionState: context, constants: generateMockEngineConstants({}),
})
expect(failedResult.verdict.status).toBe(FlowRunStatus.FAILED)
expect(retryFromFailed.verdict.status).toBe(FlowRunStatus.RUNNING)
}, 10000)
})

View File

@@ -0,0 +1,275 @@
import { BranchOperator, FlowRunStatus, LoopStepOutput, RouterExecutionType, RouterStepOutput } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { StepExecutionPath } from '../../src/lib/handler/context/step-execution-path'
import { flowExecutor } from '../../src/lib/handler/flow-executor'
import { buildCodeAction, buildPieceAction, buildRouterWithOneCondition, buildSimpleLoopAction, generateMockEngineConstants } from './test-helper'
const simplePauseFlow = buildPieceAction({
name: 'approval',
pieceName: '@activepieces/piece-approval',
actionName: 'wait_for_approval',
input: {},
nextAction: buildCodeAction({
name: 'echo_step',
input: {},
}),
})
const flawWithTwoPause = buildPieceAction({
name: 'approval',
pieceName: '@activepieces/piece-approval',
actionName: 'wait_for_approval',
input: {},
nextAction: buildCodeAction({
name: 'echo_step',
input: {},
nextAction: buildPieceAction({
name: 'approval-1',
pieceName: '@activepieces/piece-approval',
actionName: 'wait_for_approval',
input: {},
nextAction: buildCodeAction({
name: 'echo_step_1',
input: {},
}),
}),
}),
})
const pauseFlowWithLoopAndBranch = buildSimpleLoopAction({
name: 'loop',
loopItems: '{{ [false, true ] }}',
firstLoopAction: buildRouterWithOneCondition({
conditions: [
{
operator: BranchOperator.BOOLEAN_IS_TRUE,
firstValue: '{{ loop.item }}',
},
],
executionType: RouterExecutionType.EXECUTE_FIRST_MATCH,
children: [
simplePauseFlow,
],
}),
})
describe('flow with pause', () => {
it('should pause and resume successfully with loops and branch', async () => {
const pauseResult = await flowExecutor.execute({
action: pauseFlowWithLoopAndBranch,
executionState: FlowExecutorContext.empty().setPauseRequestId('requestId'),
constants: generateMockEngineConstants(),
})
expect(pauseResult.verdict).toEqual({
status: FlowRunStatus.PAUSED,
pauseMetadata: {
response: {},
requestId: 'requestId',
requestIdToReply: undefined,
'type': 'WEBHOOK',
},
})
expect(Object.keys(pauseResult.steps)).toEqual(['loop'])
// Verify that the first iteration (true) triggered the branch condition
const loopOutputBeforeResume = pauseResult.steps.loop as LoopStepOutput
expect(loopOutputBeforeResume.output?.iterations.length).toBe(2)
expect(loopOutputBeforeResume.output?.item).toBe(true)
expect(Object.keys(loopOutputBeforeResume.output?.iterations[0] ?? {})).toContain('router')
const resumeResultTwo = await flowExecutor.execute({
action: pauseFlowWithLoopAndBranch,
executionState: pauseResult.setCurrentPath(StepExecutionPath.empty()).setVerdict({
status: FlowRunStatus.RUNNING,
}),
constants: generateMockEngineConstants({
resumePayload: {
queryParams: {
action: 'approve',
},
body: {},
headers: {},
},
}),
})
expect(resumeResultTwo.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
},
)
expect(Object.keys(resumeResultTwo.steps)).toEqual(['loop'])
const loopOut = resumeResultTwo.steps.loop as LoopStepOutput
expect(Object.keys(loopOut.output?.iterations[1] ?? {})).toEqual(['router', 'approval', 'echo_step'])
expect((loopOut.output?.iterations[0].router as RouterStepOutput).output?.branches[0].evaluation).toBe(false)
expect((loopOut.output?.iterations[1].router as RouterStepOutput).output?.branches[0].evaluation).toBe(true)
})
it('should pause and resume with two different steps in same flow successfully', async () => {
const pauseResult1 = await flowExecutor.execute({
action: flawWithTwoPause,
executionState: FlowExecutorContext.empty().setPauseRequestId('requestId'),
constants: generateMockEngineConstants(),
})
const resumeResult1 = await flowExecutor.execute({
action: flawWithTwoPause,
executionState: pauseResult1,
constants: generateMockEngineConstants({
resumePayload: {
queryParams: {
action: 'approve',
},
body: {},
headers: {},
},
}),
})
expect(resumeResult1.verdict).toStrictEqual({
status: FlowRunStatus.PAUSED,
pauseMetadata: {
response: {},
requestId: 'requestId',
requestIdToReply: undefined,
'type': 'WEBHOOK',
},
})
const resumeResult2 = await flowExecutor.execute({
action: flawWithTwoPause,
executionState: resumeResult1.setVerdict({
status: FlowRunStatus.RUNNING,
}),
constants: generateMockEngineConstants({
resumePayload: {
queryParams: {
action: 'approve',
},
body: {},
headers: {},
},
}),
})
expect(resumeResult2.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
})
it('should pause and resume successfully', async () => {
const pauseResult = await flowExecutor.execute({
action: simplePauseFlow,
executionState: FlowExecutorContext.empty().setPauseRequestId('requestId'),
constants: generateMockEngineConstants(),
})
expect(pauseResult.verdict).toStrictEqual({
status: FlowRunStatus.PAUSED,
pauseMetadata: {
response: {},
requestId: 'requestId',
requestIdToReply: undefined,
'type': 'WEBHOOK',
},
})
const currentState = pauseResult.currentState()
expect(Object.keys(currentState).length).toBe(1)
const resumeResult = await flowExecutor.execute({
action: simplePauseFlow,
executionState: pauseResult,
constants: generateMockEngineConstants({
resumePayload: {
queryParams: {
action: 'approve',
},
body: {},
headers: {},
},
}),
})
expect(resumeResult.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(resumeResult.currentState()).toEqual({
'approval': {
approved: true,
},
echo_step: {},
})
})
it('should pause at most one action when router has multiple branches with pause actions', async () => {
const routerWithTwoPauseActions = buildRouterWithOneCondition({
conditions: [
{
operator: BranchOperator.BOOLEAN_IS_TRUE,
firstValue: 'true',
},
{
operator: BranchOperator.BOOLEAN_IS_TRUE,
firstValue: 'true',
},
],
executionType: RouterExecutionType.EXECUTE_ALL_MATCH,
children: [
buildPieceAction({
name: 'approval_1',
pieceName: '@activepieces/piece-approval',
actionName: 'wait_for_approval',
input: {},
nextAction: buildCodeAction({
name: 'echo_step',
input: {},
}),
}),
buildPieceAction({
name: 'approval_2',
pieceName: '@activepieces/piece-approval',
actionName: 'wait_for_approval',
input: {},
nextAction: buildCodeAction({
name: 'echo_step_1',
input: {},
}),
}),
],
})
const result = await flowExecutor.execute({
action: routerWithTwoPauseActions,
executionState: FlowExecutorContext.empty().setPauseRequestId('requestId'),
constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.PAUSED,
pauseMetadata: {
response: {},
requestId: 'requestId',
requestIdToReply: undefined,
'type': 'WEBHOOK',
},
})
const routerOutput = result.steps.router as RouterStepOutput
expect(routerOutput).toBeDefined()
expect(routerOutput.output).toBeDefined()
const executedBranches = routerOutput.output?.branches?.filter((branch) => branch.evaluation === true)
expect(executedBranches).toHaveLength(2)
expect(result.steps.approval_1).toBeDefined()
expect(result.steps.approval_1.status).toBe('PAUSED')
expect(result.steps.approval_2).toBeUndefined()
expect(Object.keys(result.steps)).toEqual(['router', 'approval_1'])
})
})

View File

@@ -0,0 +1,47 @@
import { FlowRunStatus } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { flowExecutor } from '../../src/lib/handler/flow-executor'
import { buildPieceAction, generateMockEngineConstants } from './test-helper'
describe('flow with response', () => {
it('should execute return response successfully', async () => {
const input = {
responseType: 'json',
fields: {
status: 200,
headers: {
'random': 'header',
},
body: {
'hello': 'world',
},
},
respond: 'stop',
}
const response = {
status: 200,
headers: {
'random': 'header',
},
body: {
'hello': 'world',
},
}
const result = await flowExecutor.execute({
action: buildPieceAction({
name: 'http',
pieceName: '@activepieces/piece-webhook',
actionName: 'return_response',
input,
}), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.SUCCEEDED,
stopResponse: response,
})
expect(result.steps.http.output).toEqual(response)
})
})

View File

@@ -0,0 +1,433 @@
import { BranchCondition, BranchOperator, FlowAction, FlowRunStatus, RouterExecutionType } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { flowExecutor } from '../../src/lib/handler/flow-executor'
import { buildCodeAction, buildPieceAction, buildRouterWithOneCondition, generateMockEngineConstants } from './test-helper'
function executeRouterActionWithOneCondition(children: FlowAction[], conditions: (BranchCondition | null)[], executionType: RouterExecutionType): Promise<FlowExecutorContext> {
return flowExecutor.execute({
action: buildRouterWithOneCondition({
children,
conditions,
executionType,
}),
executionState: FlowExecutorContext.empty(),
constants: generateMockEngineConstants(),
})
}
describe('router with branching different conditions', () => {
it('should execute router with the first matching condition', async () => {
const result = await executeRouterActionWithOneCondition([
buildPieceAction({
name: 'data_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
buildPieceAction({
name: 'data_mapper_1',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
], [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'anything',
caseSensitive: false,
},
], RouterExecutionType.EXECUTE_FIRST_MATCH)
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
expect(result.steps.data_mapper_1).toBeUndefined()
})
it('should execute router with the all matching conditions', async () => {
const result = await executeRouterActionWithOneCondition([
buildPieceAction({
name: 'data_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
buildPieceAction({
name: 'data_mapper_1',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
], [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
], RouterExecutionType.EXECUTE_ALL_MATCH)
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
expect(result.steps.data_mapper_1.output).toEqual({ 'key': 3 })
})
it('should execute router but no branch will match', async () => {
const result = await executeRouterActionWithOneCondition([
buildPieceAction({
name: 'data_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
buildPieceAction({
name: 'data_mapper_1',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 5 }}',
},
},
}),
], [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'abc',
secondValue: 'test',
caseSensitive: false,
},
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'fasc',
caseSensitive: false,
},
], RouterExecutionType.EXECUTE_ALL_MATCH)
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
const routerOutput = result.steps.router.output as { branches: boolean[] }
expect(routerOutput.branches).toEqual([
{
branchName: 'Test Branch',
branchIndex: 1,
evaluation: false,
},
{
branchName: 'Test Branch',
branchIndex: 2,
evaluation: false,
},
])
expect(result.steps.data_mapper).toBeUndefined()
expect(result.steps.data_mapper_1).toBeUndefined()
})
it('should execute fallback branch with first match execution type', async () => {
const result = await executeRouterActionWithOneCondition([
buildPieceAction({
name: 'data_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
buildPieceAction({
name: 'data_mapper_1',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 5 }}',
},
},
}),
buildPieceAction({
name: 'fallback_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 10 }}',
},
},
}),
], [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'abc',
secondValue: 'test',
caseSensitive: false,
},
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'fasc',
caseSensitive: false,
},
null, // Fallback branch
], RouterExecutionType.EXECUTE_FIRST_MATCH)
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper).toBeUndefined()
expect(result.steps.data_mapper_1).toBeUndefined()
expect(result.steps.fallback_mapper.output).toEqual({ 'key': 11 })
})
it('should execute fallback branch with all match execution type', async () => {
const result = await executeRouterActionWithOneCondition([
buildPieceAction({
name: 'data_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
buildPieceAction({
name: 'data_mapper_1',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 5 }}',
},
},
}),
buildPieceAction({
name: 'fallback_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 10 }}',
},
},
}),
], [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'abc',
secondValue: 'test',
caseSensitive: false,
},
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'fasc',
caseSensitive: false,
},
null, // Fallback branch
], RouterExecutionType.EXECUTE_ALL_MATCH)
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper).toBeUndefined()
expect(result.steps.data_mapper_1).toBeUndefined()
expect(result.steps.fallback_mapper.output).toEqual({ 'key': 11 })
})
it('should not execute fallback branch when there is a matching condition in EXECUTE_FIRST_MATCH mode', async () => {
const result = await executeRouterActionWithOneCondition([
buildPieceAction({
name: 'data_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
buildPieceAction({
name: 'fallback_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 10 }}',
},
},
}),
], [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
null, // Fallback branch
], RouterExecutionType.EXECUTE_FIRST_MATCH)
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
expect(result.steps.fallback_mapper).toBeUndefined()
})
it('should not execute fallback branch when there is a matching condition in EXECUTE_ALL_MATCH mode', async () => {
const result = await executeRouterActionWithOneCondition([
buildPieceAction({
name: 'data_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 2 }}',
},
},
}),
buildPieceAction({
name: 'data_mapper_1',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 5 }}',
},
},
}),
buildPieceAction({
name: 'fallback_mapper',
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {
mapping: {
'key': '{{ 1 + 10 }}',
},
},
}),
], [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
null, // Fallback branch
], RouterExecutionType.EXECUTE_ALL_MATCH)
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.data_mapper.output).toEqual({ 'key': 3 })
expect(result.steps.data_mapper_1.output).toEqual({ 'key': 6 })
expect(result.steps.fallback_mapper).toBeUndefined()
})
it('should skip router', async () => {
const result = await flowExecutor.execute({
action: buildRouterWithOneCondition({ children: [
buildPieceAction({
name: 'data_mapper',
skip: true,
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {},
}),
], conditions: [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
], executionType: RouterExecutionType.EXECUTE_FIRST_MATCH, skip: true }), executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.router).toBeUndefined()
})
it('should skip router action in flow', async () => {
const router: FlowAction = {
...buildRouterWithOneCondition({ children: [
buildPieceAction({
name: 'data_mapper',
skip: true,
pieceName: '@activepieces/piece-data-mapper',
actionName: 'advanced_mapping',
input: {},
}),
], conditions: [
{
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
firstValue: 'test',
secondValue: 'test',
caseSensitive: false,
},
],
executionType: RouterExecutionType.EXECUTE_FIRST_MATCH,
skip: true }),
nextAction: {
...buildCodeAction({
name: 'echo_step',
skip: false,
input: {
'key': '{{ 1 + 2 }}',
},
}),
nextAction: undefined,
},
}
const result = await flowExecutor.execute({
action: router, executionState: FlowExecutorContext.empty(), constants: generateMockEngineConstants(),
})
expect(result.verdict).toStrictEqual({
status: FlowRunStatus.RUNNING,
})
expect(result.steps.router).toBeUndefined()
expect(result.steps.echo_step.output).toEqual({ 'key': 3 })
})
})

View File

@@ -0,0 +1,122 @@
import { ActionErrorHandlingOptions, BranchCondition, BranchExecutionType, CodeAction, FlowAction, FlowActionType, FlowVersionState, LoopOnItemsAction, PieceAction, ProgressUpdateType, PropertyExecutionType, RouterExecutionType, RunEnvironment } from '@activepieces/shared'
import { EngineConstants } from '../../src/lib/handler/context/engine-constants'
export const generateMockEngineConstants = (params?: Partial<EngineConstants>): EngineConstants => {
return new EngineConstants(
{
platformId: params?.platformId ?? 'platformId',
timeoutInSeconds: params?.timeoutInSeconds ?? 10,
flowId: params?.flowId ?? 'flowId',
flowVersionId: params?.flowVersionId ?? 'flowVersionId',
flowVersionState: params?.flowVersionState ?? FlowVersionState.DRAFT,
flowRunId: params?.flowRunId ?? 'flowRunId',
publicApiUrl: params?.publicApiUrl ?? 'http://127.0.0.1:4200/api/',
internalApiUrl: params?.internalApiUrl ?? 'http://127.0.0.1:3000/',
retryConstants: params?.retryConstants ?? {
maxAttempts: 2,
retryExponential: 1,
retryInterval: 1,
},
engineToken: params?.engineToken ?? 'engineToken',
projectId: params?.projectId ?? 'projectId',
triggerPieceName: params?.triggerPieceName ?? 'mcp-trigger-piece-name',
progressUpdateType: params?.progressUpdateType ?? ProgressUpdateType.NONE,
serverHandlerId: params?.serverHandlerId ?? null,
httpRequestId: params?.httpRequestId ?? null,
resumePayload: params?.resumePayload,
runEnvironment: params?.runEnvironment ?? RunEnvironment.TESTING,
stepNameToTest: params?.stepNameToTest ?? undefined,
})
}
export function buildSimpleLoopAction({
name,
loopItems,
firstLoopAction,
skip,
}: {
name: string
loopItems: string
firstLoopAction?: FlowAction
skip?: boolean
}): LoopOnItemsAction {
return {
name,
displayName: 'Loop',
type: FlowActionType.LOOP_ON_ITEMS,
skip: skip ?? false,
settings: {
items: loopItems,
},
firstLoopAction,
valid: true,
}
}
export function buildRouterWithOneCondition({ children, conditions, executionType, skip }: { children: FlowAction[], conditions: (BranchCondition | null)[], executionType: RouterExecutionType, skip?: boolean }): FlowAction {
return {
name: 'router',
displayName: 'Your Router Name',
type: FlowActionType.ROUTER,
skip: skip ?? false,
settings: {
branches: conditions.map((condition) => {
if (condition === null) {
return {
branchType: BranchExecutionType.FALLBACK,
branchName: 'Fallback Branch',
}
}
return {
conditions: [[condition]],
branchType: BranchExecutionType.CONDITION,
branchName: 'Test Branch',
}
}),
executionType,
},
children,
valid: true,
}
}
export function buildCodeAction({ name, input, skip, nextAction, errorHandlingOptions }: { name: 'echo_step' | 'runtime' | 'echo_step_1', input: Record<string, unknown>, skip?: boolean, errorHandlingOptions?: ActionErrorHandlingOptions, nextAction?: FlowAction }): CodeAction {
return {
name,
displayName: 'Your Action Name',
type: FlowActionType.CODE,
skip: skip ?? false,
settings: {
input,
sourceCode: {
packageJson: '',
code: '',
},
errorHandlingOptions,
},
nextAction,
valid: true,
}
}
export function buildPieceAction({ name, input, skip, pieceName, actionName, nextAction, errorHandlingOptions }: { errorHandlingOptions?: ActionErrorHandlingOptions, name: string, input: Record<string, unknown>, skip?: boolean, pieceName: string, actionName: string, nextAction?: FlowAction }): PieceAction {
return {
name,
displayName: 'Your Action Name',
type: FlowActionType.PIECE,
skip: skip ?? false,
settings: {
input,
pieceName,
pieceVersion: '1.0.0', // Not required since it's running in development mode
actionName,
propertySettings: Object.fromEntries(Object.entries(input).map(([key]) => [key, {
type: PropertyExecutionType.MANUAL,
schema: undefined,
}])),
errorHandlingOptions,
},
nextAction,
valid: true,
}
}

View File

@@ -0,0 +1,100 @@
import { FlowRunStatus } from '@activepieces/shared'
import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context'
import { runWithExponentialBackoff } from '../../src/lib/helper/error-handling'
import { buildCodeAction, generateMockEngineConstants } from '../handler/test-helper'
describe('runWithExponentialBackoff', () => {
const executionState = FlowExecutorContext.empty()
const action = buildCodeAction({
name: 'runtime',
input: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: true,
},
},
})
const constants = generateMockEngineConstants()
const requestFunction = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
afterAll(() => {
jest.clearAllMocks()
})
it('should return resultExecutionState when verdict is not FAILED', async () => {
const resultExecutionState = FlowExecutorContext.empty().setVerdict({
status: FlowRunStatus.SUCCEEDED,
stopResponse: undefined,
})
requestFunction.mockResolvedValue(resultExecutionState)
const output = await runWithExponentialBackoff(executionState, action, constants, requestFunction)
expect(output).toEqual(resultExecutionState)
expect(requestFunction).toHaveBeenCalledWith({ action, executionState, constants })
})
it('should retry and return resultExecutionState when verdict is FAILED and retry is enabled', async () => {
const resultExecutionState = FlowExecutorContext.empty().setVerdict({
status: FlowRunStatus.FAILED,
failedStep: {
name: 'runtime',
displayName: 'runtime',
message: 'Custom Runtime Error',
},
})
requestFunction.mockResolvedValue(resultExecutionState)
const output = await runWithExponentialBackoff(executionState, action, constants, requestFunction)
expect(output).toEqual(resultExecutionState)
// Mock applies for the first attempt and second attempt is a real call which return success
expect(requestFunction).toHaveBeenCalledTimes(2)
expect(requestFunction).toHaveBeenCalledWith({ action, executionState, constants })
expect(requestFunction).toHaveBeenCalledWith({ action, executionState, constants })
})
it('should not retry and return resultExecutionState when verdict is FAILED but retry is disabled', async () => {
const resultExecutionState = FlowExecutorContext.empty().setVerdict({
status: FlowRunStatus.FAILED,
failedStep: {
name: 'runtime',
displayName: 'runtime',
message: 'Custom Runtime Error',
},
})
requestFunction.mockResolvedValue(resultExecutionState)
const actionWithDisabledRetry = buildCodeAction({
name: 'runtime',
input: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
})
const output = await runWithExponentialBackoff(executionState, actionWithDisabledRetry, constants, requestFunction)
expect(output).toEqual(resultExecutionState)
expect(requestFunction).toHaveBeenCalledTimes(1)
expect(requestFunction).toHaveBeenCalledWith({ action: actionWithDisabledRetry, executionState, constants })
})
})

View File

@@ -0,0 +1,26 @@
import {
FlowActionType,
GenericStepOutput,
StepOutputStatus,
} from '@activepieces/shared'
import { loggingUtils } from '../../src/lib/helper/logging-utils'
describe('Logging Utils', () => {
it('Should not truncate whole step if its log size exceeds limit', async () => {
const steps = {
mockStep: GenericStepOutput.create({
type: FlowActionType.CODE,
status: StepOutputStatus.SUCCEEDED,
input: {
a: 'a'.repeat(1024 * 1024 * 12),
},
}),
}
// act
const result = await loggingUtils.trimExecution(steps)
// assert
expect((result.mockStep.input as Record<string, string>).a.length).toBeLessThan(1024 * 1024 * 12)
})
})

View File

@@ -0,0 +1,6 @@
module.exports = {
code: async (params) => {
return params;
}
};

View File

@@ -0,0 +1,6 @@
module.exports = {
code: async (params) => {
return params;
}
};

View File

@@ -0,0 +1,6 @@
module.exports = {
code: async (params) => {
throw new Error('Custom Runtime Error');
}
};

View File

@@ -0,0 +1,753 @@
import { ApFile, LATEST_CONTEXT_VERSION, 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',
contextVersion: LATEST_CONTEXT_VERSION,
})
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({
auth: undefined,
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({})
})
})

View File

@@ -0,0 +1,342 @@
import { PieceAuth, Property } from '@activepieces/pieces-framework'
import { propsProcessor } from '../../src/lib/variables/props-processor'
describe('Property Validation', () => {
describe('required properties', () => {
it('should validate required string property', async () => {
const props = {
text: Property.ShortText({
displayName: 'Text',
required: true,
}),
}
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
{ text: 'valid text' },
props,
PieceAuth.None(),
false,
{},
)
expect(validErrors).toEqual({})
const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators(
{ text: null },
props,
PieceAuth.None(),
false,
{},
)
expect(nullErrors).toEqual({
text: ['Expected string, received: null'],
})
const { errors: undefinedErrors } = await propsProcessor.applyProcessorsAndValidators(
{ text: undefined },
props,
PieceAuth.None(),
false,
{},
)
expect(undefinedErrors).toEqual({
text: ['Expected string, received: undefined'],
})
})
it('should validate required number property', async () => {
const props = {
number: Property.Number({
displayName: 'Number',
required: true,
}),
}
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
{ number: 42 },
props,
PieceAuth.None(),
false,
{},
)
expect(validErrors).toEqual({})
const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators(
{ number: null },
props,
PieceAuth.None(),
false,
{},
)
expect(nullErrors).toEqual({
number: ['Expected number, received: null'],
})
const { errors: typeErrors } = await propsProcessor.applyProcessorsAndValidators(
{ number: 'not a number' },
props,
PieceAuth.None(),
false,
{},
)
expect(typeErrors).toEqual({
number: ['Expected number, received: not a number'],
})
})
it('should validate required datetime property', async () => {
const props = {
date: Property.DateTime({
displayName: 'DateTime',
required: true,
}),
}
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
{ date: '2024-03-14T12:00:00.000Z' },
props,
PieceAuth.None(),
false,
{},
)
expect(validErrors).toEqual({})
const { errors: invalidErrors } = await propsProcessor.applyProcessorsAndValidators(
{ date: 'not a date' },
props,
PieceAuth.None(),
false,
{},
)
expect(invalidErrors).toEqual({
date: ['Invalid datetime format. Expected ISO format (e.g. 2024-03-14T12:00:00.000Z), received: not a date'],
})
})
it('should validate required array property', async () => {
const props = {
array: Property.Array({
displayName: 'Array',
required: true,
}),
}
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
{ array: [1, 2, 3] },
props,
PieceAuth.None(),
false,
{},
)
expect(validErrors).toEqual({})
const { errors: typeErrors } = await propsProcessor.applyProcessorsAndValidators(
{ array: 'not an array' },
props,
PieceAuth.None(),
false,
{},
)
expect(typeErrors).toEqual({
array: ['Expected array, received: not an array'],
})
})
it('should validate required json property', async () => {
const props = {
json: Property.Json({
displayName: 'JSON',
required: true,
}),
}
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
{ json: { key: 'value' } },
props,
PieceAuth.None(),
false,
{},
)
expect(validErrors).toEqual({})
const { errors: validJsonStringErrors } = await propsProcessor.applyProcessorsAndValidators(
{ json: '{"key": "value"}' },
props,
PieceAuth.None(),
false,
{},
)
expect(validJsonStringErrors).toEqual({})
const { errors: validArrayErrors } = await propsProcessor.applyProcessorsAndValidators(
{ json: [1, 2, 3] },
props,
PieceAuth.None(),
false,
{},
)
expect(validArrayErrors).toEqual({})
const { errors: validArrayStringErrors } = await propsProcessor.applyProcessorsAndValidators(
{ json: '[1, 2, 3]' },
props,
PieceAuth.None(),
false,
{},
)
expect(validArrayStringErrors).toEqual({})
const { errors: invalidJsonErrors } = await propsProcessor.applyProcessorsAndValidators(
{ json: 'not a json object' },
props,
PieceAuth.None(),
false,
{},
)
expect(invalidJsonErrors).toEqual({
json: ['Expected JSON, received: not a json object'],
})
const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators(
{ json: null },
props,
PieceAuth.None(),
false,
{},
)
expect(nullErrors).toEqual({
json: ['Expected JSON, received: null'],
})
})
it('should validate required object property', async () => {
const props = {
object: Property.Object({
displayName: 'Object',
required: true,
}),
}
const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators(
{ object: { key: 'value' } },
props,
PieceAuth.None(),
false,
{},
)
expect(validErrors).toEqual({})
const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators(
{ object: null },
props,
PieceAuth.None(),
false,
{},
)
expect(nullErrors).toEqual({
object: ['Expected object, received: null'],
})
const { errors: typeErrors } = await propsProcessor.applyProcessorsAndValidators(
{ object: 'not an object' },
props,
PieceAuth.None(),
false,
{},
)
expect(typeErrors).toEqual({
object: ['Expected object, received: not an object'],
})
const { errors: jsonStringErrors } = await propsProcessor.applyProcessorsAndValidators(
{ object: JSON.stringify({ key: 'value' }) },
props,
PieceAuth.None(),
false,
{},
)
expect(jsonStringErrors).toEqual({})
const { errors: undefinedErrors } = await propsProcessor.applyProcessorsAndValidators(
{ object: { key: 'value' } },
props,
PieceAuth.None(),
false,
{},
)
expect(undefinedErrors).toEqual({})
})
})
describe('optional properties', () => {
it('should validate optional properties', async () => {
const props = {
text: Property.ShortText({
displayName: 'Text',
required: false,
}),
number: Property.Number({
displayName: 'Number',
required: false,
}),
}
const { errors } = await propsProcessor.applyProcessorsAndValidators(
{
text: null,
number: undefined,
},
props,
PieceAuth.None(),
false,
{},
)
expect(errors).toEqual({})
})
})
describe('type validation', () => {
it('should validate property types', async () => {
const props = {
string: Property.ShortText({
displayName: 'Text',
required: true,
}),
number: Property.Number({
displayName: 'Number',
required: true,
}),
boolean: Property.Checkbox({
displayName: 'Checkbox',
required: true,
}),
array: Property.Array({
displayName: 'Array',
required: true,
}),
object: Property.Object({
displayName: 'Object',
required: true,
}),
}
const { errors } = await propsProcessor.applyProcessorsAndValidators(
{
string: 42,
number: 'not a number',
boolean: 'not a boolean',
array: 'not an array',
object: 'not an object',
},
props,
PieceAuth.None(),
false,
{},
)
expect(errors).toEqual({
number: ['Expected number, received: not a number'],
boolean: ['Expected boolean, received: not a boolean'],
array: ['Expected array, received: not an array'],
object: ['Expected object, received: not an object'],
})
})
})
})