Merge pull request #15366 from Budibase/execute-script-v2

Create a new automation step for executing JS
This commit is contained in:
Michael Drury 2025-03-06 14:50:37 +00:00 committed by GitHub
commit b6169cdca0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 355 additions and 66 deletions

View File

@ -8,11 +8,13 @@
export let invalid: boolean = false
export let disabled: boolean = false
export let closable: boolean = false
export let emphasized: boolean = false
</script>
<div
class:is-invalid={invalid}
class:is-disabled={disabled}
class:is-emphasized={emphasized}
class="spectrum-Tags-item"
role="listitem"
>
@ -40,4 +42,9 @@
margin-bottom: 0;
margin-top: 0;
}
.is-emphasized {
border-color: var(--spectrum-global-color-blue-700);
color: var(--spectrum-global-color-blue-700);
}
</style>

View File

@ -23,9 +23,8 @@
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction
let actions = Object.entries($automationStore.blockDefinitions.ACTION).filter(
entry => {
const [key] = entry
return key !== AutomationActionStepId.BRANCH
([key, action]) => {
return key !== AutomationActionStepId.BRANCH && action.deprecated !== true
}
)
let lockedFeatures = [
@ -186,6 +185,10 @@
</div>
{:else if isDisabled}
<Icon name="Help" tooltip={disabled()[idx].message} />
{:else if action.new}
<Tags>
<Tag emphasized>New</Tag>
</Tags>
{/if}
</div>
</div>
@ -227,6 +230,10 @@
grid-gap: var(--spectrum-alias-grid-baseline);
}
.item :global(.spectrum-Tags-itemLabel) {
cursor: pointer;
}
.item {
cursor: pointer;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
@ -237,6 +244,8 @@
border-radius: 5px;
box-sizing: border-box;
border-width: 2px;
min-height: 3.5rem;
display: flex;
}
.item:not(.disabled):hover,
.selected {

View File

@ -1,6 +1,13 @@
<script>
import { automationStore, selectedAutomation } from "@/stores/builder"
import { Icon, Body, AbsTooltip, StatusLight } from "@budibase/bbui"
import {
Icon,
Body,
AbsTooltip,
StatusLight,
Tags,
Tag,
} from "@budibase/bbui"
import { externalActions } from "./ExternalActions"
import { createEventDispatcher } from "svelte"
import { Features } from "@/constants/backend/automations"
@ -24,6 +31,7 @@
$: blockRefs = $selectedAutomation?.blockRefs || {}
$: stepNames = automation?.definition.stepNames || {}
$: allSteps = automation?.definition.steps || []
$: blockDefinition = $automationStore.blockDefinitions.ACTION[block.stepId]
$: automationName = itemName || stepNames?.[block.id] || block?.name || ""
$: automationNameError = getAutomationNameError(automationName)
$: status = updateStatus(testResult)
@ -135,7 +143,16 @@
{#if isHeaderTrigger}
<Body size="XS"><b>Trigger</b></Body>
{:else}
<Body size="XS"><b>{isBranch ? "Branch" : "Step"}</b></Body>
<Body size="XS">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<b>{isBranch ? "Branch" : "Step"}</b>
{#if blockDefinition.deprecated}
<Tags>
<Tag invalid>Deprecated</Tag>
</Tags>
{/if}
</div>
</Body>
{/if}
{#if enableNaming}

View File

@ -15,6 +15,7 @@ export const ActionStepID = {
DELETE_ROW: "DELETE_ROW",
OUTGOING_WEBHOOK: "OUTGOING_WEBHOOK",
EXECUTE_SCRIPT: "EXECUTE_SCRIPT",
EXECUTE_SCRIPT_V2: "EXECUTE_SCRIPT_V2",
EXECUTE_QUERY: "EXECUTE_QUERY",
SERVER_LOG: "SERVER_LOG",
DELAY: "DELAY",

View File

@ -33,6 +33,8 @@ import {
isRowSaveTrigger,
isAppTrigger,
BranchStep,
GetAutomationTriggerDefinitionsResponse,
GetAutomationActionDefinitionsResponse,
} from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { FIELDS } from "@/constants/backend"
@ -68,16 +70,19 @@ const initialAutomationState: AutomationState = {
}
const getFinalDefinitions = (
triggers: Record<string, any>,
actions: Record<string, any>
triggers: GetAutomationTriggerDefinitionsResponse,
actions: GetAutomationActionDefinitionsResponse
): BlockDefinitions => {
const creatable: Record<string, any> = {}
Object.entries(triggers).forEach(entry => {
if (entry[0] === AutomationTriggerStepId.ROW_ACTION) {
return
const creatable: Partial<GetAutomationTriggerDefinitionsResponse> = {}
for (const [key, trigger] of Object.entries(triggers)) {
if (key === AutomationTriggerStepId.ROW_ACTION) {
continue
}
creatable[entry[0]] = entry[1]
})
if (trigger.deprecated === true) {
continue
}
creatable[key as keyof GetAutomationTriggerDefinitionsResponse] = trigger
}
return {
TRIGGER: triggers,
CREATABLE_TRIGGER: creatable,
@ -679,7 +684,10 @@ const automationActions = (store: AutomationStore) => ({
runtimeName = `loop.${name}`
} else if (idx === 0) {
runtimeName = `trigger.${name}`
} else if (currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT) {
} else if (
currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT ||
currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT_V2
) {
const stepId = pathSteps[idx].id
if (!stepId) {
notifications.error("Error generating binding: Step ID not found.")

View File

@ -1,7 +1,7 @@
import * as triggers from "../../automations/triggers"
import { sdk as coreSdk } from "@budibase/shared-core"
import { DocumentType } from "../../db/utils"
import { updateTestHistory, removeDeprecated } from "../../automations/utils"
import { updateTestHistory } from "../../automations/utils"
import { withTestFlag } from "../../utilities/redis"
import { context, cache, events, db as dbCore } from "@budibase/backend-core"
import { automations, features } from "@budibase/pro"
@ -34,14 +34,6 @@ import sdk from "../../sdk"
import { builderSocket } from "../../websockets"
import env from "../../environment"
async function getActionDefinitions() {
return removeDeprecated(await actionDefs())
}
function getTriggerDefinitions() {
return removeDeprecated(triggers.TRIGGER_DEFINITIONS)
}
/*************************
* *
* BUILDER FUNCTIONS *
@ -141,21 +133,21 @@ export async function clearLogError(
export async function getActionList(
ctx: UserCtx<void, GetAutomationActionDefinitionsResponse>
) {
ctx.body = await getActionDefinitions()
ctx.body = await actionDefs()
}
export async function getTriggerList(
ctx: UserCtx<void, GetAutomationTriggerDefinitionsResponse>
) {
ctx.body = getTriggerDefinitions()
ctx.body = triggers.TRIGGER_DEFINITIONS
}
export async function getDefinitionList(
ctx: UserCtx<void, GetAutomationStepDefinitionsResponse>
) {
ctx.body = {
trigger: getTriggerDefinitions(),
action: await getActionDefinitions(),
trigger: triggers.TRIGGER_DEFINITIONS,
action: await actionDefs(),
}
}

View File

@ -19,7 +19,6 @@ import {
Table,
} from "@budibase/types"
import { mocks } from "@budibase/backend-core/tests"
import { removeDeprecated } from "../../../automations/utils"
import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder"
import { basicTable } from "../../../tests/utilities/structures"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
@ -64,15 +63,11 @@ describe("/automations", () => {
it("returns all of the definitions in one", async () => {
const { action, trigger } = await config.api.automation.getDefinitions()
let definitionsLength = Object.keys(
removeDeprecated(BUILTIN_ACTION_DEFINITIONS)
).length
expect(Object.keys(action).length).toBeGreaterThanOrEqual(
definitionsLength
Object.keys(BUILTIN_ACTION_DEFINITIONS).length
)
expect(Object.keys(trigger).length).toEqual(
Object.keys(removeDeprecated(TRIGGER_DEFINITIONS)).length
Object.keys(TRIGGER_DEFINITIONS).length
)
})
})

View File

@ -4,6 +4,7 @@ import * as createRow from "./steps/createRow"
import * as updateRow from "./steps/updateRow"
import * as deleteRow from "./steps/deleteRow"
import * as executeScript from "./steps/executeScript"
import * as executeScriptV2 from "./steps/executeScriptV2"
import * as executeQuery from "./steps/executeQuery"
import * as outgoingWebhook from "./steps/outgoingWebhook"
import * as serverLog from "./steps/serverLog"
@ -44,6 +45,7 @@ const ACTION_IMPLS: ActionImplType = {
DELETE_ROW: deleteRow.run,
OUTGOING_WEBHOOK: outgoingWebhook.run,
EXECUTE_SCRIPT: executeScript.run,
EXECUTE_SCRIPT_V2: executeScriptV2.run,
EXECUTE_QUERY: executeQuery.run,
SERVER_LOG: serverLog.run,
DELAY: delay.run,
@ -70,6 +72,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<
DELETE_ROW: automations.steps.deleteRow.definition,
OUTGOING_WEBHOOK: automations.steps.outgoingWebhook.definition,
EXECUTE_SCRIPT: automations.steps.executeScript.definition,
EXECUTE_SCRIPT_V2: automations.steps.executeScriptV2.definition,
EXECUTE_QUERY: automations.steps.executeQuery.definition,
SERVER_LOG: automations.steps.serverLog.definition,
DELAY: automations.steps.delay.definition,

View File

@ -0,0 +1,48 @@
import * as automationUtils from "../automationUtils"
import {
ExecuteScriptStepInputs,
ExecuteScriptStepOutputs,
} from "@budibase/types"
import { processStringSync } from "@budibase/string-templates"
export async function run({
inputs,
context,
}: {
inputs: ExecuteScriptStepInputs
context: Record<string, any>
}): Promise<ExecuteScriptStepOutputs> {
let { code } = inputs
if (code == null) {
return {
success: false,
response: {
message: "Invalid inputs",
},
}
}
code = code.trim()
if (!code.startsWith("{{ js ")) {
return {
success: false,
response: {
message: "Expected code to be a {{ js }} template block",
},
}
}
try {
return {
success: true,
value: processStringSync(inputs.code, context, { noThrow: false }),
}
} catch (err) {
return {
success: false,
response: automationUtils.getError(err),
}
}
}

View File

@ -0,0 +1,158 @@
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
import * as automation from "../index"
import * as setup from "./utilities"
import { Table } from "@budibase/types"
function encodeJS(js: string): string {
return `{{ js "${Buffer.from(js, "utf-8").toString("base64")}" }}`
}
describe("Execute Script 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 execute a basic script and return the result", async () => {
config.name = "Basic Script Execution"
const builder = createAutomationBuilder(config)
const results = await builder
.onAppAction()
.executeScriptV2({ code: encodeJS("return 2 + 2") })
.test({ fields: {} })
expect(results.steps[0].outputs.value).toEqual(4)
})
it("should access bindings from previous steps", async () => {
config.name = "Access Bindings"
const builder = createAutomationBuilder(config)
const results = await builder
.onAppAction()
.executeScriptV2(
{
code: encodeJS(`return $("trigger.fields.data").map(x => x * 2)`),
},
{ stepId: "binding-script-step" }
)
.test({ fields: { data: [1, 2, 3] } })
expect(results.steps[0].outputs.value).toEqual([2, 4, 6])
})
it("should handle script execution errors gracefully", async () => {
config.name = "Handle Script Errors"
const builder = createAutomationBuilder(config)
const results = await builder
.onAppAction()
.executeScriptV2({
code: encodeJS("return nonexistentVariable.map(x => x)"),
})
.test({ fields: {} })
expect(results.steps[0].outputs.response).toContain(
"ReferenceError: nonexistentVariable is not defined"
)
expect(results.steps[0].outputs.success).toEqual(false)
})
it("should handle conditional logic in scripts", async () => {
config.name = "Conditional Script Logic"
const builder = createAutomationBuilder(config)
const results = await builder
.onAppAction()
.executeScriptV2({
code: encodeJS(`
if ($("trigger.fields.value") > 5) {
return "Value is greater than 5";
} else {
return "Value is 5 or less";
}
`),
})
.test({ fields: { value: 10 } })
expect(results.steps[0].outputs.value).toEqual("Value is greater than 5")
})
it("should use multiple steps and validate script execution", async () => {
config.name = "Multi-Step Script Execution"
const builder = createAutomationBuilder(config)
const results = await builder
.onAppAction()
.serverLog(
{ text: "Starting multi-step automation" },
{ stepId: "start-log-step" }
)
.createRow(
{ row: { name: "Test Row", value: 42, tableId: table._id } },
{ stepId: "abc123" }
)
.executeScriptV2(
{
code: encodeJS(`
const createdRow = $("steps")['abc123'];
return createdRow.row.value * 2;
`),
},
{ stepId: "ScriptingStep1" }
)
.serverLog({
text: `Final result is {{ steps.ScriptingStep1.value }}`,
})
.test({ fields: {} })
expect(results.steps[0].outputs.message).toContain(
"Starting multi-step automation"
)
expect(results.steps[1].outputs.row.value).toEqual(42)
expect(results.steps[2].outputs.value).toEqual(84)
expect(results.steps[3].outputs.message).toContain("Final result is 84")
})
it("should fail if the code has not been encoded as a handlebars template", async () => {
config.name = "Invalid Code Encoding"
const builder = createAutomationBuilder(config)
const results = await builder
.onAppAction()
.executeScriptV2({
code: "return 2 + 2",
})
.test({ fields: {} })
expect(results.steps[0].outputs.response.message).toEqual(
"Expected code to be a {{ js }} template block"
)
expect(results.steps[0].outputs.success).toEqual(false)
})
it("does not process embedded handlebars templates", async () => {
config.name = "Embedded Handlebars"
const builder = createAutomationBuilder(config)
const results = await builder
.onAppAction()
.executeScriptV2({
code: encodeJS(`return "{{ triggers.row.whatever }}"`),
})
.test({ fields: {} })
expect(results.steps[0].outputs.value).toEqual(
"{{ triggers.row.whatever }}"
)
expect(results.steps[0].outputs.success).toEqual(true)
})
})

View File

@ -100,6 +100,7 @@ class BranchStepBuilder<TStep extends AutomationTriggerStepId> {
loop = this.step(AutomationActionStepId.LOOP)
serverLog = this.step(AutomationActionStepId.SERVER_LOG)
executeScript = this.step(AutomationActionStepId.EXECUTE_SCRIPT)
executeScriptV2 = this.step(AutomationActionStepId.EXECUTE_SCRIPT_V2)
filter = this.step(AutomationActionStepId.FILTER)
bash = this.step(AutomationActionStepId.EXECUTE_BASH)
openai = this.step(AutomationActionStepId.OPENAI)

View File

@ -4,17 +4,8 @@ import { automationQueue } from "./bullboard"
import { updateEntityMetadata } from "../utilities"
import { context, db as dbCore, utils } from "@budibase/backend-core"
import { getAutomationMetadataParams } from "../db/utils"
import { cloneDeep } from "lodash/fp"
import { quotas } from "@budibase/pro"
import {
Automation,
AutomationActionStepId,
AutomationJob,
AutomationStepDefinition,
AutomationTriggerDefinition,
AutomationTriggerStepId,
MetadataType,
} from "@budibase/types"
import { Automation, AutomationJob, MetadataType } from "@budibase/types"
import { automationsEnabled } from "../features"
import { helpers, REBOOT_CRON } from "@budibase/shared-core"
import tracer from "dd-trace"
@ -113,23 +104,6 @@ export async function updateTestHistory(
)
}
export function removeDeprecated<
T extends
| Record<keyof typeof AutomationTriggerStepId, AutomationTriggerDefinition>
| Record<keyof typeof AutomationActionStepId, AutomationStepDefinition>
>(definitions: T): T {
const base: Record<
string,
AutomationTriggerDefinition | AutomationStepDefinition
> = cloneDeep(definitions)
for (let key of Object.keys(base)) {
if (base[key].deprecated) {
delete base[key]
}
}
return base as T
}
// end the repetition and the job itself
export async function disableAllCrons(appId: any) {
const promises = []

View File

@ -99,6 +99,7 @@ export default class TestConfiguration {
request?: supertest.SuperTest<supertest.Test>
started: boolean
appId?: string
name?: string
allApps: App[]
app?: App
prodApp?: App

View File

@ -555,8 +555,16 @@ class Orchestrator {
throw new Error(`Cannot find automation step by name ${step.stepId}`)
}
const inputs = automationUtils.cleanInputValues(
await processObject(cloneDeep(step.inputs), ctx),
let inputs = cloneDeep(step.inputs)
if (step.stepId !== AutomationActionStepId.EXECUTE_SCRIPT_V2) {
// The EXECUTE_SCRIPT_V2 step saves its input.code value as a `{{ js
// "..." }}` template, and expects to receive it that way in the
// function that runs it. So we skip this next bit for that step.
inputs = await processObject(inputs, ctx)
}
inputs = automationUtils.cleanInputValues(
inputs,
step.schema.inputs.properties
)

View File

@ -10,6 +10,7 @@ import {
export const definition: AutomationStepDefinition = {
name: "JS Scripting",
tagline: "Execute JavaScript Code",
deprecated: true,
icon: "Code",
description: "Run a piece of JavaScript code in your automation",
type: AutomationStepType.ACTION,

View File

@ -0,0 +1,48 @@
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationFeature,
AutomationIOType,
AutomationStepDefinition,
AutomationStepType,
} from "@budibase/types"
export const definition: AutomationStepDefinition = {
name: "JavaScript",
tagline: "Execute JavaScript Code",
icon: "Brackets",
description: "Run a piece of JavaScript code in your automation",
type: AutomationStepType.ACTION,
internal: true,
new: true,
stepId: AutomationActionStepId.EXECUTE_SCRIPT_V2,
inputs: {},
features: {
[AutomationFeature.LOOPING]: true,
},
schema: {
inputs: {
properties: {
code: {
type: AutomationIOType.STRING,
customType: AutomationCustomIOType.CODE,
title: "Code",
},
},
required: ["code"],
},
outputs: {
properties: {
value: {
type: AutomationIOType.STRING,
description: "The result of the return statement",
},
success: {
type: AutomationIOType.BOOLEAN,
description: "Whether the action was successful",
},
},
required: ["success"],
},
},
}

View File

@ -7,6 +7,7 @@ export * as deleteRow from "./deleteRow"
export * as discord from "./discord"
export * as executeQuery from "./executeQuery"
export * as executeScript from "./executeScript"
export * as executeScriptV2 from "./executeScriptV2"
export * as filter from "./filter"
export * as loop from "./loop"
export * as make from "./make"

View File

@ -63,6 +63,7 @@ export enum AutomationActionStepId {
EXECUTE_BASH = "EXECUTE_BASH",
OUTGOING_WEBHOOK = "OUTGOING_WEBHOOK",
EXECUTE_SCRIPT = "EXECUTE_SCRIPT",
EXECUTE_SCRIPT_V2 = "EXECUTE_SCRIPT_V2",
EXECUTE_QUERY = "EXECUTE_QUERY",
SERVER_LOG = "SERVER_LOG",
DELAY = "DELAY",

View File

@ -84,6 +84,10 @@ export type ActionImplementations<T extends Hosting> = {
ExecuteScriptStepInputs,
ExecuteScriptStepOutputs
>
[AutomationActionStepId.EXECUTE_SCRIPT_V2]: ActionImplementation<
ExecuteScriptStepInputs,
ExecuteScriptStepOutputs
>
[AutomationActionStepId.FILTER]: ActionImplementation<
FilterStepInputs,
FilterStepOutputs
@ -155,6 +159,7 @@ export interface AutomationStepSchemaBase {
type: AutomationStepType
internal?: boolean
deprecated?: boolean
new?: boolean
blockToLoop?: string
schema: {
inputs: InputOutputBlock
@ -177,6 +182,8 @@ export type AutomationStepInputs<T extends AutomationActionStepId> =
? ExecuteQueryStepInputs
: T extends AutomationActionStepId.EXECUTE_SCRIPT
? ExecuteScriptStepInputs
: T extends AutomationActionStepId.EXECUTE_SCRIPT_V2
? ExecuteScriptStepInputs
: T extends AutomationActionStepId.FILTER
? FilterStepInputs
: T extends AutomationActionStepId.QUERY_ROWS
@ -279,6 +286,9 @@ export type ExecuteQueryStep =
export type ExecuteScriptStep =
AutomationStepSchema<AutomationActionStepId.EXECUTE_SCRIPT>
export type ExecuteScriptV2Step =
AutomationStepSchema<AutomationActionStepId.EXECUTE_SCRIPT_V2>
export type FilterStep = AutomationStepSchema<AutomationActionStepId.FILTER>
export type QueryRowsStep =
@ -325,6 +335,7 @@ export type AutomationStep =
| DeleteRowStep
| ExecuteQueryStep
| ExecuteScriptStep
| ExecuteScriptV2Step
| FilterStep
| QueryRowsStep
| SendEmailSmtpStep

View File

@ -1,3 +1,8 @@
import {
GetAutomationActionDefinitionsResponse,
GetAutomationTriggerDefinitionsResponse,
} from "../../api"
export interface BranchPath {
stepIdx: number
branchIdx: number
@ -6,7 +11,7 @@ export interface BranchPath {
}
export interface BlockDefinitions {
TRIGGER: Record<string, any>
CREATABLE_TRIGGER: Record<string, any>
ACTION: Record<string, any>
TRIGGER: Partial<GetAutomationTriggerDefinitionsResponse>
CREATABLE_TRIGGER: Partial<GetAutomationTriggerDefinitionsResponse>
ACTION: Partial<GetAutomationActionDefinitionsResponse>
}