@budibase/server
Version:
Budibase Web Server
1,217 lines (1,083 loc) • 37.9 kB
text/typescript
import * as automation from "../../index"
import { basicTable } from "../../../tests/utilities/structures"
import {
Table,
LoopStepType,
ServerLogStepOutputs,
CreateRowStepOutputs,
FieldType,
FilterCondition,
AutomationStepStatus,
AutomationStepResult,
AutomationActionStepId,
} from "@budibase/types"
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
// Helper to get items from new loop output structure
const getLoopItems = (
loopOutput: any
): Record<string, AutomationStepResult[]> => {
// New structure uses items for full results or defaults to empty
return loopOutput.items || {}
}
describe("Loop Automations", () => {
const config = new TestConfiguration()
let table: Table
beforeAll(async () => {
await config.init()
await automation.init()
})
beforeEach(async () => {
await config.api.automation.deleteAll()
table = await config.api.table.save(basicTable())
await config.api.row.save(table._id!, {})
})
afterAll(async () => {
await automation.shutdown()
config.end()
})
it("attempt to run a basic loop", async () => {
const result = await createAutomationBuilder(config)
.onAppAction()
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.rows }}",
})
.serverLog({ text: "log statement" })
.test({ fields: {} })
expect(result.steps[1].outputs.iterations).toBe(1)
})
it("test a loop with a string", async () => {
const result = await createAutomationBuilder(config)
.onAppAction()
.loop({
option: LoopStepType.STRING,
binding: "a,b,c",
})
.serverLog({ text: "log statement" })
.test({ fields: {} })
expect(result.steps[0].outputs.iterations).toBe(3)
})
it("test a loop with a binding that returns an integer", async () => {
const result = await createAutomationBuilder(config)
.onAppAction()
.loop({
option: LoopStepType.ARRAY,
binding: "{{ 1 }}",
})
.serverLog({ text: "log statement" })
.test({ fields: {} })
expect(result.steps[0].outputs.iterations).toBe(1)
})
it("ensure that we maintain step identity", async () => {
const result = await createAutomationBuilder(config)
.onAppAction()
.serverLog(
{
text: "hello",
},
{
stepName: "someStepName",
}
)
.loop({
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
})
.serverLog({ text: "log statement {{loop.currentItem}}" })
.test({ fields: {} })
// Legacy loop should present child step identity and correct iterations
expect(result.steps[1].stepId).toBe(AutomationActionStepId.SERVER_LOG)
expect(result.steps[1].outputs.iterations).toBe(3)
})
it("should run an automation with a trigger, loop, and create row step", async () => {
const results = await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.loop({
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
})
.createRow({
row: {
name: "Item {{ loop.currentItem }}",
description: "Created from loop",
tableId: table._id,
},
})
.test({
row: {
name: "Trigger Row",
description: "This row triggers the automation",
},
id: "1234",
revision: "1",
})
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 results = await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
})
.serverLog({ text: "Message {{loop.currentItem}}" })
.serverLog({ text: "{{steps.1.rows.0._id}}" })
.test({
row: {
name: "Trigger Row",
description: "This row triggers the automation",
},
id: "1234",
revision: "1",
})
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("ensure the loop stops if the failure condition is reached", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.loop({
option: LoopStepType.ARRAY,
binding: ["test", "test2", "test3"],
failure: "test2",
})
.serverLog({ text: "Message {{loop.currentItem}}" })
.test({ fields: {} })
expect(results.steps[0].outputs).toEqual(
expect.objectContaining({
status: "FAILURE_CONDITION_MET",
success: false,
})
)
})
it("ensure the loop stops if the max iterations are reached", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.loop({
option: LoopStepType.ARRAY,
binding: ["test", "test2", "test3"],
iterations: 2,
})
.serverLog({ text: "{{loop.currentItem}}" })
.serverLog({ text: "{{steps.1.iterations}}" })
.test({ fields: {} })
expect(results.steps[0].outputs.status).toBe(
AutomationStepStatus.MAX_ITERATIONS
)
expect(results.steps[0].outputs.iterations).toBe(2)
expect(results.steps[0].outputs.items).toHaveLength(2)
expect(results.steps[0].outputs.items[0].message).toEndWith("test")
expect(results.steps[0].outputs.items[1].message).toEndWith("test2")
})
it("should stop when a failure condition is hit", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.loop({
option: LoopStepType.ARRAY,
binding: ["test", "test2", "test3"],
failure: "test3",
})
.serverLog({ text: "{{loop.currentItem}}" })
.serverLog({ text: "{{steps.1.iterations}}" })
.test({ fields: {} })
expect(results.steps[0].outputs.status).toBe(
AutomationStepStatus.FAILURE_CONDITION
)
expect(results.steps[0].outputs.iterations).toBe(2)
expect(results.steps[0].outputs.items).toHaveLength(2)
expect(results.steps[0].outputs.items[0].message).toEndWith("test")
expect(results.steps[0].outputs.items[1].message).toEndWith("test2")
})
it("should run an automation with loop and max iterations to ensure context correctness further down the tree", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.loop({
option: LoopStepType.ARRAY,
binding: ["test", "test2", "test3"],
iterations: 2,
})
.serverLog({ text: "{{loop.currentItem}}" })
.serverLog({ text: "{{steps.1.iterations}}" })
.test({ fields: {} })
expect(results.steps[1].outputs.message).toContain("- 2")
})
it("should run an automation where a loop is successfully run twice", async () => {
const results = await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.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}}" })
.test({
row: {
name: "Trigger Row",
description: "This row triggers the automation",
},
id: "1234",
revision: "1",
})
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 results = await createAutomationBuilder(config)
.onAppAction()
.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}}" })
.test({ fields: {} })
// We want to ensure that bindings are corr
expect(results.steps[1].outputs.message).toContain("- 3")
expect(results.steps[3].outputs.message).toContain("- 3")
})
it("should use automation names to loop with", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.loop(
{
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
},
{ stepName: "FirstLoopStep" }
)
.serverLog(
{ text: "Message {{loop.currentItem}}" },
{ stepName: "FirstLoopLog" }
)
.serverLog(
{ text: "{{steps.FirstLoopLog.iterations}}" },
{ stepName: "FirstLoopIterationLog" }
)
.test({ fields: {} })
expect(results.steps[1].outputs.message).toContain("- 3")
})
it("should run an automation with a loop and update row step", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const rows = [
{ name: "Row 1", value: 1, tableId: table._id },
{ name: "Row 2", value: 2, tableId: table._id },
{ name: "Row 3", value: 3, tableId: table._id },
]
await config.api.row.bulkImport(table._id!, { rows })
const results = await createAutomationBuilder(config)
.onAppAction()
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.rows }}",
})
.updateRow({
rowId: "{{ loop.currentItem._id }}",
row: {
name: "Updated {{ loop.currentItem.name }}",
value: "{{ loop.currentItem.value }}",
tableId: table._id,
},
meta: {},
})
.queryRows({
tableId: table._id!,
})
.test({ fields: {} })
const expectedRows = [
{ name: "Updated Row 1", value: 1 },
{ name: "Updated Row 2", value: 2 },
{ name: "Updated Row 3", value: 3 },
]
expect(results.steps[1].outputs.items).toEqual(
expect.arrayContaining(
expectedRows.map(row =>
expect.objectContaining({
success: true,
row: expect.objectContaining(row),
})
)
)
)
expect(results.steps[2].outputs.rows).toEqual(
expect.arrayContaining(
expectedRows.map(row => expect.objectContaining(row))
)
)
expect(results.steps[1].outputs.items).toHaveLength(expectedRows.length)
expect(results.steps[2].outputs.rows).toHaveLength(expectedRows.length)
})
it("should run an automation with a loop and update row step using stepIds", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const rows = [
{ name: "Row 1", value: 1, tableId: table._id },
{ name: "Row 2", value: 2, tableId: table._id },
{ name: "Row 3", value: 3, tableId: table._id },
]
await config.api.row.bulkImport(table._id!, { rows })
const results = await createAutomationBuilder(config)
.onAppAction()
.queryRows(
{
tableId: table._id!,
},
{ stepId: "abc123" }
)
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.abc123.rows }}",
})
.updateRow({
rowId: "{{ loop.currentItem._id }}",
row: {
name: "Updated {{ loop.currentItem.name }}",
value: "{{ loop.currentItem.value }}",
tableId: table._id,
},
meta: {},
})
.queryRows({
tableId: table._id!,
})
.test({ fields: {} })
const expectedRows = [
{ name: "Updated Row 1", value: 1 },
{ name: "Updated Row 2", value: 2 },
{ name: "Updated Row 3", value: 3 },
]
expect(results.steps[1].outputs.items).toEqual(
expect.arrayContaining(
expectedRows.map(row =>
expect.objectContaining({
success: true,
row: expect.objectContaining(row),
})
)
)
)
expect(results.steps[2].outputs.rows).toEqual(
expect.arrayContaining(
expectedRows.map(row => expect.objectContaining(row))
)
)
expect(results.steps[1].outputs.items).toHaveLength(expectedRows.length)
expect(results.steps[2].outputs.rows).toHaveLength(expectedRows.length)
})
it("should run an automation with a loop and delete row step", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const rows = [
{ name: "Row 1", value: 1, tableId: table._id },
{ name: "Row 2", value: 2, tableId: table._id },
{ name: "Row 3", value: 3, tableId: table._id },
]
await config.api.row.bulkImport(table._id!, { rows })
const results = await createAutomationBuilder(config)
.onAppAction()
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.rows }}",
})
.deleteRow({
tableId: table._id!,
id: "{{ loop.currentItem._id }}",
})
.queryRows({
tableId: table._id!,
})
.test({ fields: {} })
expect(results.steps).toHaveLength(3)
expect(results.steps[2].outputs.rows).toHaveLength(0)
})
it("should successfully loop over an array returned by a JavaScript step with Array input type", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.executeScript({
code: "return [1, 2, 3, 4, 5]",
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.value }}",
})
.serverLog({ text: "Processing item: {{loop.currentItem}}" })
.test({ fields: {} })
expect(results.steps[1].outputs.success).toBe(true)
expect(results.steps[1].outputs.iterations).toBe(5)
expect(results.steps[1].outputs.items).toHaveLength(5)
results.steps[1].outputs.items.forEach((output: any, index: number) => {
expect(output).toMatchObject({
success: true,
})
expect(output.message).toContain(`Processing item: ${index + 1}`)
})
})
it("should test array binding directly", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.loop({
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
})
.serverLog({ text: "Processing item: {{loop.currentItem}}" })
.test({ fields: {} })
expect(results.steps[0].outputs.success).toBe(true)
expect(results.steps[0].outputs.iterations).toBe(3)
expect(results.steps[0].outputs.items).toHaveLength(3)
})
it("should successfully loop over an array of objects returned by a JavaScript step", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.executeScript({
code: `
return [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
]
`,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.value }}",
})
.serverLog({
text: "User: {{loop.currentItem.name}} (ID: {{loop.currentItem.id}})",
})
.test({ fields: {} })
expect(results.steps[1].outputs.success).toBe(true)
expect(results.steps[1].outputs.iterations).toBe(3)
expect(results.steps[1].outputs.items).toHaveLength(3)
const expectedNames = ["Alice", "Bob", "Charlie"]
const expectedIds = [1, 2, 3]
results.steps[1].outputs.items.forEach((output: any, index: number) => {
expect(output).toMatchObject({
success: true,
})
expect(output.message).toContain(
`User: ${expectedNames[index]} (ID: ${expectedIds[index]})`
)
})
})
it("should successfully loop over an empty array returned by a JavaScript step", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.executeScript({
code: "return []",
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.value }}",
})
.serverLog({ text: "This should not execute" })
.test({ fields: {} })
expect(results.steps[1].outputs.success).toBe(true)
expect(results.steps[1].outputs.status).toBe(
AutomationStepStatus.NO_ITERATIONS
)
expect(results.steps[1].outputs.iterations).toBe(0)
expect(results.steps[1].outputs.items).toHaveLength(0)
})
describe("loop output", () => {
it("should not output anything if a filter stops the automation", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.filter({
condition: FilterCondition.EQUAL,
field: "1",
value: "2",
})
.loop({
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
})
.serverLog({ text: "Message {{loop.currentItem}}" })
.test({ fields: {} })
expect(results.steps.length).toBe(1)
expect(results.steps[0].outputs).toEqual({
comparisonValue: 2,
refValue: 1,
result: false,
success: true,
status: "stopped",
})
})
it("should not fail if queryRows returns nothing", async () => {
const table = await config.api.table.save(basicTable())
const results = await createAutomationBuilder(config)
.onAppAction()
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.rows }}",
})
.serverLog({ text: "Message {{loop.currentItem}}" })
.test({ fields: {} })
expect(results.steps[1].outputs.success).toBe(true)
expect(results.steps[1].outputs.status).toBe(
AutomationStepStatus.NO_ITERATIONS
)
})
})
describe("Multiple children within a loop", () => {
it("should create a basic loop v2 step with multiple children", async () => {
const binding = [1, 2, 3]
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.serverLog({
text: "Log Step 1 processing item: {{loop.currentItem}}",
}),
builder.serverLog({
text: "Log Step 2 processing item: {{loop.currentItem}}",
}),
]
},
option: LoopStepType.ARRAY,
binding,
})
.serverLog({ text: "Hello" })
.test({ fields: {} })
// Check new output structure
expect(steps[0].outputs.success).toBe(true)
expect(steps[0].outputs.iterations).toBe(3)
expect(steps[0].outputs.summary).toBeDefined()
expect(steps[0].outputs.summary.totalProcessed).toBe(6) // 2 children x 3 iterations
expect(steps[0].outputs.summary.successCount).toBe(6)
expect(steps[0].outputs.summary.failureCount).toBe(0)
let results = getLoopItems(steps[0].outputs)
Object.values(results).forEach((results, stepIndex) => {
results.forEach((result, i) => {
expect(result.outputs.message).toContain(
`Log Step ${stepIndex + 1} processing item: ${i + 1}`
)
})
})
})
it("should handle empty children array", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: () => [],
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
})
.test({ fields: {} })
expect(steps[0].outputs.success).toBe(true)
expect(steps[0].outputs.iterations).toBe(3)
expect(steps[0].outputs.summary.totalProcessed).toBe(0)
expect(getLoopItems(steps[0].outputs)).toEqual({})
})
it("should handle no iterations with multiple children", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.serverLog({ text: "Should not run" }),
builder.serverLog({ text: "Also should not run" }),
]
},
option: LoopStepType.ARRAY,
binding: [],
})
.test({ fields: {} })
expect(steps[0].outputs.success).toBe(true)
expect(steps[0].outputs.status).toBe(AutomationStepStatus.NO_ITERATIONS)
expect(steps[0].outputs.iterations).toBe(0)
expect(steps[0].outputs.summary.totalProcessed).toBe(0)
const items = getLoopItems(steps[0].outputs)
expect(Object.values(items).every((item: any) => item.length === 0)).toBe(
true
)
})
it("should fail the loop if any child step fails", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.serverLog({ text: "Starting {{loop.currentItem}}" }),
builder.executeScript({
code: `
if (loop.currentItem === 2) {
throw new Error("Intentional error on item 2")
}
return loop.currentItem
`,
}),
builder.serverLog({ text: "Completed {{loop.currentItem}}" }),
]
},
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
})
.test({ fields: {} })
expect(steps[0].outputs.success).toBe(false)
expect(steps[0].outputs.summary).toBeDefined()
expect(steps[0].outputs.summary.failureCount).toBeGreaterThan(0)
})
it("should respect max iterations with multiple children", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.serverLog({ text: "Step 1: {{loop.currentItem}}" }),
builder.serverLog({ text: "Step 2: {{loop.currentItem}}" }),
]
},
option: LoopStepType.ARRAY,
binding: [1, 2, 3, 4, 5],
iterations: 3,
})
.test({ fields: {} })
expect(steps[0].outputs.iterations).toBe(3)
expect(steps[0].outputs.status).toBe(AutomationStepStatus.MAX_ITERATIONS)
expect(steps[0].outputs.success).toBe(false)
expect(steps[0].outputs.summary.totalProcessed).toBe(6) // 2 children x 3 iterations
const loopResults = getLoopItems(steps[0].outputs)
Object.values(loopResults).forEach(childResults => {
expect(childResults).toHaveLength(3)
})
})
it("should stop on failure condition with multiple children", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.serverLog({ text: "Processing: {{loop.currentItem}}" }),
builder.executeScript({
code: "return loop.currentItem",
}),
]
},
option: LoopStepType.ARRAY,
binding: ["continue", "continue", "stop", "should-not-process"],
failure: "stop",
})
.test({ fields: {} })
expect(steps[0].outputs.success).toBe(false)
expect(steps[0].outputs.status).toBe(
AutomationStepStatus.FAILURE_CONDITION
)
expect(steps[0].outputs.summary.totalProcessed).toBe(4) // 2 children x 2 iterations before stop
expect(steps[0].outputs.summary.failureCount).toBe(0) // No actual failures, just stop condition
const loopResults = getLoopItems(steps[0].outputs)
Object.values(loopResults).forEach(childResults => {
expect(childResults).toHaveLength(2)
})
})
it("should handle loops with database operations on multiple children", async () => {
const testTable = await config.createTable({
name: "LoopTestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: { presence: true },
},
category: {
name: "category",
type: FieldType.STRING,
},
value: {
name: "value",
type: FieldType.NUMBER,
},
},
})
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.createRow({
row: {
name: "Item {{loop.currentItem.name}}",
category: "{{loop.currentItem.category}}",
value: "{{loop.currentItem.value}}",
tableId: testTable._id,
},
}),
builder.executeScript({
code: "return steps[1].row._id",
}),
builder.serverLog({
text: "Created row with ID: {{steps.2.value}}",
}),
]
},
option: LoopStepType.ARRAY,
binding: [
{ name: "Product A", category: "Electronics", value: 100 },
{ name: "Product B", category: "Books", value: 20 },
{ name: "Product C", category: "Electronics", value: 150 },
],
})
.queryRows({
tableId: testTable._id!,
})
.test({ fields: {} })
expect(steps[0].outputs.success).toBe(true)
expect(steps[0].outputs.summary.totalProcessed).toBe(9) // 3 children x 3 iterations
expect(steps[0].outputs.summary.successCount).toBe(9)
const loopResults = getLoopItems(steps[0].outputs)
const [createResults, , logResults] = Object.values(loopResults)
expect(createResults).toHaveLength(3)
createResults.forEach(result => {
expect(result.outputs.success).toBe(true)
expect(result.outputs.row.name).toContain("Product")
})
logResults.forEach(result => {
expect(result.outputs.message).toMatch(/Created row with ID: ro_/)
})
expect(steps[1].outputs.rows).toHaveLength(3)
})
it("should preserve correct step indexing with loopV2", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.serverLog({ text: "Before loop" })
.loopV2({
steps: builder => {
return [
builder.serverLog({ text: "Inside loop: {{loop.currentItem}}" }),
]
},
option: LoopStepType.ARRAY,
binding: [1, 2],
})
.serverLog({
text: "After loop - previous step count: {{steps.2.iterations}}",
})
.test({ fields: {} })
expect(steps[0].outputs.message).toContain("Before loop")
expect(steps[1].outputs.success).toBe(true)
expect(steps[1].outputs.iterations).toBe(2)
expect(steps[1].outputs.summary.totalProcessed).toBe(2)
expect(steps[2].outputs.message).toContain(
"After loop - previous step count: 2"
)
})
it("should handle mixing legacy and new loops", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loop({
option: LoopStepType.ARRAY,
binding: [1, 2],
})
.serverLog({ text: "Legacy loop: {{loop.currentItem}}" })
.loopV2({
steps: builder => {
return [
builder.serverLog({ text: "New loop: {{loop.currentItem}}" }),
builder.serverLog({ text: "Second child: {{loop.currentItem}}" }),
]
},
option: LoopStepType.ARRAY,
binding: ["A", "B"],
})
.test({ fields: {} })
// Legacy loop still returns flat array
expect(steps[0].outputs.iterations).toBe(2)
expect(steps[0].outputs.items).toHaveLength(2)
expect(steps[0].outputs.items[0].message).toContain("Legacy loop: 1")
expect(steps[0].outputs.items[1].message).toContain("Legacy loop: 2")
// New loop returns structured results
expect(steps[1].outputs.iterations).toBe(2)
expect(steps[1].outputs.summary.totalProcessed).toBe(4) // 2 children x 2 iterations
const newLoopResults = getLoopItems(steps[1].outputs)
expect(Object.keys(newLoopResults)).toHaveLength(2)
const [firstChild, secondChild] = Object.values(newLoopResults)
expect(firstChild[0].outputs.message).toContain("New loop: A")
expect(secondChild[0].outputs.message).toContain("Second child: A")
})
it("should maintain separate context for each iteration", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.executeScript({
// eslint-disable-next-line no-template-curly-in-string
code: "return `Prefix-${loop.currentItem}`",
}),
builder.serverLog({
text: "{{steps.1.value}}",
}),
]
},
option: LoopStepType.ARRAY,
binding: ["A", "B", "C"],
})
.test({ fields: {} })
expect(steps[0].outputs.success).toBe(true)
expect(steps[0].outputs.summary.totalProcessed).toBe(6) // 2 children x 3 iterations
const loopResults = getLoopItems(steps[0].outputs)
const [, logResults] = Object.values(loopResults)
expect(logResults[0].outputs.message).toContain("Prefix-A")
expect(logResults[1].outputs.message).toContain("Prefix-B")
expect(logResults[2].outputs.message).toContain("Prefix-C")
})
it("should support nested loops", async () => {
const results = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: outerBuilder => {
return [
outerBuilder.serverLog({
text: "Outer loop: {{loop.currentItem.name}}",
}),
outerBuilder.loopV2({
steps: innerBuilder => {
return [
innerBuilder.serverLog({
text: "Inner loop: {{loop.currentItem}}",
}),
]
},
option: LoopStepType.ARRAY,
binding: "{{loop.currentItem.values}}",
}),
]
},
option: LoopStepType.ARRAY,
binding: [
{ name: "Group A", values: ["A1", "A2", "A3"] },
{ name: "Group B", values: ["B1", "B2"] },
],
})
.test({ fields: {} })
// Basic checks
expect(results.steps[0].outputs.success).toBe(true)
expect(results.steps[0].outputs.iterations).toBe(2)
expect(results.steps[0].outputs.summary.totalProcessed).toBe(4) // 2 children x 2 iterations
// Check nested summaries
expect(results.steps[0].outputs.nestedSummaries).toBeDefined()
const outerLoopResults = getLoopItems(results.steps[0].outputs)
const outerStepIds = Object.keys(outerLoopResults)
// Check outer loop logs
const outerLogResults = outerLoopResults[outerStepIds[0]]
expect(outerLogResults[0].outputs.message).toContain(
"Outer loop: Group A"
)
expect(outerLogResults[1].outputs.message).toContain(
"Outer loop: Group B"
)
// Check inner loop results
const innerLoopResults = outerLoopResults[outerStepIds[1]]
// The inner loops should execute properly with context preservation
expect(innerLoopResults[0].outputs.success).toBe(true)
expect(innerLoopResults[1].outputs.success).toBe(true)
// Check inner loop summaries
expect(innerLoopResults[0].outputs.summary.totalProcessed).toBe(3)
expect(innerLoopResults[1].outputs.summary.totalProcessed).toBe(3)
})
it("should handle filter steps that stop execution within a loop iteration", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.filter({
condition: FilterCondition.NOT_EQUAL,
field: "{{loop.currentItem}}",
value: "skip",
}),
builder.serverLog({ text: "Processed: {{loop.currentItem}}" }),
]
},
option: LoopStepType.ARRAY,
binding: ["process", "skip", "process"],
})
.test({ fields: {} })
expect(steps[0].outputs.success).toBe(true)
expect(steps[0].outputs.summary.totalProcessed).toBe(3)
expect(steps[0].outputs.summary.successCount).toBe(3)
const loopResults = getLoopItems(steps[0].outputs)
const [filterResults, logResults] = Object.values(loopResults)
expect(filterResults[0].outputs.result).toBe(true)
expect(logResults[0].outputs.message).toContain("Processed: process")
expect(filterResults[1].outputs.result).toBe(false)
expect(filterResults[1].outputs.status).toBe("stopped")
expect(logResults).toHaveLength(1)
expect(logResults[0].outputs.message).toContain("Processed: process")
})
it("sanitizes branch results in loop items", async () => {
const { steps } = await createAutomationBuilder(config)
.onAppAction()
.loopV2({
steps: builder => {
return [
builder.branch({
takeA: {
steps: b =>
b.serverLog({ text: "Branch A {{loop.currentItem}}" }),
condition: {
equal: { "{{ literal loop.currentItem }}": 1 },
},
},
takeB: {
steps: b =>
b.serverLog({ text: "Branch B {{loop.currentItem}}" }),
condition: {
notEqual: { "{{ literal loop.currentItem }}": 1 },
},
},
}),
]
},
option: LoopStepType.ARRAY,
binding: [1],
})
.test({ fields: {} })
const loopOutputs = steps[0].outputs
const items = getLoopItems(loopOutputs)
const branchKey = Object.keys(items).find(
key => items[key][0]?.stepId === AutomationActionStepId.BRANCH
)
expect(branchKey).toBeDefined()
const first = items[branchKey!][0]
// Inputs should be stripped
expect(first.inputs).toEqual({})
// Only expose success/status/branchName
expect(Object.keys(first.outputs).sort()).toEqual([
"branchName",
"status",
"success",
])
expect(first.outputs.branchName).toBe("takeA")
})
})
})