diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Binding/autocomplete_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Binding/autocomplete_spec.js index c190c7e04a..fe392688ee 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Binding/autocomplete_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Binding/autocomplete_spec.js @@ -11,6 +11,9 @@ describe("Dynamic input autocomplete", () => { cy.wait(3000); cy.selectEntityByName("Aditya"); cy.openPropertyPane("buttonwidget"); + cy.testJsontext("label", "", { + parseSpecialCharSequences: true, + }); cy.get(dynamicInputLocators.input) .first() .click({ force: true }) @@ -27,22 +30,29 @@ describe("Dynamic input autocomplete", () => { // Tests if autocomplete will open cy.get(dynamicInputLocators.hints).should("exist"); - // Tests if data tree entities are sorted cy.get(`${dynamicInputLocators.hints} li`) .eq(1) .should("have.text", "Button1.text"); + cy.testJsontext("label", "", { + parseSpecialCharSequences: true, + }); // Tests if "No suggestions" message will pop if you type any garbage cy.get(dynamicInputLocators.input) .first() .click({ force: true }) .type("{uparrow}", { parseSpecialCharSequences: true }) .type("{ctrl}{shift}{downarrow}", { parseSpecialCharSequences: true }) - .type("{{ garbage", { - parseSpecialCharSequences: true, - }) + .type("{backspace}", { parseSpecialCharSequences: true }) + .then(() => { + cy.get(dynamicInputLocators.input) + .first() + .click({ force: true }) + .type("{{garbage", { + parseSpecialCharSequences: true, + }); cy.get(".CodeMirror-Tern-tooltip").should( "have.text", "No suggestions", @@ -51,6 +61,24 @@ describe("Dynamic input autocomplete", () => { }); cy.evaluateErrorMessage("ReferenceError: garbage is not defined"); }); + + it("test if action inside non event field throws error", () => { + cy.get(dynamicInputLocators.input) + .first() + .click({ force: true }) + .type("{backspace}".repeat(12)) + .type("{{storeValue()}}", { parseSpecialCharSequences: false }); + + cy.wait(1000); + + cy.evaluateErrorMessage( + "Found a reference to {{actionName}} during evaluation. Sync fields cannot execute framework actions. Please remove any direct/indirect references to {{actionName}} and try again.".replaceAll( + "{{actionName}}", + "storeValue()", + ), + ); + }); + it("opens current value popup", () => { // Test on api pane cy.NavigateToAPI_Panel(); diff --git a/app/client/src/ce/entities/DataTree/actionTriggers.ts b/app/client/src/ce/entities/DataTree/actionTriggers.ts index ba0090642c..16ba8405dc 100644 --- a/app/client/src/ce/entities/DataTree/actionTriggers.ts +++ b/app/client/src/ce/entities/DataTree/actionTriggers.ts @@ -21,6 +21,8 @@ export enum ActionTriggerType { STOP_WATCHING_CURRENT_LOCATION = "STOP_WATCHING_CURRENT_LOCATION", CONFIRMATION_MODAL = "CONFIRMATION_MODAL", POST_MESSAGE = "POST_MESSAGE", + SET_TIMEOUT = "SET_TIMEOUT", + CLEAR_TIMEOUT = "CLEAR_TIMEOUT", } export const ActionTriggerFunctionNames: Record = { @@ -43,6 +45,8 @@ export const ActionTriggerFunctionNames: Record = { [ActionTriggerType.STOP_WATCHING_CURRENT_LOCATION]: "stopWatch", [ActionTriggerType.CONFIRMATION_MODAL]: "ConfirmationModal", [ActionTriggerType.POST_MESSAGE]: "postWindowMessage", + [ActionTriggerType.SET_TIMEOUT]: "setTimeout", + [ActionTriggerType.CLEAR_TIMEOUT]: "clearTimeout", }; export type RunPluginActionDescription = { diff --git a/app/client/src/ce/workers/Evaluation/Actions.ts b/app/client/src/ce/workers/Evaluation/Actions.ts index ac7b06f713..ca041cee34 100644 --- a/app/client/src/ce/workers/Evaluation/Actions.ts +++ b/app/client/src/ce/workers/Evaluation/Actions.ts @@ -1,16 +1,19 @@ /* eslint-disable @typescript-eslint/ban-types */ import { DataTree, DataTreeEntity } from "entities/DataTree/dataTreeFactory"; -import _ from "lodash"; +import _, { set } from "lodash"; import { ActionDescription, + ActionTriggerFunctionNames, ActionTriggerType, } from "@appsmith/entities/DataTree/actionTriggers"; import { NavigationTargetType } from "sagas/ActionExecution/NavigateActionSaga"; import { promisifyAction } from "workers/Evaluation/PromisifyAction"; -import { klona } from "klona/full"; import uniqueId from "lodash/uniqueId"; import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; import { isAction, isAppsmithEntity, isTrueObject } from "./evaluationUtils"; +import { EvalContext } from "workers/Evaluation/evaluate"; +import { ActionCalledInSyncFieldError } from "workers/Evaluation/errorModifier"; + declare global { /** All identifiers added to the worker global scope should also * be included in the DEDICATED_WORKER_GLOBAL_SCOPE_IDENTIFIERS in @@ -37,14 +40,9 @@ type ActionDispatcherWithExecutionType = ( ...args: any[] ) => ActionDescriptionWithExecutionType; -export const DATA_TREE_FUNCTIONS: Record< +export const PLATFORM_FUNCTIONS: Record< string, - | ActionDispatcherWithExecutionType - | { - qualifier: (entity: DataTreeEntity) => boolean; - func: (entity: DataTreeEntity) => ActionDispatcherWithExecutionType; - path?: string; - } + ActionDispatcherWithExecutionType > = { navigateTo: function( pageNameOrUrl: string, @@ -136,6 +134,51 @@ export const DATA_TREE_FUNCTIONS: Record< executionType: ExecutionType.PROMISE, }; }, + setInterval: function(callback: Function, interval: number, id?: string) { + return { + type: ActionTriggerType.SET_INTERVAL, + payload: { + callback: callback?.toString(), + interval, + id, + }, + executionType: ExecutionType.TRIGGER, + }; + }, + clearInterval: function(id: string) { + return { + type: ActionTriggerType.CLEAR_INTERVAL, + payload: { + id, + }, + executionType: ExecutionType.TRIGGER, + }; + }, + postWindowMessage: function( + message: unknown, + source: string, + targetOrigin: string, + ) { + return { + type: ActionTriggerType.POST_MESSAGE, + payload: { + message, + source, + targetOrigin, + }, + executionType: ExecutionType.TRIGGER, + }; + }, +}; + +const ENTITY_FUNCTIONS: Record< + string, + { + qualifier: (entity: DataTreeEntity) => boolean; + func: (entity: DataTreeEntity) => ActionDispatcherWithExecutionType; + path?: string; + } +> = { run: { qualifier: (entity) => isAction(entity), func: (entity) => @@ -190,26 +233,6 @@ export const DATA_TREE_FUNCTIONS: Record< }; }, }, - setInterval: function(callback: Function, interval: number, id?: string) { - return { - type: ActionTriggerType.SET_INTERVAL, - payload: { - callback: callback.toString(), - interval, - id, - }, - executionType: ExecutionType.TRIGGER, - }; - }, - clearInterval: function(id: string) { - return { - type: ActionTriggerType.CLEAR_INTERVAL, - payload: { - id, - }, - executionType: ExecutionType.TRIGGER, - }; - }, getGeoLocation: { qualifier: (entity) => isAppsmithEntity(entity), path: "appsmith.geolocation.getCurrentPosition", @@ -281,70 +304,86 @@ export const DATA_TREE_FUNCTIONS: Record< }; }, }, - postWindowMessage: function( - message: unknown, - source: string, - targetOrigin: string, - ) { - return { - type: ActionTriggerType.POST_MESSAGE, - payload: { - message, - source, - targetOrigin, - }, - executionType: ExecutionType.TRIGGER, - }; - }, }; -export const enhanceDataTreeWithFunctions = ( - dataTree: Readonly, - // Whether not to add functions like "run", "clear" to entity - skipEntityFunctions = false, - eventType?: EventType, -): DataTree => { - const clonedDT = klona(dataTree); +const platformFunctionEntries = Object.entries(PLATFORM_FUNCTIONS); +const entityFunctionEntries = Object.entries(ENTITY_FUNCTIONS); +/** + * This method returns new dataTree with entity function and platform function + */ +export const addDataTreeToContext = (args: { + EVAL_CONTEXT: EvalContext; + dataTree: Readonly; + skipEntityFunctions?: boolean; + eventType?: EventType; + isTriggerBased: boolean; +}) => { + const { + dataTree, + EVAL_CONTEXT, + eventType, + isTriggerBased, + skipEntityFunctions = false, + } = args; + const dataTreeEntries = Object.entries(dataTree); + const entityFunctionCollection: Record> = {}; + self.TRIGGER_COLLECTOR = []; - Object.entries(DATA_TREE_FUNCTIONS).forEach(([name, funcOrFuncCreator]) => { - if ( - typeof funcOrFuncCreator === "object" && - "qualifier" in funcOrFuncCreator - ) { - !skipEntityFunctions && - Object.entries(dataTree).forEach(([entityName, entity]) => { - if (funcOrFuncCreator.qualifier(entity)) { - const func = funcOrFuncCreator.func(entity); - const funcName = `${funcOrFuncCreator.path || - `${entityName}.${name}`}`; - _.set( - clonedDT, - funcName, - pusher.bind( - { - TRIGGER_COLLECTOR: self.TRIGGER_COLLECTOR, - EVENT_TYPE: eventType, - }, - func, - ), - ); - } - }); - } else { - _.set( - clonedDT, - name, + + for (const [entityName, entity] of dataTreeEntries) { + EVAL_CONTEXT[entityName] = entity; + if (skipEntityFunctions || !isTriggerBased) continue; + + for (const [functionName, funcCreator] of entityFunctionEntries) { + if (!funcCreator.qualifier(entity)) continue; + const func = funcCreator.func(entity); + const fullPath = `${funcCreator.path || `${entityName}.${functionName}`}`; + set( + entityFunctionCollection, + fullPath, pusher.bind( { - TRIGGER_COLLECTOR: self.TRIGGER_COLLECTOR, + EVENT_TYPE: eventType, }, - funcOrFuncCreator, + func, ), ); } - }); + } - return clonedDT; + // if eval is not trigger based i.e., sync eval then we skip adding entity and platform function to evalContext + if (!isTriggerBased) return; + + for (const [entityName, funcObj] of Object.entries( + entityFunctionCollection, + )) { + EVAL_CONTEXT[entityName] = Object.assign({}, dataTree[entityName], funcObj); + } +}; + +export const addPlatformFunctionsToEvalContext = (context: any) => { + for (const [funcName, fn] of platformFunctionEntries) { + context[funcName] = pusher.bind({}, fn); + } +}; + +export const getAllAsyncFunctions = (dataTree: DataTree) => { + const asyncFunctionNameMap: Record = {}; + const dataTreeEntries = Object.entries(dataTree); + + for (const [entityName, entity] of dataTreeEntries) { + for (const [functionName, funcCreator] of entityFunctionEntries) { + if (!funcCreator.qualifier(entity)) continue; + const fullPath = `${funcCreator.path || `${entityName}.${functionName}`}`; + asyncFunctionNameMap[fullPath] = true; + } + } + + for (const [name] of platformFunctionEntries) { + asyncFunctionNameMap[name] = true; + } + + return asyncFunctionNameMap; }; /** @@ -363,13 +402,17 @@ export const enhanceDataTreeWithFunctions = ( * **/ export const pusher = function( this: { - TRIGGER_COLLECTOR: ActionDescription[]; EVENT_TYPE?: EventType; }, action: ActionDispatcherWithExecutionType, ...args: any[] ) { const actionDescription = action(...args); + if (!self.ALLOW_ASYNC) { + self.IS_ASYNC = true; + const actionName = ActionTriggerFunctionNames[actionDescription.type]; + throw new ActionCalledInSyncFieldError(actionName); + } const { executionType, payload, type } = actionDescription; const actionPayload = { type, @@ -377,7 +420,7 @@ export const pusher = function( } as ActionDescription; if (executionType && executionType === ExecutionType.TRIGGER) { - this.TRIGGER_COLLECTOR.push(actionPayload); + self.TRIGGER_COLLECTOR.push(actionPayload); } else { return promisifyAction(actionPayload, this.EVENT_TYPE); } diff --git a/app/client/src/ce/workers/Evaluation/dataTreeUtils.ts b/app/client/src/ce/workers/Evaluation/dataTreeUtils.ts index d64a5d4975..3f3b2445bd 100644 --- a/app/client/src/ce/workers/Evaluation/dataTreeUtils.ts +++ b/app/client/src/ce/workers/Evaluation/dataTreeUtils.ts @@ -52,7 +52,7 @@ export function makeEntityConfigsAsObjProperties( for (const entityName of Object.keys(dataTree)) { const entityConfig = Object.getPrototypeOf(dataTree[entityName]) || {}; const entity = dataTree[entityName]; - newDataTree[entityName] = { ...entityConfig, ...entity }; + newDataTree[entityName] = Object.assign({}, entityConfig, entity); } const dataTreeToReturn = sanitizeDataTree ? JSON.parse(JSON.stringify(newDataTree)) diff --git a/app/client/src/components/editorComponents/Debugger/ContextualMenu.tsx b/app/client/src/components/editorComponents/Debugger/ContextualMenu.tsx index 9f1c6c8a71..d9e2e6506f 100644 --- a/app/client/src/components/editorComponents/Debugger/ContextualMenu.tsx +++ b/app/client/src/components/editorComponents/Debugger/ContextualMenu.tsx @@ -70,7 +70,7 @@ const getOptions = (type?: string, subType?: string) => { CONTEXT_MENU_ACTIONS.INTERCOM, ]; case PropertyEvaluationErrorType.PARSE: - return [CONTEXT_MENU_ACTIONS.SNIPPET]; + return [CONTEXT_MENU_ACTIONS.DOCS, CONTEXT_MENU_ACTIONS.SNIPPET]; case PropertyEvaluationErrorType.LINT: return [CONTEXT_MENU_ACTIONS.SNIPPET]; default: diff --git a/app/client/src/workers/Evaluation/JSObject/index.ts b/app/client/src/workers/Evaluation/JSObject/index.ts index dd4d745dec..6393dddf87 100644 --- a/app/client/src/workers/Evaluation/JSObject/index.ts +++ b/app/client/src/workers/Evaluation/JSObject/index.ts @@ -4,7 +4,7 @@ import { EvalErrorTypes } from "utils/DynamicBindingUtils"; import { JSUpdate, ParsedJSSubAction } from "utils/JSPaneUtils"; import { isTypeOfFunction, parseJSObjectWithAST } from "@shared/ast"; import DataTreeEvaluator from "workers/common/DataTreeEvaluator"; -import evaluateSync, { isFunctionAsync } from "workers/Evaluation/evaluate"; +import evaluateSync from "workers/Evaluation/evaluate"; import { DataTreeDiff, DataTreeDiffEvent, @@ -15,6 +15,7 @@ import { removeFunctionsAndVariableJSCollection, updateJSCollectionInUnEvalTree, } from "workers/Evaluation/JSObject/utils"; +import { functionDeterminer } from "../functionDeterminer"; /** * Here we update our unEvalTree according to the change in JSObject's body @@ -246,16 +247,19 @@ export function parseJSActions( }); } + functionDeterminer.setupEval( + unEvalDataTree, + dataTreeEvalRef.resolvedFunctions, + ); + Object.keys(jsUpdates).forEach((entityName) => { const parsedBody = jsUpdates[entityName].parsedBody; if (!parsedBody) return; parsedBody.actions = parsedBody.actions.map((action) => { return { ...action, - isAsync: isFunctionAsync( + isAsync: functionDeterminer.isFunctionAsync( action.parsedFunction, - unEvalDataTree, - dataTreeEvalRef.resolvedFunctions, dataTreeEvalRef.logs, ), // parsedFunction - used only to determine if function is async @@ -263,6 +267,9 @@ export function parseJSActions( } as ParsedJSSubAction; }); }); + + functionDeterminer.setOffEval(); + return { jsUpdates }; } diff --git a/app/client/src/workers/Evaluation/PromisifyAction.ts b/app/client/src/workers/Evaluation/PromisifyAction.ts index ff3a70214a..5766992190 100644 --- a/app/client/src/workers/Evaluation/PromisifyAction.ts +++ b/app/client/src/workers/Evaluation/PromisifyAction.ts @@ -1,4 +1,4 @@ -import { createGlobalData } from "workers/Evaluation/evaluate"; +import { createEvaluationContext } from "workers/Evaluation/evaluate"; const ctx: Worker = self as any; /* @@ -19,15 +19,6 @@ export const promisifyAction = ( actionDescription: ActionDescription, eventType?: EventType, ) => { - if (!self.ALLOW_ASYNC) { - /** - * To figure out if any function (JS action) is async, we do a dry run so that we can know if the function - * is using an async action. We set an IS_ASYNC flag to later indicate that a promise was called. - * @link isFunctionAsync - * */ - self.IS_ASYNC = true; - throw new Error("Async function called in a sync field"); - } return new Promise((resolve, reject) => { // We create a new sub request id for each request going on so that we can resolve the correct one later on const messageId = _.uniqueId(`${actionDescription.type}_`); @@ -61,7 +52,7 @@ export const promisifyAction = ( } else { self.ALLOW_ASYNC = true; // Reset the global data with the correct request id for this promise - const globalData = createGlobalData({ + const evalContext = createEvaluationContext({ dataTree: dataTreeEvaluator.evalTree, resolvedFunctions: dataTreeEvaluator.resolvedFunctions, isTriggerBased: true, @@ -69,10 +60,8 @@ export const promisifyAction = ( eventType, }, }); - for (const entity in globalData) { - // @ts-expect-error: Types are not available - self[entity] = globalData[entity]; - } + + Object.assign(self, evalContext); // Resolve or reject the promise if (success) { diff --git a/app/client/src/workers/Evaluation/TimeoutOverride.ts b/app/client/src/workers/Evaluation/TimeoutOverride.ts index ce5d4e53ea..57a4ab38d7 100644 --- a/app/client/src/workers/Evaluation/TimeoutOverride.ts +++ b/app/client/src/workers/Evaluation/TimeoutOverride.ts @@ -1,4 +1,5 @@ -import { createGlobalData } from "./evaluate"; +import { ActionCalledInSyncFieldError } from "./errorModifier"; +import { createEvaluationContext } from "./evaluate"; import { dataTreeEvaluator } from "./handlers/evalTree"; export const _internalSetTimeout = self.setTimeout; @@ -11,9 +12,9 @@ export default function overrideTimeout() { value: function(cb: (...args: any) => any, delay: number, ...args: any) { if (!self.ALLOW_ASYNC) { self.IS_ASYNC = true; - throw new Error("Async function called in a sync field"); + throw new ActionCalledInSyncFieldError("setTimeout"); } - const globalData = createGlobalData({ + const evalContext = createEvaluationContext({ dataTree: dataTreeEvaluator?.evalTree || {}, resolvedFunctions: dataTreeEvaluator?.resolvedFunctions || {}, isTriggerBased: true, @@ -21,7 +22,7 @@ export default function overrideTimeout() { return _internalSetTimeout( function(...args: any) { self.ALLOW_ASYNC = true; - Object.assign(self, globalData); + Object.assign(self, evalContext); cb(...args); }, delay, @@ -34,6 +35,10 @@ export default function overrideTimeout() { writable: true, configurable: true, value: function(timerId: number) { + if (!self.ALLOW_ASYNC) { + self.IS_ASYNC = true; + throw new ActionCalledInSyncFieldError("clearTimeout"); + } return _internalClearTimeout(timerId); }, }); diff --git a/app/client/src/workers/Evaluation/__tests__/Actions.test.ts b/app/client/src/workers/Evaluation/__tests__/Actions.test.ts index f48059cbd9..06c92a6d80 100644 --- a/app/client/src/workers/Evaluation/__tests__/Actions.test.ts +++ b/app/client/src/workers/Evaluation/__tests__/Actions.test.ts @@ -1,8 +1,16 @@ import { DataTree, ENTITY_TYPE } from "entities/DataTree/dataTreeFactory"; import { PluginType } from "entities/Action"; -import { createGlobalData } from "workers/Evaluation/evaluate"; +import { + createEvaluationContext, + EvalContext, +} from "workers/Evaluation/evaluate"; import uniqueId from "lodash/uniqueId"; import { MessageType } from "utils/MessageUtil"; +import { + addDataTreeToContext, + addPlatformFunctionsToEvalContext, +} from "@appsmith/workers/Evaluation/Actions"; + jest.mock("lodash/uniqueId"); describe("Add functions", () => { @@ -31,15 +39,15 @@ describe("Add functions", () => { }, }; self.TRIGGER_COLLECTOR = []; - const dataTreeWithFunctions = createGlobalData({ + const evalContext = createEvaluationContext({ dataTree, resolvedFunctions: {}, isTriggerBased: true, - context: { - requestId: "EVAL_TRIGGER", - }, + context: {}, }); + addPlatformFunctionsToEvalContext(evalContext); + const messageCreator = (type: string, body: unknown) => ({ messageId: expect.stringContaining(type), messageType: MessageType.REQUEST, @@ -58,9 +66,9 @@ describe("Add functions", () => { const actionParams = { param1: "value1" }; // Old syntax works with functions - expect( - dataTreeWithFunctions.action1.run(onSuccess, onError, actionParams), - ).toBe(undefined); + expect(evalContext.action1.run(onSuccess, onError, actionParams)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toHaveLength(1); expect(self.TRIGGER_COLLECTOR[0]).toStrictEqual({ payload: { @@ -77,9 +85,9 @@ describe("Add functions", () => { self.TRIGGER_COLLECTOR.pop(); // Old syntax works with one undefined value - expect( - dataTreeWithFunctions.action1.run(onSuccess, undefined, actionParams), - ).toBe(undefined); + expect(evalContext.action1.run(onSuccess, undefined, actionParams)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toHaveLength(1); expect(self.TRIGGER_COLLECTOR[0]).toStrictEqual({ payload: { @@ -95,9 +103,9 @@ describe("Add functions", () => { self.TRIGGER_COLLECTOR.pop(); - expect( - dataTreeWithFunctions.action1.run(undefined, onError, actionParams), - ).toBe(undefined); + expect(evalContext.action1.run(undefined, onError, actionParams)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toHaveLength(1); expect(self.TRIGGER_COLLECTOR[0]).toStrictEqual({ payload: { @@ -123,9 +131,9 @@ describe("Add functions", () => { }); // Old syntax works with null values is treated as new syntax - expect( - dataTreeWithFunctions.action1.run(null, null, actionParams), - ).resolves.toBe({ a: "b" }); + expect(evalContext.action1.run(null, null, actionParams)).resolves.toBe({ + a: "b", + }); expect(workerEventMock).lastCalledWith( messageCreator("RUN_PLUGIN_ACTION", { data: { @@ -143,7 +151,7 @@ describe("Add functions", () => { // Old syntax works with undefined values is treated as new syntax expect( - dataTreeWithFunctions.action1.run(undefined, undefined, actionParams), + evalContext.action1.run(undefined, undefined, actionParams), ).resolves.toBe({ a: "b" }); expect(workerEventMock).lastCalledWith( messageCreator("RUN_PLUGIN_ACTION", { @@ -162,7 +170,7 @@ describe("Add functions", () => { // new syntax works expect( - dataTreeWithFunctions.action1 + evalContext.action1 .run(actionParams) .then(onSuccess) .catch(onError), @@ -182,7 +190,7 @@ describe("Add functions", () => { }), ); // New syntax without params - expect(dataTreeWithFunctions.action1.run()).resolves.toBe({ a: "b" }); + expect(evalContext.action1.run()).resolves.toBe({ a: "b" }); expect(workerEventMock).lastCalledWith( messageCreator("RUN_PLUGIN_ACTION", { @@ -201,7 +209,7 @@ describe("Add functions", () => { }); it("action.clear works", () => { - expect(dataTreeWithFunctions.action1.clear()).resolves.toBe({}); + expect(evalContext.action1.clear()).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("CLEAR_PLUGIN_ACTION", { data: { @@ -223,9 +231,9 @@ describe("Add functions", () => { const params = "{ param1: value1 }"; const target = "NEW_WINDOW"; - expect( - dataTreeWithFunctions.navigateTo(pageNameOrUrl, params, target), - ).resolves.toBe({}); + expect(evalContext.navigateTo(pageNameOrUrl, params, target)).resolves.toBe( + {}, + ); expect(workerEventMock).lastCalledWith( messageCreator("NAVIGATE_TO", { data: { @@ -247,7 +255,7 @@ describe("Add functions", () => { it("showAlert works", () => { const message = "Alert message"; const style = "info"; - expect(dataTreeWithFunctions.showAlert(message, style)).resolves.toBe({}); + expect(evalContext.showAlert(message, style)).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("SHOW_ALERT", { data: { @@ -268,7 +276,7 @@ describe("Add functions", () => { it("showModal works", () => { const modalName = "Modal 1"; - expect(dataTreeWithFunctions.showModal(modalName)).resolves.toBe({}); + expect(evalContext.showModal(modalName)).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("SHOW_MODAL_BY_NAME", { data: { @@ -287,7 +295,7 @@ describe("Add functions", () => { it("closeModal works", () => { const modalName = "Modal 1"; - expect(dataTreeWithFunctions.closeModal(modalName)).resolves.toBe({}); + expect(evalContext.closeModal(modalName)).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("CLOSE_MODAL", { data: { @@ -313,9 +321,7 @@ describe("Add functions", () => { // @ts-expect-error: mockReturnValueOnce is not available on uniqueId uniqueId.mockReturnValueOnce(uniqueActionRequestId); - expect(dataTreeWithFunctions.storeValue(key, value, persist)).resolves.toBe( - {}, - ); + expect(evalContext.storeValue(key, value, persist)).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("STORE_VALUE", { data: { @@ -337,7 +343,7 @@ describe("Add functions", () => { it("removeValue works", () => { const key = "some"; - expect(dataTreeWithFunctions.removeValue(key)).resolves.toBe({}); + expect(evalContext.removeValue(key)).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("REMOVE_VALUE", { data: { @@ -355,7 +361,7 @@ describe("Add functions", () => { }); it("clearStore works", () => { - expect(dataTreeWithFunctions.clearStore()).resolves.toBe({}); + expect(evalContext.clearStore()).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("CLEAR_STORE", { data: { @@ -375,7 +381,7 @@ describe("Add functions", () => { const name = "downloadedFile.txt"; const type = "text"; - expect(dataTreeWithFunctions.download(data, name, type)).resolves.toBe({}); + expect(evalContext.download(data, name, type)).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("DOWNLOAD", { data: { @@ -396,7 +402,7 @@ describe("Add functions", () => { it("copyToClipboard works", () => { const data = "file"; - expect(dataTreeWithFunctions.copyToClipboard(data)).resolves.toBe({}); + expect(evalContext.copyToClipboard(data)).resolves.toBe({}); expect(workerEventMock).lastCalledWith( messageCreator("COPY_TO_CLIPBOARD", { data: { @@ -418,9 +424,9 @@ describe("Add functions", () => { const widgetName = "widget1"; const resetChildren = true; - expect( - dataTreeWithFunctions.resetWidget(widgetName, resetChildren), - ).resolves.toBe({}); + expect(evalContext.resetWidget(widgetName, resetChildren)).resolves.toBe( + {}, + ); expect(workerEventMock).lastCalledWith( messageCreator("RESET_WIDGET_META_RECURSIVE_BY_NAME", { data: { @@ -443,9 +449,7 @@ describe("Add functions", () => { const interval = 5000; const id = "myInterval"; - expect(dataTreeWithFunctions.setInterval(callback, interval, id)).toBe( - undefined, - ); + expect(evalContext.setInterval(callback, interval, id)).toBe(undefined); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -463,7 +467,7 @@ describe("Add functions", () => { it("clearInterval works", () => { const id = "myInterval"; - expect(dataTreeWithFunctions.clearInterval(id)).toBe(undefined); + expect(evalContext.clearInterval(id)).toBe(undefined); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -483,9 +487,9 @@ describe("Add functions", () => { it("Post message with first argument (message) as a string", () => { const message = "Hello world!"; - expect( - dataTreeWithFunctions.postWindowMessage(message, source, targetOrigin), - ).toBe(undefined); + expect(evalContext.postWindowMessage(message, source, targetOrigin)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ @@ -504,9 +508,9 @@ describe("Add functions", () => { it("Post message with first argument (message) as undefined", () => { const message = undefined; - expect( - dataTreeWithFunctions.postWindowMessage(message, source, targetOrigin), - ).toBe(undefined); + expect(evalContext.postWindowMessage(message, source, targetOrigin)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ @@ -525,9 +529,9 @@ describe("Add functions", () => { it("Post message with first argument (message) as null", () => { const message = null; - expect( - dataTreeWithFunctions.postWindowMessage(message, source, targetOrigin), - ).toBe(undefined); + expect(evalContext.postWindowMessage(message, source, targetOrigin)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ @@ -546,9 +550,9 @@ describe("Add functions", () => { it("Post message with first argument (message) as a number", () => { const message = 1826; - expect( - dataTreeWithFunctions.postWindowMessage(message, source, targetOrigin), - ).toBe(undefined); + expect(evalContext.postWindowMessage(message, source, targetOrigin)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ @@ -567,9 +571,9 @@ describe("Add functions", () => { it("Post message with first argument (message) as a boolean", () => { const message = true; - expect( - dataTreeWithFunctions.postWindowMessage(message, source, targetOrigin), - ).toBe(undefined); + expect(evalContext.postWindowMessage(message, source, targetOrigin)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ @@ -588,9 +592,9 @@ describe("Add functions", () => { it("Post message with first argument (message) as an array", () => { const message = [1, 2, 3, [1, 2, 3, [1, 2, 3]]]; - expect( - dataTreeWithFunctions.postWindowMessage(message, source, targetOrigin), - ).toBe(undefined); + expect(evalContext.postWindowMessage(message, source, targetOrigin)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ @@ -616,9 +620,9 @@ describe("Add functions", () => { randomArr: [1, 2, 3], }; - expect( - dataTreeWithFunctions.postWindowMessage(message, source, targetOrigin), - ).toBe(undefined); + expect(evalContext.postWindowMessage(message, source, targetOrigin)).toBe( + undefined, + ); expect(self.TRIGGER_COLLECTOR).toEqual( expect.arrayContaining([ @@ -642,3 +646,253 @@ describe("Add functions", () => { }); }); }); + +const dataTree = { + Text1: { + widgetName: "Text1", + displayName: "Text", + type: "TEXT_WIDGET", + hideCard: false, + animateLoading: true, + overflow: "SCROLL", + fontFamily: "Nunito Sans", + parentColumnSpace: 15.0625, + dynamicTriggerPathList: [], + leftColumn: 4, + dynamicBindingPathList: [], + shouldTruncate: false, + text: '"2022-11-27T21:36:00.128Z"', + key: "gt93hhlp15", + isDeprecated: false, + rightColumn: 29, + textAlign: "LEFT", + dynamicHeight: "FIXED", + widgetId: "ajg9fjegvr", + isVisible: true, + fontStyle: "BOLD", + textColor: "#231F20", + version: 1, + parentId: "0", + renderMode: "CANVAS", + isLoading: false, + borderRadius: "0.375rem", + maxDynamicHeight: 9000, + fontSize: "1rem", + minDynamicHeight: 4, + value: '"2022-11-27T21:36:00.128Z"', + defaultProps: {}, + defaultMetaProps: [], + logBlackList: { + value: true, + }, + meta: {}, + propertyOverrideDependency: {}, + overridingPropertyPaths: {}, + bindingPaths: { + text: "TEMPLATE", + isVisible: "TEMPLATE", + }, + reactivePaths: { + value: "TEMPLATE", + fontFamily: "TEMPLATE", + }, + triggerPaths: {}, + validationPaths: {}, + ENTITY_TYPE: "WIDGET", + privateWidgets: {}, + __evaluation__: { + errors: {}, + evaluatedValues: {}, + }, + backgroundColor: "", + borderColor: "", + }, + pageList: [ + { + pageName: "Page1", + pageId: "63349fb5d39f215f89b8245e", + isDefault: false, + isHidden: false, + slug: "page1", + }, + { + pageName: "Page2", + pageId: "637cc6b4a3664a7fe679b7b0", + isDefault: true, + isHidden: false, + slug: "page2", + }, + ], + appsmith: { + store: {}, + geolocation: { + canBeRequested: true, + currentPosition: {}, + }, + mode: "EDIT", + ENTITY_TYPE: "APPSMITH", + }, + Api2: { + run: {}, + clear: {}, + name: "Api2", + pluginType: "API", + config: {}, + dynamicBindingPathList: [ + { + key: "config.path", + }, + ], + responseMeta: { + isExecutionSuccess: false, + }, + ENTITY_TYPE: "ACTION", + isLoading: false, + bindingPaths: { + "config.path": "TEMPLATE", + "config.body": "SMART_SUBSTITUTE", + "config.pluginSpecifiedTemplates[1].value": "SMART_SUBSTITUTE", + "config.pluginSpecifiedTemplates[2].value.limitBased.limit.value": + "SMART_SUBSTITUTE", + }, + reactivePaths: { + data: "TEMPLATE", + isLoading: "TEMPLATE", + datasourceUrl: "TEMPLATE", + "config.path": "TEMPLATE", + "config.body": "SMART_SUBSTITUTE", + "config.pluginSpecifiedTemplates[1].value": "SMART_SUBSTITUTE", + }, + dependencyMap: { + "config.body": ["config.pluginSpecifiedTemplates[0].value"], + }, + logBlackList: {}, + datasourceUrl: "", + __evaluation__: { + errors: { + config: [], + }, + evaluatedValues: { + "config.path": "/users/undefined", + config: { + timeoutInMillisecond: 10000, + paginationType: "NONE", + path: "/users/test", + headers: [], + encodeParamsToggle: true, + queryParameters: [], + bodyFormData: [], + httpMethod: "GET", + selfReferencingDataPaths: [], + pluginSpecifiedTemplates: [ + { + value: true, + }, + ], + formData: { + apiContentType: "none", + }, + }, + }, + }, + }, + JSObject1: { + name: "JSObject1", + actionId: "637cda3b2f8e175c6f5269d5", + pluginType: "JS", + ENTITY_TYPE: "JSACTION", + body: + "export default {\n\tstoreTest2: () => {\n\t\tlet values = [\n\t\t\t\t\tstoreValue('val1', 'number 1'),\n\t\t\t\t\tstoreValue('val2', 'number 2'),\n\t\t\t\t\tstoreValue('val3', 'number 3'),\n\t\t\t\t\tstoreValue('val4', 'number 4')\n\t\t\t\t];\n\t\treturn Promise.all(values)\n\t\t\t.then(() => {\n\t\t\tshowAlert(JSON.stringify(appsmith.store))\n\t\t})\n\t\t\t.catch((err) => {\n\t\t\treturn showAlert('Could not store values in store ' + err.toString());\n\t\t})\n\t},\n\tnewFunction: function() {\n\t\tJSObject1.storeTest()\n\t}\n}", + meta: { + newFunction: { + arguments: [], + isAsync: false, + confirmBeforeExecute: false, + }, + storeTest2: { + arguments: [], + isAsync: true, + confirmBeforeExecute: false, + }, + }, + bindingPaths: { + body: "SMART_SUBSTITUTE", + newFunction: "SMART_SUBSTITUTE", + storeTest2: "SMART_SUBSTITUTE", + }, + reactivePaths: { + body: "SMART_SUBSTITUTE", + newFunction: "SMART_SUBSTITUTE", + storeTest2: "SMART_SUBSTITUTE", + }, + dynamicBindingPathList: [ + { + key: "body", + }, + { + key: "newFunction", + }, + { + key: "storeTest2", + }, + ], + variables: [], + dependencyMap: { + body: ["newFunction", "storeTest2"], + }, + __evaluation__: { + errors: { + storeTest2: [], + newFunction: [], + body: [], + }, + }, + }, +}; + +describe("Test addDataTreeToContext method", () => { + const evalContext: EvalContext = {}; + beforeAll(() => { + addDataTreeToContext({ + EVAL_CONTEXT: evalContext, + dataTree: (dataTree as unknown) as DataTree, + isTriggerBased: true, + }); + addPlatformFunctionsToEvalContext(evalContext); + }); + + it("1. Assert platform actions are added", () => { + const frameworkActions = { + navigateTo: true, + showAlert: true, + showModal: true, + closeModal: true, + storeValue: true, + removeValue: true, + clearStore: true, + download: true, + copyToClipboard: true, + resetWidget: true, + setInterval: true, + clearInterval: true, + postWindowMessage: true, + }; + + for (const actionName of Object.keys(frameworkActions)) { + expect(evalContext).toHaveProperty(actionName); + expect(typeof evalContext[actionName]).toBe("function"); + } + }); + + it("2. Assert Api has run and clear method", () => { + expect(evalContext.Api2).toHaveProperty("run"); + expect(evalContext.Api2).toHaveProperty("clear"); + + expect(typeof evalContext.Api2.run).toBe("function"); + expect(typeof evalContext.Api2.clear).toBe("function"); + }); + + it("3. Assert input dataTree is not mutated", () => { + expect(typeof dataTree.Api2.run).not.toBe("function"); + }); +}); diff --git a/app/client/src/workers/Evaluation/__tests__/PromisifyAction.test.ts b/app/client/src/workers/Evaluation/__tests__/PromisifyAction.test.ts index e703e825bd..37dc8ce5c2 100644 --- a/app/client/src/workers/Evaluation/__tests__/PromisifyAction.test.ts +++ b/app/client/src/workers/Evaluation/__tests__/PromisifyAction.test.ts @@ -1,6 +1,7 @@ -import { createGlobalData } from "workers/Evaluation/evaluate"; +import { createEvaluationContext } from "workers/Evaluation/evaluate"; import _ from "lodash"; import { MessageType } from "utils/MessageUtil"; +import { addPlatformFunctionsToEvalContext } from "ce/workers/Evaluation/Actions"; jest.mock("../handlers/evalTree", () => { return { dataTreeEvaluator: { @@ -13,13 +14,15 @@ jest.mock("../handlers/evalTree", () => { describe("promise execution", () => { const postMessageMock = jest.fn(); const requestId = _.uniqueId("TEST_REQUEST"); - const dataTreeWithFunctions = createGlobalData({ + const evalContext = createEvaluationContext({ dataTree: {}, resolvedFunctions: {}, isTriggerBased: true, - context: { requestId }, + context: {}, }); + addPlatformFunctionsToEvalContext(evalContext); + const requestMessageCreator = (type: string, body: unknown) => ({ messageId: expect.stringContaining(`${type}_`), messageType: MessageType.REQUEST, @@ -37,12 +40,12 @@ describe("promise execution", () => { it("throws when allow async is not enabled", () => { self.ALLOW_ASYNC = false; self.IS_ASYNC = false; - expect(dataTreeWithFunctions.showAlert).toThrowError(); + expect(evalContext.showAlert).toThrowError(); expect(self.IS_ASYNC).toBe(true); expect(postMessageMock).not.toHaveBeenCalled(); }); it("sends an event from the worker", () => { - dataTreeWithFunctions.showAlert("test alert", "info"); + evalContext.showAlert("test alert", "info"); expect(postMessageMock).toBeCalledWith( requestMessageCreator("SHOW_ALERT", { data: { @@ -60,12 +63,8 @@ describe("promise execution", () => { }); it("returns a promise that resolves", async () => { postMessageMock.mockReset(); - const returnedPromise = dataTreeWithFunctions.showAlert( - "test alert", - "info", - ); + const returnedPromise = evalContext.showAlert("test alert", "info"); const requestArgs = postMessageMock.mock.calls[0][0]; - console.log(requestArgs); const messageId = requestArgs.messageId; self.dispatchEvent( @@ -91,10 +90,7 @@ describe("promise execution", () => { it("returns a promise that rejects", async () => { postMessageMock.mockReset(); - const returnedPromise = dataTreeWithFunctions.showAlert( - "test alert", - "info", - ); + const returnedPromise = evalContext.showAlert("test alert", "info"); const requestArgs = postMessageMock.mock.calls[0][0]; self.dispatchEvent( new MessageEvent("message", { @@ -113,10 +109,7 @@ describe("promise execution", () => { }); it("does not process till right event is triggered", async () => { postMessageMock.mockReset(); - const returnedPromise = dataTreeWithFunctions.showAlert( - "test alert", - "info", - ); + const returnedPromise = evalContext.showAlert("test alert", "info"); const requestArgs = postMessageMock.mock.calls[0][0]; const correctId = requestArgs.messageId; @@ -161,10 +154,7 @@ describe("promise execution", () => { }); it("same subRequestId is not accepted again", async () => { postMessageMock.mockReset(); - const returnedPromise = dataTreeWithFunctions.showAlert( - "test alert", - "info", - ); + const returnedPromise = evalContext.showAlert("test alert", "info"); const requestArgs = postMessageMock.mock.calls[0][0]; const messageId = requestArgs.messageId; diff --git a/app/client/src/workers/Evaluation/__tests__/errorModifier.test.ts b/app/client/src/workers/Evaluation/__tests__/errorModifier.test.ts new file mode 100644 index 0000000000..7dba10ec9d --- /dev/null +++ b/app/client/src/workers/Evaluation/__tests__/errorModifier.test.ts @@ -0,0 +1,163 @@ +import { DataTree } from "entities/DataTree/dataTreeFactory"; +import { errorModifier } from "../errorModifier"; + +describe("Test error modifier", () => { + const dataTree = ({ + Api2: { + run: {}, + clear: {}, + name: "Api2", + pluginType: "API", + config: {}, + dynamicBindingPathList: [ + { + key: "config.path", + }, + ], + responseMeta: { + isExecutionSuccess: false, + }, + ENTITY_TYPE: "ACTION", + isLoading: false, + bindingPaths: { + "config.path": "TEMPLATE", + "config.body": "SMART_SUBSTITUTE", + "config.pluginSpecifiedTemplates[1].value": "SMART_SUBSTITUTE", + "config.pluginSpecifiedTemplates[2].value.limitBased.limit.value": + "SMART_SUBSTITUTE", + }, + reactivePaths: { + data: "TEMPLATE", + isLoading: "TEMPLATE", + datasourceUrl: "TEMPLATE", + "config.path": "TEMPLATE", + "config.body": "SMART_SUBSTITUTE", + "config.pluginSpecifiedTemplates[1].value": "SMART_SUBSTITUTE", + }, + dependencyMap: { + "config.body": ["config.pluginSpecifiedTemplates[0].value"], + }, + logBlackList: {}, + datasourceUrl: "", + __evaluation__: { + errors: { + config: [], + }, + evaluatedValues: { + "config.path": "/users/undefined", + config: { + timeoutInMillisecond: 10000, + paginationType: "NONE", + path: "/users/test", + headers: [], + encodeParamsToggle: true, + queryParameters: [], + bodyFormData: [], + httpMethod: "GET", + selfReferencingDataPaths: [], + pluginSpecifiedTemplates: [ + { + value: true, + }, + ], + formData: { + apiContentType: "none", + }, + }, + }, + }, + }, + JSObject1: { + name: "JSObject1", + actionId: "637cda3b2f8e175c6f5269d5", + pluginType: "JS", + ENTITY_TYPE: "JSACTION", + body: + "export default {\n\tstoreTest2: () => {\n\t\tlet values = [\n\t\t\t\t\tstoreValue('val1', 'number 1'),\n\t\t\t\t\tstoreValue('val2', 'number 2'),\n\t\t\t\t\tstoreValue('val3', 'number 3'),\n\t\t\t\t\tstoreValue('val4', 'number 4')\n\t\t\t\t];\n\t\treturn Promise.all(values)\n\t\t\t.then(() => {\n\t\t\tshowAlert(JSON.stringify(appsmith.store))\n\t\t})\n\t\t\t.catch((err) => {\n\t\t\treturn showAlert('Could not store values in store ' + err.toString());\n\t\t})\n\t},\n\tnewFunction: function() {\n\t\tJSObject1.storeTest()\n\t}\n}", + meta: { + newFunction: { + arguments: [], + isAsync: false, + confirmBeforeExecute: false, + }, + storeTest2: { + arguments: [], + isAsync: true, + confirmBeforeExecute: false, + }, + }, + bindingPaths: { + body: "SMART_SUBSTITUTE", + newFunction: "SMART_SUBSTITUTE", + storeTest2: "SMART_SUBSTITUTE", + }, + reactivePaths: { + body: "SMART_SUBSTITUTE", + newFunction: "SMART_SUBSTITUTE", + storeTest2: "SMART_SUBSTITUTE", + }, + dynamicBindingPathList: [ + { + key: "body", + }, + { + key: "newFunction", + }, + { + key: "storeTest2", + }, + ], + variables: [], + dependencyMap: { + body: ["newFunction", "storeTest2"], + }, + __evaluation__: { + errors: { + storeTest2: [], + newFunction: [], + body: [], + }, + }, + }, + } as unknown) as DataTree; + + beforeAll(() => { + errorModifier.updateAsyncFunctions(dataTree); + }); + + it("TypeError for defined Api in sync field ", () => { + const error = new Error(); + error.name = "TypeError"; + error.message = "Api2.run is not a function"; + const result = errorModifier.run(error); + expect(result).toEqual( + "Found a reference to Api2.run() during evaluation. Sync fields cannot execute framework actions. Please remove any direct/indirect references to Api2.run() and try again.", + ); + }); + + it("TypeError for undefined Api in sync field ", () => { + const error = new Error(); + error.name = "TypeError"; + error.message = "Api1.run is not a function"; + const result = errorModifier.run(error); + expect(result).toEqual("TypeError: Api1.run is not a function"); + }); + + it("ReferenceError for platform function in sync field", () => { + const error = new Error(); + error.name = "ReferenceError"; + error.message = "storeValue is not defined"; + const result = errorModifier.run(error); + expect(result).toEqual( + "Found a reference to storeValue() during evaluation. Sync fields cannot execute framework actions. Please remove any direct/indirect references to storeValue() and try again.", + ); + }); + + it("ReferenceError for undefined function in sync field", () => { + const error = new Error(); + error.name = "ReferenceError"; + error.message = "storeValue2 is not defined"; + const result = errorModifier.run(error); + expect(result).toEqual("ReferenceError: storeValue2 is not defined"); + }); +}); diff --git a/app/client/src/workers/Evaluation/__tests__/evaluate.test.ts b/app/client/src/workers/Evaluation/__tests__/evaluate.test.ts index 0f9d4ede14..ab4b490796 100644 --- a/app/client/src/workers/Evaluation/__tests__/evaluate.test.ts +++ b/app/client/src/workers/Evaluation/__tests__/evaluate.test.ts @@ -1,7 +1,4 @@ -import evaluate, { - evaluateAsync, - isFunctionAsync, -} from "workers/Evaluation/evaluate"; +import evaluate, { evaluateAsync } from "workers/Evaluation/evaluate"; import { DataTree, DataTreeWidget, @@ -9,6 +6,8 @@ import { } from "entities/DataTree/dataTreeFactory"; import { RenderModes } from "constants/WidgetConstants"; import setupEvalEnv from "../handlers/setupEvalEnv"; +import { addPlatformFunctionsToEvalContext } from "@appsmith/workers/Evaluation/Actions"; +import { functionDeterminer } from "../functionDeterminer"; describe("evaluateSync", () => { const widget: DataTreeWidget = { @@ -251,7 +250,11 @@ describe("isFunctionAsync", () => { if (typeof testFunc === "string") { testFunc = eval(testFunc); } - const actual = isFunctionAsync(testFunc, {}, {}); + + functionDeterminer.setupEval({}, {}); + addPlatformFunctionsToEvalContext(self); + + const actual = functionDeterminer.isFunctionAsync(testFunc); expect(actual).toBe(testCase.expected); } }); diff --git a/app/client/src/workers/Evaluation/__tests__/timeout.test.ts b/app/client/src/workers/Evaluation/__tests__/timeout.test.ts index 57901760ee..08dfb72aa0 100644 --- a/app/client/src/workers/Evaluation/__tests__/timeout.test.ts +++ b/app/client/src/workers/Evaluation/__tests__/timeout.test.ts @@ -1,8 +1,8 @@ import { PluginType } from "entities/Action"; import { DataTree, ENTITY_TYPE } from "entities/DataTree/dataTreeFactory"; -import { createGlobalData } from "../evaluate"; -import "../TimeoutOverride"; +import { createEvaluationContext } from "../evaluate"; import overrideTimeout from "../TimeoutOverride"; +import { addPlatformFunctionsToEvalContext } from "@appsmith/workers/Evaluation/Actions"; describe("Expects appsmith setTimeout to pass the following criteria", () => { overrideTimeout(); @@ -107,13 +107,16 @@ describe("Expects appsmith setTimeout to pass the following criteria", () => { }, }; self.ALLOW_ASYNC = true; - const dataTreeWithFunctions = createGlobalData({ + const evalContext = createEvaluationContext({ dataTree, resolvedFunctions: {}, isTriggerBased: true, context: {}, }); - setTimeout(() => dataTreeWithFunctions.action1.run(), 1000); + + addPlatformFunctionsToEvalContext(evalContext); + + setTimeout(() => evalContext.action1.run(), 1000); jest.runAllTimers(); expect(self.postMessage).toBeCalled(); }); diff --git a/app/client/src/workers/Evaluation/errorModifier.ts b/app/client/src/workers/Evaluation/errorModifier.ts new file mode 100644 index 0000000000..d71978bc88 --- /dev/null +++ b/app/client/src/workers/Evaluation/errorModifier.ts @@ -0,0 +1,76 @@ +import { DataTree } from "entities/DataTree/dataTreeFactory"; +import { getAllAsyncFunctions } from "@appsmith/workers/Evaluation/Actions"; + +const UNDEFINED_ACTION_IN_SYNC_EVAL_ERROR = + "Found a reference to {{actionName}} during evaluation. Sync fields cannot execute framework actions. Please remove any direct/indirect references to {{actionName}} and try again."; + +const ErrorNameType = { + ReferenceError: "ReferenceError", + TypeError: "TypeError", +}; + +class ErrorModifier { + private errorNamesToScan = [ + ErrorNameType.ReferenceError, + ErrorNameType.TypeError, + ]; + // Note all regex below groups the async function name + + private asyncFunctionsNameMap: Record = {}; + + updateAsyncFunctions(dataTree: DataTree) { + this.asyncFunctionsNameMap = getAllAsyncFunctions(dataTree); + } + + run(error: Error) { + const errorMessage = getErrorMessage(error); + + if (!this.errorNamesToScan.includes(error.name)) return errorMessage; + + for (const asyncFunctionFullPath of Object.keys( + this.asyncFunctionsNameMap, + )) { + const functionNameWithWhiteSpace = " " + asyncFunctionFullPath + " "; + if (errorMessage.match(functionNameWithWhiteSpace)) { + return UNDEFINED_ACTION_IN_SYNC_EVAL_ERROR.replaceAll( + "{{actionName}}", + asyncFunctionFullPath + "()", + ); + } + } + + return errorMessage; + } +} + +export const errorModifier = new ErrorModifier(); + +export class FoundPromiseInSyncEvalError extends Error { + constructor() { + super(); + this.name = ""; + this.message = + "Found a Promise() during evaluation. Sync fields cannot execute asynchronous code."; + } +} + +export class ActionCalledInSyncFieldError extends Error { + constructor(actionName: string) { + super(actionName); + + if (!actionName) { + this.message = "Async function called in a sync field"; + return; + } + + this.name = ""; + this.message = UNDEFINED_ACTION_IN_SYNC_EVAL_ERROR.replaceAll( + "{{actionName}}", + actionName + "()", + ); + } +} + +export const getErrorMessage = (error: Error) => { + return error.name ? `${error.name}: ${error.message}` : error.message; +}; diff --git a/app/client/src/workers/Evaluation/evaluate.ts b/app/client/src/workers/Evaluation/evaluate.ts index 6e67922ae2..6487e9d7bd 100644 --- a/app/client/src/workers/Evaluation/evaluate.ts +++ b/app/client/src/workers/Evaluation/evaluate.ts @@ -6,8 +6,6 @@ import { } from "utils/DynamicBindingUtils"; import unescapeJS from "unescape-js"; import { LogObject, Severity } from "entities/AppsmithConsole"; -import { enhanceDataTreeWithFunctions } from "@appsmith/workers/Evaluation/Actions"; -import { isEmpty } from "lodash"; import { ActionDescription } from "@appsmith/entities/DataTree/actionTriggers"; import userLogs from "./UserLog"; import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; @@ -15,6 +13,11 @@ import { TriggerMeta } from "@appsmith/sagas/ActionExecution/ActionExecutionSaga import indirectEval from "./indirectEval"; import { DOM_APIS } from "./SetupDOM"; import { JSLibraries, libraryReservedIdentifiers } from "../common/JSLibrary"; +import { errorModifier, FoundPromiseInSyncEvalError } from "./errorModifier"; +import { + PLATFORM_FUNCTIONS, + addDataTreeToContext, +} from "@appsmith/workers/Evaluation/Actions"; export type EvalResult = { result: any; @@ -78,6 +81,7 @@ function resetWorkerGlobalScope() { continue; if (JSLibraries.find((lib) => lib.accessor.includes(key))) continue; if (libraryReservedIdentifiers[key]) continue; + if (PLATFORM_FUNCTIONS[key]) continue; try { // @ts-expect-error: Types are not available delete self[key]; @@ -115,17 +119,25 @@ export const getScriptToEval = ( }; const beginsWithLineBreakRegex = /^\s+|\s+$/; -export interface createGlobalDataArgs { + +export type EvalContext = Record; +type ResolvedFunctions = Record; +export interface createEvaluationContextArgs { dataTree: DataTree; - resolvedFunctions: Record; + resolvedFunctions: ResolvedFunctions; context?: EvaluateContext; - evalArguments?: Array; isTriggerBased: boolean; + evalArguments?: Array; // Whether not to add functions like "run", "clear" to entity in global data skipEntityFunctions?: boolean; } - -export const createGlobalData = (args: createGlobalDataArgs) => { +/** + * This method created an object with dataTree and appsmith's framework actions that needs to be added to worker global scope for the JS code evaluation to then consume it. + * + * Example: + * - For `eval("Table1.tableData")` code to work as expected, we define Table1.tableData in worker global scope and for that we use `createEvaluationContext` to get the object to set in global scope. + */ +export const createEvaluationContext = (args: createEvaluationContextArgs) => { const { context, dataTree, @@ -135,63 +147,54 @@ export const createGlobalData = (args: createGlobalDataArgs) => { skipEntityFunctions, } = args; - const GLOBAL_DATA: Record = {}; + const EVAL_CONTEXT: EvalContext = {}; ///// Adding callback data - GLOBAL_DATA.ARGUMENTS = evalArguments; + EVAL_CONTEXT.ARGUMENTS = evalArguments; //// Adding contextual data not part of data tree - GLOBAL_DATA.THIS_CONTEXT = {}; - if (context) { - if (context.thisContext) { - GLOBAL_DATA.THIS_CONTEXT = context.thisContext; - } - if (context.globalContext) { - Object.entries(context.globalContext).forEach(([key, value]) => { - GLOBAL_DATA[key] = value; - }); + EVAL_CONTEXT.THIS_CONTEXT = context?.thisContext || {}; + + if (context?.globalContext) { + Object.assign(EVAL_CONTEXT, context.globalContext); + } + + addDataTreeToContext({ + EVAL_CONTEXT, + dataTree, + skipEntityFunctions: !!skipEntityFunctions, + eventType: context?.eventType, + isTriggerBased, + }); + + assignJSFunctionsToContext(EVAL_CONTEXT, resolvedFunctions); + + return EVAL_CONTEXT; +}; + +export const assignJSFunctionsToContext = ( + EVAL_CONTEXT: EvalContext, + resolvedFunctions: ResolvedFunctions, +) => { + const jsObjectNames = Object.keys(resolvedFunctions || {}); + for (const jsObjectName of jsObjectNames) { + const resolvedObject = resolvedFunctions[jsObjectName]; + const jsObject = EVAL_CONTEXT[jsObjectName]; + const jsObjectFunction: Record> = {}; + if (!jsObject) continue; + for (const fnName of Object.keys(resolvedObject)) { + const fn = resolvedObject[fnName]; + if (typeof fn !== "function") continue; + // Investigate promisify of JSObject function confirmation + // Task: https://github.com/appsmithorg/appsmith/issues/13289 + // Previous implementation commented code: https://github.com/appsmithorg/appsmith/pull/18471 + const data = jsObject[fnName]?.data; + jsObjectFunction[fnName] = fn; + if (!!data) { + jsObjectFunction[fnName]["data"] = data; + } } + + EVAL_CONTEXT[jsObjectName] = Object.assign({}, jsObject, jsObjectFunction); } - if (isTriggerBased) { - //// Add internal functions to dataTree; - const dataTreeWithFunctions = enhanceDataTreeWithFunctions( - dataTree, - skipEntityFunctions, - context?.eventType, - ); - ///// Adding Data tree with functions - Object.assign(GLOBAL_DATA, dataTreeWithFunctions); - } else { - // Object.assign removes prototypes of the entity object making sure configs are not shown to user. - Object.assign(GLOBAL_DATA, dataTree); - } - if (!isEmpty(resolvedFunctions)) { - Object.keys(resolvedFunctions).forEach((datum: any) => { - const resolvedObject = resolvedFunctions[datum]; - Object.keys(resolvedObject).forEach((key: any) => { - const dataTreeKey = GLOBAL_DATA[datum]; - if (dataTreeKey) { - const data = dataTreeKey[key]?.data; - //do not remove we will be investigating this - //const isAsync = dataTreeKey?.meta[key]?.isAsync || false; - //const confirmBeforeExecute = dataTreeKey?.meta[key]?.confirmBeforeExecute || false; - dataTreeKey[key] = resolvedObject[key]; - // if (isAsync && confirmBeforeExecute) { - // dataTreeKey[key] = confirmationPromise.bind( - // {}, - // context?.requestId, - // resolvedObject[key], - // dataTreeKey.name + "." + key, - // ); - // } else { - // dataTreeKey[key] = resolvedObject[key]; - // } - if (!!data) { - dataTreeKey[key]["data"] = data; - } - } - }); - }); - } - return GLOBAL_DATA; }; export function sanitizeScript(js: string) { @@ -252,19 +255,22 @@ export default function evaluateSync( userLogs.resetLogs(); } /**** Setting the eval context ****/ - const GLOBAL_DATA: Record = createGlobalData({ + const evalContext: EvalContext = createEvaluationContext({ dataTree, resolvedFunctions, - isTriggerBased: isJSCollection, context, evalArguments, + isTriggerBased: isJSCollection, }); - GLOBAL_DATA.ALLOW_ASYNC = false; + + evalContext.ALLOW_ASYNC = false; + const { script } = getUserScriptToEvaluate( userScript, false, evalArguments, ); + // If nothing is present to evaluate, return instead of evaluating if (!script.length) { return { @@ -277,19 +283,20 @@ export default function evaluateSync( // Set it to self so that the eval function can have access to it // as global data. This is what enables access all appsmith // entity properties from the global context - for (const entity in GLOBAL_DATA) { - // @ts-expect-error: Types are not available - self[entity] = GLOBAL_DATA[entity]; - } + Object.assign(self, evalContext); try { result = indirectEval(script); + if (result instanceof Promise) { + /** + * If a promise is returned in sync field then show the error to help understand sync field doesn't await to resolve promise. + * NOTE: Awaiting for promise will make sync field evaluation slower. + */ + throw new FoundPromiseInSyncEvalError(); + } } catch (error) { - const errorMessage = `${(error as Error).name}: ${ - (error as Error).message - }`; errors.push({ - errorMessage: errorMessage, + errorMessage: errorModifier.run(error as Error), severity: Severity.ERROR, raw: script, errorType: PropertyEvaluationErrorType.PARSE, @@ -297,9 +304,11 @@ export default function evaluateSync( }); } finally { if (!skipLogsOperations) logs = userLogs.flushLogs(); - for (const entity in GLOBAL_DATA) { - // @ts-expect-error: Types are not available - delete self[entity]; + for (const entityName in evalContext) { + if (evalContext.hasOwnProperty(entityName)) { + // @ts-expect-error: Types are not available + delete self[entityName]; + } } } @@ -325,22 +334,20 @@ export async function evaluateAsync( eventType: context?.eventType, triggerMeta: context?.triggerMeta, }); - const GLOBAL_DATA: Record = createGlobalData({ + const evalContext: EvalContext = createEvaluationContext({ dataTree, resolvedFunctions, - isTriggerBased: true, context, evalArguments, + isTriggerBased: true, }); const { script } = getUserScriptToEvaluate(userScript, true, evalArguments); - GLOBAL_DATA.ALLOW_ASYNC = true; + evalContext.ALLOW_ASYNC = true; + // Set it to self so that the eval function can have access to it // as global data. This is what enables access all appsmith // entity properties from the global context - Object.keys(GLOBAL_DATA).forEach((key) => { - // @ts-expect-error: Types are not available - self[key] = GLOBAL_DATA[key]; - }); + Object.assign(self, evalContext); try { result = await indirectEval(script); @@ -370,72 +377,3 @@ export async function evaluateAsync( } })(); } - -export function isFunctionAsync( - userFunction: unknown, - dataTree: DataTree, - resolvedFunctions: Record, - logs: unknown[] = [], -) { - return (function() { - /**** Setting the eval context ****/ - const GLOBAL_DATA: Record = { - ALLOW_ASYNC: false, - IS_ASYNC: false, - }; - //// Add internal functions to dataTree; - const dataTreeWithFunctions = enhanceDataTreeWithFunctions(dataTree); - ///// Adding Data tree with functions - Object.keys(dataTreeWithFunctions).forEach((datum) => { - GLOBAL_DATA[datum] = dataTreeWithFunctions[datum]; - }); - if (!isEmpty(resolvedFunctions)) { - Object.keys(resolvedFunctions).forEach((datum: any) => { - const resolvedObject = resolvedFunctions[datum]; - Object.keys(resolvedObject).forEach((key: any) => { - const dataTreeKey = GLOBAL_DATA[datum]; - if (dataTreeKey) { - const data = dataTreeKey[key]?.data; - dataTreeKey[key] = resolvedObject[key]; - if (!!data) { - dataTreeKey[key].data = data; - } - } - }); - }); - } - // Set it to self so that the eval function can have access to it - // as global data. This is what enables access all appsmith - // entity properties from the global context - Object.keys(GLOBAL_DATA).forEach((key) => { - // @ts-expect-error: Types are not available - self[key] = GLOBAL_DATA[key]; - }); - try { - if (typeof userFunction === "function") { - if (userFunction.constructor.name === "AsyncFunction") { - // functions declared with an async keyword - self.IS_ASYNC = true; - } else { - const returnValue = userFunction(); - if (!!returnValue && returnValue instanceof Promise) { - self.IS_ASYNC = true; - } - if (self.TRIGGER_COLLECTOR.length) { - self.IS_ASYNC = true; - } - } - } - } catch (e) { - // We do not want to throw errors for internal operations, to users. - // logLevel should help us in debugging this. - logs.push({ error: "Error when determining async function" + e }); - } - const isAsync = !!self.IS_ASYNC; - for (const entity in GLOBAL_DATA) { - // @ts-expect-error: Types are not available - delete self[entity]; - } - return isAsync; - })(); -} diff --git a/app/client/src/workers/Evaluation/functionDeterminer.ts b/app/client/src/workers/Evaluation/functionDeterminer.ts new file mode 100644 index 0000000000..8844d7521e --- /dev/null +++ b/app/client/src/workers/Evaluation/functionDeterminer.ts @@ -0,0 +1,72 @@ +import { addDataTreeToContext } from "@appsmith/workers/Evaluation/Actions"; +import { EvalContext, assignJSFunctionsToContext } from "./evaluate"; +import { DataTree } from "entities/DataTree/dataTreeFactory"; + +class FunctionDeterminer { + private evalContext: EvalContext = {}; + + setupEval(dataTree: DataTree, resolvedFunctions: Record) { + /**** Setting the eval context ****/ + const evalContext: EvalContext = { + ALLOW_ASYNC: false, + IS_ASYNC: false, + }; + + addDataTreeToContext({ + dataTree, + EVAL_CONTEXT: evalContext, + isTriggerBased: true, + }); + + assignJSFunctionsToContext(evalContext, resolvedFunctions); + + // Set it to self so that the eval function can have access to it + // as global data. This is what enables access all appsmith + // entity properties from the global context + Object.assign(self, evalContext); + + this.evalContext = evalContext; + } + + setOffEval() { + for (const entityName in this.evalContext) { + if (this.evalContext.hasOwnProperty(entityName)) { + // @ts-expect-error: Types are not available + delete self[entityName]; + } + } + } + + isFunctionAsync(userFunction: unknown, logs: unknown[] = []) { + self.TRIGGER_COLLECTOR = []; + self.IS_ASYNC = false; + + return (function() { + try { + if (typeof userFunction === "function") { + if (userFunction.constructor.name === "AsyncFunction") { + // functions declared with an async keyword + self.IS_ASYNC = true; + } else { + const returnValue = userFunction(); + if (!!returnValue && returnValue instanceof Promise) { + self.IS_ASYNC = true; + } + if (self.TRIGGER_COLLECTOR.length) { + self.IS_ASYNC = true; + } + } + } + } catch (e) { + // We do not want to throw errors for internal operations, to users. + // logLevel should help us in debugging this. + logs.push({ error: "Error when determining async function" + e }); + } + const isAsync = !!self.IS_ASYNC; + + return isAsync; + })(); + } +} + +export const functionDeterminer = new FunctionDeterminer(); diff --git a/app/client/src/workers/Evaluation/handlers/setupEvalEnv.ts b/app/client/src/workers/Evaluation/handlers/setupEvalEnv.ts index 41a65722a6..4ed9281e3b 100644 --- a/app/client/src/workers/Evaluation/handlers/setupEvalEnv.ts +++ b/app/client/src/workers/Evaluation/handlers/setupEvalEnv.ts @@ -5,6 +5,7 @@ import setupDOM from "../SetupDOM"; import overrideTimeout from "../TimeoutOverride"; import { EvalWorkerSyncRequest } from "../types"; import userLogs from "../UserLog"; +import { addPlatformFunctionsToEvalContext } from "@appsmith/workers/Evaluation/Actions"; export default function() { const libraries = resetJSLibraries(); @@ -24,6 +25,7 @@ export default function() { overrideTimeout(); interceptAndOverrideHttpRequest(); setupDOM(); + addPlatformFunctionsToEvalContext(self); return true; } diff --git a/app/client/src/workers/Linting/utils.ts b/app/client/src/workers/Linting/utils.ts index 836bbf0abe..23f696800f 100644 --- a/app/client/src/workers/Linting/utils.ts +++ b/app/client/src/workers/Linting/utils.ts @@ -35,7 +35,7 @@ import { import { getDynamicBindings } from "utils/DynamicBindingUtils"; import { - createGlobalData, + createEvaluationContext, EvaluationScripts, EvaluationScriptType, getScriptToEval, @@ -53,17 +53,30 @@ import { LintErrors } from "reducers/lintingReducers/lintErrorsReducers"; import { Severity } from "entities/AppsmithConsole"; import { JSLibraries } from "workers/common/JSLibrary"; import { MessageType, sendMessage } from "utils/MessageUtil"; +import { addPlatformFunctionsToEvalContext } from "@appsmith/workers/Evaluation/Actions"; export function getlintErrorsFromTree( pathsToLint: string[], unEvalTree: DataTree, ): LintErrors { const lintTreeErrors: LintErrors = {}; - const GLOBAL_DATA_WITHOUT_FUNCTIONS = createGlobalData({ + + const evalContext = createEvaluationContext({ dataTree: unEvalTree, resolvedFunctions: {}, isTriggerBased: false, + skipEntityFunctions: true, }); + + addPlatformFunctionsToEvalContext(evalContext); + + const evalContextWithOutFunctions = createEvaluationContext({ + dataTree: unEvalTree, + resolvedFunctions: {}, + isTriggerBased: true, + skipEntityFunctions: true, + }); + // trigger paths const triggerPaths = new Set(); // Certain paths, like JS Object's body are binding paths where appsmith functions are needed in the global data @@ -91,7 +104,7 @@ export function getlintErrorsFromTree( unEvalPropertyValue, entity, fullPropertyPath, - GLOBAL_DATA_WITHOUT_FUNCTIONS, + evalContextWithOutFunctions, ); set(lintTreeErrors, `["${fullPropertyPath}"]`, lintErrors); }); @@ -99,12 +112,6 @@ export function getlintErrorsFromTree( if (triggerPaths.size || bindingPathsRequiringFunctions.size) { // we only create GLOBAL_DATA_WITH_FUNCTIONS if there are paths requiring it // In trigger based fields, functions such as showAlert, storeValue, etc need to be added to the global data - const GLOBAL_DATA_WITH_FUNCTIONS = createGlobalData({ - dataTree: unEvalTree, - resolvedFunctions: {}, - isTriggerBased: true, - skipEntityFunctions: true, - }); // lint binding paths that need GLOBAL_DATA_WITH_FUNCTIONS if (bindingPathsRequiringFunctions.size) { @@ -121,7 +128,7 @@ export function getlintErrorsFromTree( unEvalPropertyValue, entity, fullPropertyPath, - GLOBAL_DATA_WITH_FUNCTIONS, + evalContext, ); set(lintTreeErrors, `["${fullPropertyPath}"]`, lintErrors); }); @@ -141,7 +148,7 @@ export function getlintErrorsFromTree( const lintErrors = lintTriggerPath( unEvalPropertyValue, entity, - GLOBAL_DATA_WITH_FUNCTIONS, + evalContext, ); set(lintTreeErrors, `["${triggerPath}"]`, lintErrors); }); @@ -155,7 +162,7 @@ function lintBindingPath( dynamicBinding: string, entity: DataTreeEntity, fullPropertyPath: string, - globalData: ReturnType, + globalData: ReturnType, ) { let lintErrors: LintError[] = []; @@ -214,7 +221,7 @@ function lintBindingPath( function lintTriggerPath( userScript: string, entity: DataTreeEntity, - globalData: ReturnType, + globalData: ReturnType, ) { const { jsSnippets } = getDynamicBindings(userScript, entity); const script = getScriptToEval(jsSnippets[0], EvaluationScriptType.TRIGGERS); diff --git a/app/client/src/workers/common/DataTreeEvaluator/index.ts b/app/client/src/workers/common/DataTreeEvaluator/index.ts index 4cbe5420ed..d2a0012951 100644 --- a/app/client/src/workers/common/DataTreeEvaluator/index.ts +++ b/app/client/src/workers/common/DataTreeEvaluator/index.ts @@ -98,6 +98,7 @@ import { validateActionProperty, validateAndParseWidgetProperty, } from "./validationUtils"; +import { errorModifier } from "workers/Evaluation/errorModifier"; type SortedDependencies = Array; export type EvalProps = { @@ -661,6 +662,9 @@ export default class DataTreeEvaluator { evalMetaUpdates: EvalMetaUpdates; } { const tree = klona(oldUnevalTree); + + errorModifier.updateAsyncFunctions(tree); + const evalMetaUpdates: EvalMetaUpdates = []; try { const evaluatedTree = sortedDependencies.reduce( @@ -1033,7 +1037,7 @@ export default class DataTreeEvaluator { js: string, data: DataTree, resolvedFunctions: Record, - createGlobalData: boolean, + isJSObject: boolean, contextData?: EvaluateContext, callbackData?: Array, skipUserLogsOperations = false, @@ -1043,7 +1047,7 @@ export default class DataTreeEvaluator { js, data, resolvedFunctions, - createGlobalData, + isJSObject, contextData, callbackData, skipUserLogsOperations,