diff --git a/lerna.json b/lerna.json index b6eb31f2b0..91980e0a15 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.4.13", + "version": "3.4.15", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index fbf74d1e9b..0f4bc64e2a 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -49,6 +49,7 @@ import type { EditorMode } from "@budibase/types" import type { BindingCompletion, CodeValidator } from "@/types" import { validateHbsTemplate } from "./validator/hbs" + import { validateJsTemplate } from "./validator/js" export let label: string | undefined = undefined export let completions: BindingCompletion[] = [] @@ -356,6 +357,9 @@ if (mode === EditorModes.Handlebars) { const diagnostics = validateHbsTemplate(value, validations) editor.dispatch(setDiagnostics(editor.state, diagnostics)) + } else if (mode === EditorModes.JS) { + const diagnostics = validateJsTemplate(value, validations) + editor.dispatch(setDiagnostics(editor.state, diagnostics)) } } diff --git a/packages/builder/src/components/common/CodeEditor/validator/js.ts b/packages/builder/src/components/common/CodeEditor/validator/js.ts new file mode 100644 index 0000000000..20fb5abd07 --- /dev/null +++ b/packages/builder/src/components/common/CodeEditor/validator/js.ts @@ -0,0 +1,101 @@ +import { Parser } from "acorn" +import * as walk from "acorn-walk" + +import type { Diagnostic } from "@codemirror/lint" +import { CodeValidator } from "@/types" + +export function validateJsTemplate( + code: string, + validations: CodeValidator +): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + + try { + const ast = Parser.parse(code, { + ecmaVersion: "latest", + locations: true, + allowReturnOutsideFunction: true, + }) + + const lineOffsets: number[] = [] + let offset = 0 + for (const line of code.split("\n")) { + lineOffsets.push(offset) + offset += line.length + 1 // +1 for newline character + } + + let hasReturnStatement = false + walk.ancestor(ast, { + ReturnStatement(node, _state, ancestors) { + if ( + // it returns a value + node.argument && + // and it is top level + ancestors.length === 2 && + ancestors[0].type === "Program" && + ancestors[1].type === "ReturnStatement" + ) { + hasReturnStatement = true + } + }, + CallExpression(node) { + const callee: any = node.callee + if ( + node.type === "CallExpression" && + callee.object?.name === "helpers" && + node.loc + ) { + const functionName = callee.property.name + const from = + lineOffsets[node.loc.start.line - 1] + node.loc.start.column + const to = lineOffsets[node.loc.end.line - 1] + node.loc.end.column + + if (!(functionName in validations)) { + diagnostics.push({ + from, + to, + severity: "warning", + message: `"${functionName}" function does not exist.`, + }) + return + } + + const { arguments: expectedArguments } = validations[functionName] + if ( + expectedArguments && + node.arguments.length !== expectedArguments.length + ) { + diagnostics.push({ + from, + to, + severity: "error", + message: `Function "${functionName}" expects ${ + expectedArguments.length + } parameters (${expectedArguments.join(", ")}), but got ${ + node.arguments.length + }.`, + }) + } + } + }, + }) + + if (!hasReturnStatement) { + diagnostics.push({ + from: 0, + to: code.length, + severity: "error", + message: "Your code must return a value.", + }) + } + } catch (e: any) { + diagnostics.push({ + from: 0, + to: code.length, + severity: "error", + message: `Syntax error: ${e.message}`, + }) + } + + return diagnostics +} diff --git a/packages/builder/src/components/common/CodeEditor/validator/tests/js.spec.ts b/packages/builder/src/components/common/CodeEditor/validator/tests/js.spec.ts new file mode 100644 index 0000000000..cd9fe4684c --- /dev/null +++ b/packages/builder/src/components/common/CodeEditor/validator/tests/js.spec.ts @@ -0,0 +1,156 @@ +import { validateJsTemplate } from "../js" +import { CodeValidator } from "@/types" + +describe("js validator", () => { + it("validates valid code", () => { + const text = "return 7" + const validators = {} + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([]) + }) + + it("does not validate runtime errors", () => { + const text = "return a" + const validators = {} + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([]) + }) + + it("validates multiline code", () => { + const text = "const foo='bar'\nreturn 123" + const validators = {} + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([]) + }) + + it("allows return not being on the last line", () => { + const text = "const foo='bar'\nreturn 123\nconsole.log(foo)" + const validators = {} + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([]) + }) + + it("throws on missing return", () => { + const text = "const foo='bar'\nbar='foo'" + const validators = {} + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: "Your code must return a value.", + severity: "error", + to: 25, + }, + ]) + }) + + it("checks that returns are at top level", () => { + const text = ` + function call(){ + return 1 + }` + const validators = {} + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([ + { + from: 0, + message: "Your code must return a value.", + severity: "error", + to: text.length, + }, + ]) + }) + + describe("helpers", () => { + const validators: CodeValidator = { + helperFunction: { + arguments: ["a", "b", "c"], + }, + } + + it("validates helpers with valid params", () => { + const text = "return helpers.helperFunction(1, 99, 'a')" + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([]) + }) + + it("throws on too few params", () => { + const text = "return helpers.helperFunction(100)" + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([ + { + from: 7, + message: `Function "helperFunction" expects 3 parameters (a, b, c), but got 1.`, + severity: "error", + to: 34, + }, + ]) + }) + + it("throws on too many params", () => { + const text = "return helpers.helperFunction( 1, 99, 'a', 100)" + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([ + { + from: 7, + message: `Function "helperFunction" expects 3 parameters (a, b, c), but got 4.`, + severity: "error", + to: 47, + }, + ]) + }) + + it("validates helpers on inner functions", () => { + const text = `function call(){ + return helpers.helperFunction(1, 99) + } + return call()` + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([ + { + from: 46, + message: `Function "helperFunction" expects 3 parameters (a, b, c), but got 2.`, + severity: "error", + to: 75, + }, + ]) + }) + + it("validates multiple helpers", () => { + const text = + "return helpers.helperFunction(1, 99, 'a') + helpers.helperFunction(1) + helpers.another(1) + helpers.another()" + const validators: CodeValidator = { + helperFunction: { + arguments: ["a", "b", "c"], + }, + another: { arguments: [] }, + } + + const result = validateJsTemplate(text, validators) + expect(result).toEqual([ + { + from: 44, + message: `Function "helperFunction" expects 3 parameters (a, b, c), but got 1.`, + severity: "error", + to: 69, + }, + { + from: 72, + message: `Function "another" expects 0 parameters (), but got 1.`, + severity: "error", + to: 90, + }, + ]) + }) + }) +}) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 1f61136500..32d024f798 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -377,6 +377,7 @@ value={jsValue ? decodeJSBinding(jsValue) : jsValue} on:change={onChangeJSValue} {completions} + {validations} mode={EditorModes.JS} bind:getCaretPosition bind:insertAtPos diff --git a/packages/server/src/automations/tests/steps/loop.spec.ts b/packages/server/src/automations/tests/steps/loop.spec.ts index 883732330f..2bdf33b253 100644 --- a/packages/server/src/automations/tests/steps/loop.spec.ts +++ b/packages/server/src/automations/tests/steps/loop.spec.ts @@ -7,6 +7,7 @@ import { CreateRowStepOutputs, FieldType, FilterCondition, + AutomationStepStatus, } from "@budibase/types" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import TestConfiguration from "../../../tests/utilities/TestConfiguration" @@ -560,5 +561,25 @@ describe("Attempt to run a basic loop automation", () => { 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 + ) + }) }) }) diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index 8b2aac662c..762da1cbc1 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -68,7 +68,11 @@ function getLoopIterable(step: LoopStep): any[] { let input = step.inputs.binding if (option === LoopStepType.ARRAY && typeof input === "string") { - input = JSON.parse(input) + if (input === "") { + input = [] + } else { + input = JSON.parse(input) + } } if (option === LoopStepType.STRING && Array.isArray(input)) { @@ -492,7 +496,7 @@ class Orchestrator { } const status = - iterations === 0 ? AutomationStatus.NO_CONDITION_MET : undefined + iterations === 0 ? AutomationStepStatus.NO_ITERATIONS : undefined return stepSuccess(stepToLoop, { status, iterations, items }) }) }