## Description Currently, the error messages in the toasts contain the names of the errors (like Reference error, uncaught promise rejection error, etc.,). These are unhelpful to users (especially if they are not programmers) and do not convey any actionable feedback to the user who is trying to fix and debug the app. You can see it in action [here](https://www.loom.com/share/e946f779dd1147f38eec1588a84821b2). This PR aims to remove the names of these errors from the toast messages so that the action to fix them can be highlighted. We are retaining the names of the errors for the console, so that programmers using the console, can get a full context of the error. Fixes #22318 Media Previous behavior - https://www.loom.com/share/e946f779dd1147f38eec1588a84821b2 Current behavior - https://www.loom.com/share/83fd8d08ed114f8b830acadb9894e4b1 ## Type of change - Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Manual - Jest - Cypress ### Test Plan - Reference error check - Uncaught promise rejection check ### Issues raised during DP testing - none ## Checklist: ### Dev activity - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] PR is being merged under a feature flag ### QA activity: - [ ] Test plan has been approved by relevant developers - [ ] Test plan has been peer-reviewed by QA - [x] Cypress test cases have been added and approved by either SDET or manual QA - [ ] Organized project review call with relevant stakeholders after Round 1/2 of QA - [x] Added Test Plan Approved label after reviewing all Cypress test
366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
/* eslint-disable no-console */
|
|
import type { DataTree } from "entities/DataTree/dataTreeFactory";
|
|
import type { EvaluationError } from "utils/DynamicBindingUtils";
|
|
import { PropertyEvaluationErrorType } from "utils/DynamicBindingUtils";
|
|
import unescapeJS from "unescape-js";
|
|
import { Severity } from "entities/AppsmithConsole";
|
|
import type { EventType } from "constants/AppsmithActionConstants/ActionConstants";
|
|
import type { TriggerMeta } from "@appsmith/sagas/ActionExecution/ActionExecutionSagas";
|
|
import indirectEval from "./indirectEval";
|
|
import DOM_APIS from "./domApis";
|
|
import { JSLibraries, libraryReservedIdentifiers } from "../common/JSLibrary";
|
|
import { errorModifier, FoundPromiseInSyncEvalError } from "./errorModifier";
|
|
import { addDataTreeToContext } from "@appsmith/workers/Evaluation/Actions";
|
|
import log from "loglevel";
|
|
import * as Sentry from "@sentry/react";
|
|
|
|
export type EvalResult = {
|
|
result: any;
|
|
errors: EvaluationError[];
|
|
};
|
|
|
|
export enum EvaluationScriptType {
|
|
EXPRESSION = "EXPRESSION",
|
|
ANONYMOUS_FUNCTION = "ANONYMOUS_FUNCTION",
|
|
ASYNC_ANONYMOUS_FUNCTION = "ASYNC_ANONYMOUS_FUNCTION",
|
|
TRIGGERS = "TRIGGERS",
|
|
OBJECT_PROPERTY = "OBJECT_PROPERTY",
|
|
}
|
|
|
|
export const ScriptTemplate = "<<string>>";
|
|
|
|
export const EvaluationScripts: Record<EvaluationScriptType, string> = {
|
|
[EvaluationScriptType.EXPRESSION]: `
|
|
function $$closedFn () {
|
|
const $$result = ${ScriptTemplate}
|
|
return $$result
|
|
}
|
|
$$closedFn.call(THIS_CONTEXT)
|
|
`,
|
|
[EvaluationScriptType.ANONYMOUS_FUNCTION]: `
|
|
function $$closedFn (script) {
|
|
const $$userFunction = script;
|
|
const $$result = $$userFunction?.apply(THIS_CONTEXT, ARGUMENTS);
|
|
return $$result
|
|
}
|
|
$$closedFn(${ScriptTemplate})
|
|
`,
|
|
[EvaluationScriptType.ASYNC_ANONYMOUS_FUNCTION]: `
|
|
async function $$closedFn (script) {
|
|
const $$userFunction = script;
|
|
const $$result = $$userFunction?.apply(THIS_CONTEXT, ARGUMENTS);
|
|
return await $$result;
|
|
}
|
|
$$closedFn(${ScriptTemplate})
|
|
`,
|
|
[EvaluationScriptType.TRIGGERS]: `
|
|
async function $$closedFn () {
|
|
const $$result = ${ScriptTemplate};
|
|
return await $$result
|
|
}
|
|
$$closedFn.call(THIS_CONTEXT)
|
|
`,
|
|
[EvaluationScriptType.OBJECT_PROPERTY]: `
|
|
function $$closedFn () {
|
|
const $$result = {${ScriptTemplate}}
|
|
return $$result
|
|
}
|
|
$$closedFn.call(THIS_CONTEXT)
|
|
`,
|
|
};
|
|
|
|
const topLevelWorkerAPIs = Object.keys(self).reduce((acc, key: string) => {
|
|
acc[key] = true;
|
|
return acc;
|
|
}, {} as any);
|
|
|
|
function resetWorkerGlobalScope() {
|
|
for (const key of Object.keys(self)) {
|
|
if (topLevelWorkerAPIs[key] || DOM_APIS[key]) continue;
|
|
//TODO: Remove this once we have a better way to handle this
|
|
if (["evaluationVersion", "window", "document", "location"].includes(key))
|
|
continue;
|
|
if (JSLibraries.find((lib) => lib.accessor.includes(key))) continue;
|
|
if (libraryReservedIdentifiers[key]) continue;
|
|
try {
|
|
// @ts-expect-error: Types are not available
|
|
delete self[key];
|
|
} catch (e) {
|
|
// @ts-expect-error: Types are not available
|
|
self[key] = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const getScriptType = (
|
|
evalArgumentsExist = false,
|
|
isTriggerBased = false,
|
|
): EvaluationScriptType => {
|
|
let scriptType = EvaluationScriptType.EXPRESSION;
|
|
if (evalArgumentsExist && isTriggerBased) {
|
|
scriptType = EvaluationScriptType.ASYNC_ANONYMOUS_FUNCTION;
|
|
} else if (evalArgumentsExist && !isTriggerBased) {
|
|
scriptType = EvaluationScriptType.ANONYMOUS_FUNCTION;
|
|
} else if (isTriggerBased && !evalArgumentsExist) {
|
|
scriptType = EvaluationScriptType.TRIGGERS;
|
|
}
|
|
return scriptType;
|
|
};
|
|
|
|
export const additionalLibrariesNames: string[] = [];
|
|
|
|
export const getScriptToEval = (
|
|
userScript: string,
|
|
type: EvaluationScriptType,
|
|
): string => {
|
|
// Using replace here would break scripts with replacement patterns (ex: $&, $$)
|
|
const buffer = EvaluationScripts[type].split(ScriptTemplate);
|
|
return `${buffer[0]}${userScript}${buffer[1]}`;
|
|
};
|
|
|
|
const beginsWithLineBreakRegex = /^\s+|\s+$/;
|
|
|
|
export type EvalContext = Record<string, any>;
|
|
export interface createEvaluationContextArgs {
|
|
dataTree: DataTree;
|
|
context?: EvaluateContext;
|
|
isTriggerBased: boolean;
|
|
evalArguments?: Array<unknown>;
|
|
/*
|
|
Whether to remove functions like "run", "clear" from entities in global context
|
|
use case => To show lint warning when Api.run is used in a function bound to a data field (Eg. Button.text)
|
|
*/
|
|
removeEntityFunctions?: boolean;
|
|
}
|
|
/**
|
|
* 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,
|
|
evalArguments,
|
|
isTriggerBased,
|
|
removeEntityFunctions,
|
|
} = args;
|
|
|
|
const EVAL_CONTEXT: EvalContext = {};
|
|
///// Adding callback data
|
|
EVAL_CONTEXT.ARGUMENTS = evalArguments;
|
|
//// Adding contextual data not part of data tree
|
|
EVAL_CONTEXT.THIS_CONTEXT = context?.thisContext || {};
|
|
|
|
if (context?.globalContext) {
|
|
Object.assign(EVAL_CONTEXT, context.globalContext);
|
|
}
|
|
|
|
addDataTreeToContext({
|
|
EVAL_CONTEXT,
|
|
dataTree,
|
|
removeEntityFunctions: !!removeEntityFunctions,
|
|
isTriggerBased,
|
|
});
|
|
|
|
return EVAL_CONTEXT;
|
|
};
|
|
|
|
export function sanitizeScript(js: string) {
|
|
// We remove any line breaks from the beginning of the script because that
|
|
// makes the final function invalid. We also unescape any escaped characters
|
|
// so that eval can happen
|
|
const trimmedJS = js.replace(beginsWithLineBreakRegex, "");
|
|
return self.evaluationVersion > 1 ? trimmedJS : unescapeJS(trimmedJS);
|
|
}
|
|
|
|
/** Define a context just for this script
|
|
* thisContext will define it on the `this`
|
|
* globalContext will define it globally
|
|
* requestId is used for completing promises
|
|
*/
|
|
export type EvaluateContext = {
|
|
thisContext?: Record<string, any>;
|
|
globalContext?: Record<string, any>;
|
|
requestId?: string;
|
|
eventType?: EventType;
|
|
triggerMeta?: TriggerMeta;
|
|
};
|
|
|
|
export const getUserScriptToEvaluate = (
|
|
userScript: string,
|
|
isTriggerBased: boolean,
|
|
evalArguments?: Array<any>,
|
|
) => {
|
|
const unescapedJS = sanitizeScript(userScript);
|
|
// If nothing is present to evaluate, return
|
|
if (!unescapedJS.length) {
|
|
return {
|
|
script: "",
|
|
};
|
|
}
|
|
const scriptType = getScriptType(!!evalArguments, isTriggerBased);
|
|
const script = getScriptToEval(unescapedJS, scriptType);
|
|
return { script };
|
|
};
|
|
|
|
export function setEvalContext({
|
|
context,
|
|
dataTree,
|
|
evalArguments,
|
|
isDataField,
|
|
isTriggerBased,
|
|
}: {
|
|
context?: EvaluateContext;
|
|
dataTree: DataTree;
|
|
evalArguments?: Array<any>;
|
|
isDataField: boolean;
|
|
isTriggerBased: boolean;
|
|
}) {
|
|
self["$isDataField"] = isDataField;
|
|
|
|
const evalContext = createEvaluationContext({
|
|
dataTree,
|
|
context,
|
|
evalArguments,
|
|
isTriggerBased,
|
|
});
|
|
|
|
Object.assign(self, evalContext);
|
|
}
|
|
|
|
export default function evaluateSync(
|
|
userScript: string,
|
|
dataTree: DataTree,
|
|
isJSCollection: boolean,
|
|
context?: EvaluateContext,
|
|
evalArguments?: Array<any>,
|
|
): EvalResult {
|
|
return (function () {
|
|
resetWorkerGlobalScope();
|
|
const errors: EvaluationError[] = [];
|
|
let result;
|
|
|
|
const { script } = getUserScriptToEvaluate(
|
|
userScript,
|
|
false,
|
|
evalArguments,
|
|
);
|
|
|
|
// If nothing is present to evaluate, return instead of evaluating
|
|
if (!script.length) {
|
|
return {
|
|
errors: [],
|
|
result: undefined,
|
|
triggers: [],
|
|
};
|
|
}
|
|
|
|
setEvalContext({
|
|
dataTree,
|
|
isDataField: true,
|
|
isTriggerBased: isJSCollection,
|
|
context,
|
|
evalArguments,
|
|
});
|
|
|
|
try {
|
|
result = indirectEval(script);
|
|
if (result instanceof Promise) {
|
|
/**
|
|
* If a promise is returned in data field then show the error to help understand data field doesn't await to resolve promise.
|
|
* NOTE: Awaiting for promise will make data field evaluation slower.
|
|
*/
|
|
throw new FoundPromiseInSyncEvalError();
|
|
}
|
|
} catch (error) {
|
|
const { errorCategory, errorMessage } = errorModifier.run(error as Error);
|
|
errors.push({
|
|
errorMessage,
|
|
severity: Severity.ERROR,
|
|
raw: script,
|
|
errorType: PropertyEvaluationErrorType.PARSE,
|
|
originalBinding: userScript,
|
|
kind: errorCategory && {
|
|
category: errorCategory,
|
|
rootcause: "",
|
|
},
|
|
});
|
|
} finally {
|
|
self["$isDataField"] = false;
|
|
}
|
|
return { result, errors };
|
|
})();
|
|
}
|
|
|
|
export async function evaluateAsync(
|
|
userScript: string,
|
|
dataTree: DataTree,
|
|
context?: EvaluateContext,
|
|
evalArguments?: Array<any>,
|
|
) {
|
|
return (async function () {
|
|
resetWorkerGlobalScope();
|
|
const errors: EvaluationError[] = [];
|
|
let result;
|
|
|
|
const { script } = getUserScriptToEvaluate(userScript, true, evalArguments);
|
|
|
|
setEvalContext({
|
|
dataTree,
|
|
isDataField: false,
|
|
isTriggerBased: true,
|
|
context,
|
|
evalArguments,
|
|
});
|
|
|
|
try {
|
|
result = await indirectEval(script);
|
|
} catch (e: any) {
|
|
let errorMessage;
|
|
if (e instanceof Error) {
|
|
errorMessage = { name: e.name, message: e.message };
|
|
} else {
|
|
// this covers cases where any primitive value is thrown
|
|
// for eg., throw "error";
|
|
// These types of errors might have a name/message but are not an instance of Error class
|
|
const message = convertAllDataTypesToString(e);
|
|
errorMessage = {
|
|
name: e?.name || "Error",
|
|
message: e?.message || message,
|
|
};
|
|
}
|
|
errors.push({
|
|
errorMessage: errorMessage,
|
|
severity: Severity.ERROR,
|
|
raw: script,
|
|
errorType: PropertyEvaluationErrorType.PARSE,
|
|
originalBinding: userScript,
|
|
});
|
|
} finally {
|
|
return {
|
|
result,
|
|
errors,
|
|
};
|
|
}
|
|
})();
|
|
}
|
|
|
|
export function convertAllDataTypesToString(e: any) {
|
|
// Functions do not get converted properly with JSON.stringify
|
|
// So using String fot functions
|
|
// Types like [], {} get converted to "" using String
|
|
// hence using JSON.stringify for the rest
|
|
if (typeof e === "function") {
|
|
return String(e);
|
|
} else {
|
|
try {
|
|
return JSON.stringify(e);
|
|
} catch (error) {
|
|
log.debug(error);
|
|
Sentry.captureException(error);
|
|
}
|
|
}
|
|
}
|