diff --git a/packages/server/src/automations/tests/scenarios/branching.spec.ts b/packages/server/src/automations/tests/scenarios/branching.spec.ts new file mode 100644 index 0000000000..78d3ebd877 --- /dev/null +++ b/packages/server/src/automations/tests/scenarios/branching.spec.ts @@ -0,0 +1,199 @@ +import * as automation from "../../index" +import * as setup from "../utilities" +import { Table, AutomationStatus } from "@budibase/types" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" + +describe("Branching automations", () => { + let config = setup.getConfig(), + table: Table + + beforeEach(async () => { + await automation.init() + await config.init() + table = await config.createTable() + await config.createRow() + }) + + afterAll(setup.afterAll) + + it("should run a multiple nested branching automation", async () => { + const builder = createAutomationBuilder({ + name: "Test Trigger with Loop and Create Row", + }) + + const results = await builder + .appAction({ fields: {} }) + .serverLog({ text: "Starting automation" }) + .branch({ + topLevelBranch1: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Branch 1" }).branch({ + branch1: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Branch 1.1" }), + condition: { + equal: { "steps.1.success": true }, + }, + }, + branch2: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Branch 1.2" }), + condition: { + equal: { "steps.1.success": false }, + }, + }, + }), + condition: { + equal: { "steps.1.success": true }, + }, + }, + topLevelBranch2: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }), + condition: { + equal: { "steps.1.success": false }, + }, + }, + }) + .run() + expect(results.steps[3].outputs.status).toContain("branch1 branch taken") + expect(results.steps[4].outputs.message).toContain("Branch 1.1") + }) + + it("should execute correct branch based on string equality", async () => { + const builder = createAutomationBuilder({ + name: "String Equality Branching", + }) + + const results = await builder + .appAction({ fields: { status: "active" } }) + .branch({ + activeBranch: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Active user" }), + condition: { + equal: { "trigger.fields.status": "active" }, + }, + }, + inactiveBranch: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Inactive user" }), + condition: { + equal: { "trigger.fields.status": "inactive" }, + }, + }, + }) + .run() + expect(results.steps[0].outputs.status).toContain( + "activeBranch branch taken" + ) + expect(results.steps[1].outputs.message).toContain("Active user") + }) + + it("should handle multiple conditions with AND operator", async () => { + const builder = createAutomationBuilder({ + name: "Multiple AND Conditions Branching", + }) + + const results = await builder + .appAction({ fields: { status: "active", role: "admin" } }) + .branch({ + activeAdminBranch: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Active admin user" }), + condition: { + $and: { + conditions: [ + { equal: { "trigger.fields.status": "active" } }, + { equal: { "trigger.fields.role": "admin" } }, + ], + }, + }, + }, + otherBranch: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Other user" }), + condition: { + notEqual: { "trigger.fields.status": "active" }, + }, + }, + }) + .run() + + expect(results.steps[1].outputs.message).toContain("Active admin user") + }) + + it("should handle multiple conditions with OR operator", async () => { + const builder = createAutomationBuilder({ + name: "Multiple OR Conditions Branching", + }) + + const results = await builder + .appAction({ fields: { status: "test", role: "user" } }) + .branch({ + specialBranch: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Special user" }), + condition: { + $or: { + conditions: [ + { equal: { "trigger.fields.status": "test" } }, + { equal: { "trigger.fields.role": "admin" } }, + ], + }, + }, + }, + regularBranch: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Regular user" }), + condition: { + $and: { + conditions: [ + { notEqual: { "trigger.fields.status": "active" } }, + { notEqual: { "trigger.fields.role": "admin" } }, + ], + }, + }, + }, + }) + .run() + + expect(results.steps[1].outputs.message).toContain("Special user") + }) + + it("should handlestop the branch automation when no conditions are met", async () => { + const builder = createAutomationBuilder({ + name: "Multiple OR Conditions Branching", + }) + + const results = await builder + .appAction({ fields: { status: "test", role: "user" } }) + .createRow({ row: { name: "Test", tableId: table._id } }) + .branch({ + specialBranch: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Special user" }), + condition: { + $or: { + conditions: [ + { equal: { "trigger.fields.status": "new" } }, + { equal: { "trigger.fields.role": "admin" } }, + ], + }, + }, + }, + regularBranch: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Regular user" }), + condition: { + $and: { + conditions: [ + { equal: { "trigger.fields.status": "active" } }, + { equal: { "trigger.fields.role": "admin" } }, + ], + }, + }, + }, + }) + .serverLog({ text: "Test" }) + .run() + + expect(results.steps[1].outputs.status).toEqual( + AutomationStatus.NO_CONDITION_MET + ) + expect(results.steps[2]).toBeUndefined() + }) +}) diff --git a/packages/server/src/automations/tests/scenarios/looping.spec.ts b/packages/server/src/automations/tests/scenarios/looping.spec.ts new file mode 100644 index 0000000000..9bc382a187 --- /dev/null +++ b/packages/server/src/automations/tests/scenarios/looping.spec.ts @@ -0,0 +1,245 @@ +import * as automation from "../../index" +import * as setup from "../utilities" +import { + Table, + LoopStepType, + CreateRowStepOutputs, + ServerLogStepOutputs, +} from "@budibase/types" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" + +describe("Loop automations", () => { + let config = setup.getConfig(), + table: Table + + beforeEach(async () => { + await automation.init() + await config.init() + table = await config.createTable() + await config.createRow() + }) + + afterAll(setup.afterAll) + + it("should run an automation with a trigger, loop, and create row step", async () => { + const builder = createAutomationBuilder({ + name: "Test Trigger with Loop and Create Row", + }) + + const results = await builder + .rowSaved( + { tableId: table._id! }, + { + row: { + name: "Trigger Row", + description: "This row triggers the automation", + }, + id: "1234", + revision: "1", + } + ) + .loop({ + option: LoopStepType.ARRAY, + binding: [1, 2, 3], + }) + .createRow({ + row: { + name: "Item {{ loop.currentItem }}", + description: "Created from loop", + tableId: table._id, + }, + }) + .run() + + expect(results.trigger).toBeDefined() + expect(results.steps).toHaveLength(1) + + expect(results.steps[0].outputs.iterations).toBe(3) + expect(results.steps[0].outputs.items).toHaveLength(3) + + results.steps[0].outputs.items.forEach((output: any, index: number) => { + expect(output).toMatchObject({ + success: true, + row: { + name: `Item ${index + 1}`, + description: "Created from loop", + }, + }) + }) + }) + + it("should run an automation where a loop step is between two normal steps to ensure context correctness", async () => { + const builder = createAutomationBuilder({ + name: "Test Trigger with Loop and Create Row", + }) + + const results = await builder + .rowSaved( + { tableId: table._id! }, + { + row: { + name: "Trigger Row", + description: "This row triggers the automation", + }, + id: "1234", + revision: "1", + } + ) + .queryRows({ + tableId: table._id!, + }) + .loop({ + option: LoopStepType.ARRAY, + binding: [1, 2, 3], + }) + .serverLog({ text: "Message {{loop.currentItem}}" }) + .serverLog({ text: "{{steps.1.rows.0._id}}" }) + .run() + + results.steps[1].outputs.items.forEach( + (output: ServerLogStepOutputs, index: number) => { + expect(output).toMatchObject({ + success: true, + }) + expect(output.message).toContain(`Message ${index + 1}`) + } + ) + + expect(results.steps[2].outputs.message).toContain("ro_ta") + }) + + it("if an incorrect type is passed to the loop it should return an error", async () => { + const builder = createAutomationBuilder({ + name: "Test Loop error", + }) + + const results = await builder + .appAction({ fields: {} }) + .loop({ + option: LoopStepType.ARRAY, + binding: "1, 2, 3", + }) + .serverLog({ text: "Message {{loop.currentItem}}" }) + .run() + + expect(results.steps[0].outputs).toEqual({ + success: false, + status: "INCORRECT_TYPE", + }) + }) + + it("ensure the loop stops if the failure condition is reached", async () => { + const builder = createAutomationBuilder({ + name: "Test Loop error", + }) + + const results = await builder + .appAction({ fields: {} }) + .loop({ + option: LoopStepType.ARRAY, + binding: ["test", "test2", "test3"], + failure: "test2", + }) + .serverLog({ text: "Message {{loop.currentItem}}" }) + .run() + + expect(results.steps[0].outputs).toEqual( + expect.objectContaining({ + status: "FAILURE_CONDITION_MET", + success: false, + }) + ) + }) + + it("should run an automation where a loop is successfully run twice", async () => { + const builder = createAutomationBuilder({ + name: "Test Trigger with Loop and Create Row", + }) + + const results = await builder + .rowSaved( + { tableId: table._id! }, + { + row: { + name: "Trigger Row", + description: "This row triggers the automation", + }, + id: "1234", + revision: "1", + } + ) + .loop({ + option: LoopStepType.ARRAY, + binding: [1, 2, 3], + }) + .createRow({ + row: { + name: "Item {{ loop.currentItem }}", + description: "Created from loop", + tableId: table._id, + }, + }) + .loop({ + option: LoopStepType.STRING, + binding: "Message 1,Message 2,Message 3", + }) + .serverLog({ text: "{{loop.currentItem}}" }) + .run() + + expect(results.trigger).toBeDefined() + expect(results.steps).toHaveLength(2) + + expect(results.steps[0].outputs.iterations).toBe(3) + expect(results.steps[0].outputs.items).toHaveLength(3) + + results.steps[0].outputs.items.forEach( + (output: CreateRowStepOutputs, index: number) => { + expect(output).toMatchObject({ + success: true, + row: { + name: `Item ${index + 1}`, + description: "Created from loop", + }, + }) + } + ) + + expect(results.steps[1].outputs.iterations).toBe(3) + expect(results.steps[1].outputs.items).toHaveLength(3) + + results.steps[1].outputs.items.forEach( + (output: ServerLogStepOutputs, index: number) => { + expect(output).toMatchObject({ + success: true, + }) + expect(output.message).toContain(`Message ${index + 1}`) + } + ) + }) + + it("should run an automation where a loop is used twice to ensure context correctness further down the tree", async () => { + const builder = createAutomationBuilder({ + name: "Test Trigger with Loop and Create Row", + }) + + const results = await builder + .appAction({ fields: {} }) + .loop({ + option: LoopStepType.ARRAY, + binding: [1, 2, 3], + }) + .serverLog({ text: "Message {{loop.currentItem}}" }) + .serverLog({ text: "{{steps.1.iterations}}" }) + .loop({ + option: LoopStepType.ARRAY, + binding: [1, 2, 3], + }) + .serverLog({ text: "{{loop.currentItem}}" }) + .serverLog({ text: "{{steps.3.iterations}}" }) + .run() + + // We want to ensure that bindings are corr + expect(results.steps[1].outputs.message).toContain("- 3") + expect(results.steps[3].outputs.message).toContain("- 3") + }) +}) diff --git a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts index 7fe4776d54..9a8f1597e8 100644 --- a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts +++ b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts @@ -1,12 +1,6 @@ import * as automation from "../../index" import * as setup from "../utilities" -import { - Table, - LoopStepType, - CreateRowStepOutputs, - ServerLogStepOutputs, - FieldType, -} from "@budibase/types" +import { Table, LoopStepType, FieldType } from "@budibase/types" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import { DatabaseName } from "../../../integrations/tests/utils" @@ -23,379 +17,8 @@ describe("Automation Scenarios", () => { afterAll(setup.afterAll) - describe("Branching automations", () => { - it("should run a multiple nested branching automation", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder - .appAction({ fields: {} }) - .serverLog({ text: "Starting automation" }) - .branch({ - topLevelBranch1: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Branch 1" }).branch({ - branch1: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Branch 1.1" }), - condition: { - equal: { "steps.1.success": true }, - }, - }, - branch2: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Branch 1.2" }), - condition: { - equal: { "steps.1.success": false }, - }, - }, - }), - condition: { - equal: { "steps.1.success": true }, - }, - }, - topLevelBranch2: { - steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }), - condition: { - equal: { "steps.1.success": false }, - }, - }, - }) - .run() - expect(results.steps[3].outputs.status).toContain("branch1 branch taken") - expect(results.steps[4].outputs.message).toContain("Branch 1.1") - }) - - it("should execute correct branch based on string equality", async () => { - const builder = createAutomationBuilder({ - name: "String Equality Branching", - }) - - const results = await builder - .appAction({ fields: { status: "active" } }) - .branch({ - activeBranch: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Active user" }), - condition: { - equal: { "trigger.fields.status": "active" }, - }, - }, - inactiveBranch: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Inactive user" }), - condition: { - equal: { "trigger.fields.status": "inactive" }, - }, - }, - }) - .run() - expect(results.steps[0].outputs.status).toContain( - "activeBranch branch taken" - ) - expect(results.steps[1].outputs.message).toContain("Active user") - }) - - it("should handle multiple conditions with AND operator", async () => { - const builder = createAutomationBuilder({ - name: "Multiple AND Conditions Branching", - }) - - const results = await builder - .appAction({ fields: { status: "active", role: "admin" } }) - .branch({ - activeAdminBranch: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Active admin user" }), - condition: { - $and: { - conditions: [ - { equal: { "trigger.fields.status": "active" } }, - { equal: { "trigger.fields.role": "admin" } }, - ], - }, - }, - }, - otherBranch: { - steps: stepBuilder => stepBuilder.serverLog({ text: "Other user" }), - condition: { - notEqual: { "trigger.fields.status": "active" }, - }, - }, - }) - .run() - - expect(results.steps[1].outputs.message).toContain("Active admin user") - }) - - it("should handle multiple conditions with OR operator", async () => { - const builder = createAutomationBuilder({ - name: "Multiple OR Conditions Branching", - }) - - const results = await builder - .appAction({ fields: { status: "test", role: "user" } }) - .branch({ - specialBranch: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Special user" }), - condition: { - $or: { - conditions: [ - { equal: { "trigger.fields.status": "test" } }, - { equal: { "trigger.fields.role": "admin" } }, - ], - }, - }, - }, - regularBranch: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Regular user" }), - condition: { - $and: { - conditions: [ - { notEqual: { "trigger.fields.status": "active" } }, - { notEqual: { "trigger.fields.role": "admin" } }, - ], - }, - }, - }, - }) - .run() - - expect(results.steps[1].outputs.message).toContain("Special user") - }) - }) - - describe("Loop automations", () => { - it("should run an automation with a trigger, loop, and create row step", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder - .rowSaved( - { tableId: table._id! }, - { - row: { - name: "Trigger Row", - description: "This row triggers the automation", - }, - id: "1234", - revision: "1", - } - ) - .loop({ - option: LoopStepType.ARRAY, - binding: [1, 2, 3], - }) - .createRow({ - row: { - name: "Item {{ loop.currentItem }}", - description: "Created from loop", - tableId: table._id, - }, - }) - .run() - - expect(results.trigger).toBeDefined() - expect(results.steps).toHaveLength(1) - - expect(results.steps[0].outputs.iterations).toBe(3) - expect(results.steps[0].outputs.items).toHaveLength(3) - - results.steps[0].outputs.items.forEach((output: any, index: number) => { - expect(output).toMatchObject({ - success: true, - row: { - name: `Item ${index + 1}`, - description: "Created from loop", - }, - }) - }) - }) - - it("should run an automation where a loop step is between two normal steps to ensure context correctness", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder - .rowSaved( - { tableId: table._id! }, - { - row: { - name: "Trigger Row", - description: "This row triggers the automation", - }, - id: "1234", - revision: "1", - } - ) - .queryRows({ - tableId: table._id!, - }) - .loop({ - option: LoopStepType.ARRAY, - binding: [1, 2, 3], - }) - .serverLog({ text: "Message {{loop.currentItem}}" }) - .serverLog({ text: "{{steps.1.rows.0._id}}" }) - .run() - - results.steps[1].outputs.items.forEach( - (output: ServerLogStepOutputs, index: number) => { - expect(output).toMatchObject({ - success: true, - }) - expect(output.message).toContain(`Message ${index + 1}`) - } - ) - - expect(results.steps[2].outputs.message).toContain("ro_ta") - }) - - it("if an incorrect type is passed to the loop it should return an error", async () => { - const builder = createAutomationBuilder({ - name: "Test Loop error", - }) - - const results = await builder - .appAction({ fields: {} }) - .loop({ - option: LoopStepType.ARRAY, - binding: "1, 2, 3", - }) - .serverLog({ text: "Message {{loop.currentItem}}" }) - .run() - - expect(results.steps[0].outputs).toEqual({ - success: false, - status: "INCORRECT_TYPE", - }) - }) - - it("ensure the loop stops if the failure condition is reached", async () => { - const builder = createAutomationBuilder({ - name: "Test Loop error", - }) - - const results = await builder - .appAction({ fields: {} }) - .loop({ - option: LoopStepType.ARRAY, - binding: ["test", "test2", "test3"], - failure: "test2", - }) - .serverLog({ text: "Message {{loop.currentItem}}" }) - .run() - - expect(results.steps[0].outputs).toEqual( - expect.objectContaining({ - status: "FAILURE_CONDITION_MET", - success: false, - }) - ) - }) - - it("should run an automation where a loop is successfully run twice", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder - .rowSaved( - { tableId: table._id! }, - { - row: { - name: "Trigger Row", - description: "This row triggers the automation", - }, - id: "1234", - revision: "1", - } - ) - .loop({ - option: LoopStepType.ARRAY, - binding: [1, 2, 3], - }) - .createRow({ - row: { - name: "Item {{ loop.currentItem }}", - description: "Created from loop", - tableId: table._id, - }, - }) - .loop({ - option: LoopStepType.STRING, - binding: "Message 1,Message 2,Message 3", - }) - .serverLog({ text: "{{loop.currentItem}}" }) - .run() - - expect(results.trigger).toBeDefined() - expect(results.steps).toHaveLength(2) - - expect(results.steps[0].outputs.iterations).toBe(3) - expect(results.steps[0].outputs.items).toHaveLength(3) - - results.steps[0].outputs.items.forEach( - (output: CreateRowStepOutputs, index: number) => { - expect(output).toMatchObject({ - success: true, - row: { - name: `Item ${index + 1}`, - description: "Created from loop", - }, - }) - } - ) - - expect(results.steps[1].outputs.iterations).toBe(3) - expect(results.steps[1].outputs.items).toHaveLength(3) - - results.steps[1].outputs.items.forEach( - (output: ServerLogStepOutputs, index: number) => { - expect(output).toMatchObject({ - success: true, - }) - expect(output.message).toContain(`Message ${index + 1}`) - } - ) - }) - - it("should run an automation where a loop is used twice to ensure context correctness further down the tree", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder - .appAction({ fields: {} }) - .loop({ - option: LoopStepType.ARRAY, - binding: [1, 2, 3], - }) - .serverLog({ text: "Message {{loop.currentItem}}" }) - .serverLog({ text: "{{steps.1.iterations}}" }) - .loop({ - option: LoopStepType.ARRAY, - binding: [1, 2, 3], - }) - .serverLog({ text: "{{loop.currentItem}}" }) - .serverLog({ text: "{{steps.3.iterations}}" }) - .run() - - // We want to ensure that bindings are corr - expect(results.steps[1].outputs.message).toContain("- 3") - expect(results.steps[3].outputs.message).toContain("- 3") - }) - }) - describe("Row Automations", () => { it("should trigger an automation which then creates a row", async () => { - const table = await config.createTable() - const builder = createAutomationBuilder({ name: "Test Row Save and Create", }) @@ -430,7 +53,6 @@ describe("Automation Scenarios", () => { }) it("should trigger an automation which querys the database", async () => { - const table = await config.createTable() const row = { name: "Test Row", description: "original description", @@ -454,7 +76,6 @@ describe("Automation Scenarios", () => { }) it("should trigger an automation which querys the database then deletes a row", async () => { - const table = await config.createTable() const row = { name: "DFN", description: "original description", diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index f374ff159a..fbb905307e 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -323,7 +323,9 @@ class Orchestrator { } else if (step.stepId === AutomationActionStepId.LOOP) { stepIndex = await this.executeLoopStep(step, steps, stepIndex) } else { - await this.executeStep(step) + if (!this.stopped) { + await this.executeStep(step) + } stepIndex++ } } @@ -461,7 +463,7 @@ class Orchestrator { } private async executeBranchStep(branchStep: BranchStep): Promise { const { branches, children } = branchStep.inputs - + const conditionMet = false for (const branch of branches) { const condition = await this.evaluateBranchCondition(branch.condition) if (condition) { @@ -483,6 +485,19 @@ class Orchestrator { break } } + if (!conditionMet) { + this.stopped = true + this.updateExecutionOutput( + branchStep.id, + branchStep.stepId, + branchStep.inputs, + { + success: false, + status: AutomationStatus.NO_CONDITION_MET, + } + ) + return + } } private async evaluateBranchCondition( diff --git a/packages/types/src/documents/app/automation/automation.ts b/packages/types/src/documents/app/automation/automation.ts index fcc3af445c..72f8a1aa7c 100644 --- a/packages/types/src/documents/app/automation/automation.ts +++ b/packages/types/src/documents/app/automation/automation.ts @@ -179,6 +179,7 @@ export enum AutomationStatus { ERROR = "error", STOPPED = "stopped", STOPPED_ERROR = "stopped_error", + NO_CONDITION_MET = "No branch condition met", } export enum AutomationStoppedReason {