feat: enabled setTimeout/clearTimeout APIs (#17445)

This commit is contained in:
arunvjn 2022-10-17 22:40:17 +05:30 committed by GitHub
parent 06f1b23625
commit 28138c18c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 756 additions and 328 deletions

View File

@ -129,6 +129,25 @@ describe("Button Widget Functionality", function() {
cy.get(widgetsPage.apiCallToast).should("have.text", "Success");
});
it("5. Toggle JS - Button-Call-SetTimeout Validation", function() {
//creating a query and calling it from the onClickAction of the button widget.
// Creating a mock query
cy.testJsontext(
"onclick",
"{{setTimeout(() => showAlert('Hello from setTimeout after 3 seconds'), 3000)}}",
);
cy.PublishtheApp();
// Clicking the button to verify the success message
cy.get(publishPage.buttonWidget).click();
cy.wait(3000);
cy.get(widgetsPage.apiCallToast).should(
"have.text",
"Hello from setTimeout after 3 seconds",
);
});
afterEach(() => {
cy.goToEditFromPublish();
});

View File

@ -0,0 +1,218 @@
import { ObjectsRegistry } from "../../../../support/Objects/Registry";
const jsEditor = ObjectsRegistry.JSEditor;
const agHelper = ObjectsRegistry.AggregateHelper;
const locators = ObjectsRegistry.CommonLocators;
const apiPage = ObjectsRegistry.ApiPage;
const deployMode = ObjectsRegistry.DeployMode;
describe("Tests setTimeout API", function() {
it("Executes showAlert after 3 seconds and uses default value", () => {
jsEditor.CreateJSObject(
`export default {
myVar1: [],
myVar2: {},
myFun1: (x = "default") => {
setTimeout(() => {
showAlert("Hello world - " + x);
}, 3000);
}
}`,
{
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
prettify: true,
},
);
agHelper.Sleep(2000);
jsEditor.RunJSObj();
agHelper.Sleep(3000);
agHelper.AssertContains("Hello world - default", "exist");
});
it("Executes all three alerts in parallel after 3 seconds", () => {
jsEditor.CreateJSObject(
`export default {
myVar1: [],
myVar2: {},
myFun1: (x = "default") => {
setTimeout(() => {
showAlert("Hello world - " + x);
}, 3000);
},
myFun2: () => {
this.myFun1(1)
this.myFun1(2)
this.myFun1(3)
}
}`,
{
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
prettify: true,
},
);
agHelper.Sleep(2000);
jsEditor.SelectFunctionDropdown("myFun2");
jsEditor.RunJSObj();
agHelper.Sleep(3000);
agHelper.AssertContains("Hello world - 1", "exist");
agHelper.AssertContains("Hello world - 2", "exist");
agHelper.AssertContains("Hello world - 3", "exist");
});
it("Resolves promise after 3 seconds and shows alert", () => {
jsEditor.CreateJSObject(
`export default {
myVar1: [],
myVar2: {},
myFun1: (x) => {
new Promise((res, rej) => setTimeout(() => res("resolved"), 3000)).then((res) => {
showAlert(res);
});
},
}`,
{
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
prettify: true,
},
);
agHelper.Sleep(2000);
jsEditor.RunJSObj();
agHelper.Sleep(3000);
agHelper.AssertContains("resolved");
});
it("verifies code execution order when using setTimeout", () => {
jsEditor.CreateJSObject(
`export default {
myVar1: [],
myVar2: {},
myFun1: (x) => {
console.log("Hey there!");
setTimeout(() => console.log("Working!"), 3000);
console.log("Bye!");
},
}`,
{
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
prettify: true,
},
);
agHelper.Sleep(2000);
agHelper.GetNClick(locators._debuggerIcon);
agHelper.GetNClick(jsEditor._logsTab);
jsEditor.RunJSObj();
agHelper.GetNAssertContains(locators._debuggerLogMessage, "Hey there!");
agHelper.GetNAssertContains(locators._debuggerLogMessage, "Bye!");
agHelper.GetNAssertContains(
locators._debuggerLogMessage,
"Working!",
"not.exist",
100,
);
agHelper.Sleep(3000);
agHelper.GetNAssertContains(locators._debuggerLogMessage, "Working!");
});
it("Resolves promise after 3 seconds and shows alert", () => {
jsEditor.CreateJSObject(
`export default {
myVar1: [],
myVar2: {},
myFun1: (x) => {
new Promise((res, rej) => setTimeout(() => res("resolved"), 3000)).then((res) => {
showAlert(res);
});
},
}`,
{
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
prettify: true,
},
);
agHelper.Sleep(2000);
jsEditor.RunJSObj();
agHelper.Sleep(3000);
agHelper.AssertContains("resolved");
});
it("Access to args passed into success/error callback functions in API.run when using setTimeout", () => {
apiPage.CreateAndFillApi("https://mock-api.appsmith.com/users");
jsEditor.CreateJSObject(
`export default {
myVar1: [],
myVar2: {},
myFun1: (x) => {
Api1.run((res) => {
setTimeout(() => {
showAlert(res.users[0].name);
}, 3000);
}, (error) => {
console.log(error);
});
},
myFun2: (x) => {
Api1.run().then((res) => {
setTimeout(() => {
showAlert(res.users[0].name);
}, 3000);
});
}
}`,
{
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
prettify: true,
},
);
jsEditor.RenameJSObjFromPane("Timeouts");
agHelper.Sleep(2000);
jsEditor.RunJSObj();
agHelper.Sleep(3000);
agHelper.AssertContains("Barty Crouch");
agHelper.Sleep(2000);
jsEditor.SelectFunctionDropdown("myFun2");
jsEditor.RunJSObj();
agHelper.Sleep(3000);
agHelper.AssertContains("Barty Crouch");
});
it("Verifies whether setTimeout executes on page load", () => {
apiPage.CreateAndFillApi("https://mock-api.appsmith.com/users");
jsEditor.CreateJSObject(
`export default {
myVar1: [],
myVar2: {},
myFun1: (x) => {
setTimeout(() => {
Api1.run().then(() => showAlert("Success!"));
Timeouts.myFun2();
}, 3000)
},
}`,
{
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
prettify: true,
},
);
jsEditor.EnableDisableAsyncFuncSettings("myFun1", true, false);
deployMode.DeployApp();
agHelper.Sleep(3000);
agHelper.AssertContains("Success!");
agHelper.Sleep(3000);
agHelper.AssertContains("Barty Crouch");
});
});

View File

@ -893,8 +893,9 @@ export class AggregateHelper {
selector: ElementType,
text: string | RegExp,
exists: "exist" | "not.exist" = "exist",
timeout?: number,
) {
return this.GetElement(selector)
return this.GetElement(selector, timeout)
.contains(text)
.should(exists);
}

View File

@ -241,5 +241,15 @@
"!url": "https://developer.mozilla.org/en-US/docs/Web/API/FormData/values"
}
}
},
"setTimeout": {
"!type": "fn(f: fn(), ms: number) -> number",
"!url": "https://developer.mozilla.org/en/docs/DOM/window.setTimeout",
"!doc": "Calls a function or executes a code snippet after specified delay."
},
"clearTimeout": {
"!type": "fn(timeout: number)",
"!url": "https://developer.mozilla.org/en/docs/DOM/window.clearTimeout",
"!doc": "Clears the delay set by window.setTimeout()."
}
}

View File

@ -273,6 +273,7 @@ export function* evaluateAndExecuteDynamicTrigger(
callbackData,
globalContext,
eventType,
triggerMeta,
},
);
@ -353,6 +354,18 @@ export function* executeDynamicTriggerRequest(
mainThreadRequestChannel,
);
log.debug({ requestData });
if (requestData?.logs) {
const { eventType, triggerMeta } = requestData;
yield call(
storeLogs,
requestData.logs,
triggerMeta?.source?.name || triggerMeta?.triggerPropertyName || "",
eventType === EventType.ON_JS_FUNCTION_EXECUTE
? ENTITY_TYPE.JSACTION
: ENTITY_TYPE.WIDGET,
triggerMeta?.source?.id || "",
);
}
if (requestData?.trigger) {
// if we have found a trigger, we need to execute it and respond back
log.debug({ trigger: requestData.trigger });
@ -450,7 +463,13 @@ export function* executeFunction(
evaluateAndExecuteDynamicTrigger,
functionCall,
EventType.ON_JS_FUNCTION_EXECUTE,
{},
{
source: {
id: collectionId,
name: `${collectionName}.${action.name}`,
},
triggerPropertyName: `${collectionName}.${action.name}`,
},
);
} catch (e) {
if (e instanceof UncaughtPromiseError) {

View File

@ -314,7 +314,6 @@ export const isThemeBoundProperty = (
};
export const unsafeFunctionForEval = [
"setTimeout",
"fetch",
"setInterval",
"clearInterval",

View File

@ -0,0 +1,40 @@
import { createGlobalData } from "./evaluate";
import { dataTreeEvaluator } from "./evaluation.worker";
export const _internalSetTimeout = self.setTimeout;
export const _internalClearTimeout = self.clearTimeout;
export default function overrideTimeout() {
Object.defineProperty(self, "setTimeout", {
writable: true,
configurable: true,
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");
}
const globalData = createGlobalData({
dataTree: dataTreeEvaluator?.evalTree || {},
resolvedFunctions: dataTreeEvaluator?.resolvedFunctions || {},
isTriggerBased: true,
});
return _internalSetTimeout(
function(...args: any) {
self.ALLOW_ASYNC = true;
Object.assign(self, globalData);
cb(...args);
},
delay,
...args,
);
},
});
Object.defineProperty(self, "clearTimeout", {
writable: true,
configurable: true,
value: function(timerId: number) {
return _internalClearTimeout(timerId);
},
});
}

View File

@ -1,126 +1,116 @@
import { uuid4 } from "@sentry/utils";
import { EventType } from "constants/AppsmithActionConstants/ActionConstants";
import { LogObject, Methods, Severity } from "entities/AppsmithConsole";
import { klona } from "klona/lite";
import moment from "moment";
import { TriggerMeta } from "sagas/ActionExecution/ActionExecutionSagas";
import { _internalClearTimeout, _internalSetTimeout } from "./TimeoutOverride";
class UserLog {
constructor() {
this.initiate();
}
private flushLogsTimerDelay = 0;
private logs: LogObject[] = [];
// initiates the log object with the default methods and their overrides
private initiate() {
private flushLogTimerId: number | undefined;
private requestInfo: {
requestId?: string;
eventType?: EventType;
triggerMeta?: TriggerMeta;
} | null = null;
public setCurrentRequestInfo(requestInfo: {
requestId?: string;
eventType?: EventType;
triggerMeta?: TriggerMeta;
}) {
this.requestInfo = requestInfo;
}
private resetFlushTimer() {
if (this.flushLogTimerId) _internalClearTimeout(this.flushLogTimerId);
this.flushLogTimerId = _internalSetTimeout(() => {
const logs = this.flushLogs();
self.postMessage({
promisified: true,
responseData: {
logs,
eventType: this.requestInfo?.eventType,
triggerMeta: this.requestInfo?.triggerMeta,
},
requestId: this.requestInfo?.requestId,
});
}, this.flushLogsTimerDelay);
}
private saveLog(method: Methods, data: any[]) {
const parsed = this.parseLogs(method, data);
this.logs.push(parsed);
this.resetFlushTimer();
}
public overrideConsoleAPI() {
const { debug, error, info, log, table, warn } = console;
console = {
...console,
table: (...args: any) => {
table.call(this, args);
const parsed = this.parseLogs("table", args);
if (parsed) {
this.logs.push(parsed);
}
return;
this.saveLog("table", args);
},
error: (...args: any) => {
error.apply(this, args);
const parsed = this.parseLogs("error", args);
if (parsed) {
this.logs.push(parsed);
}
return;
this.saveLog("error", args);
},
log: (...args: any) => {
log.apply(this, args);
const parsed = this.parseLogs("log", args);
if (parsed) {
this.logs.push(parsed);
}
return;
this.saveLog("log", args);
},
debug: (...args: any) => {
debug.apply(this, args);
const parsed = this.parseLogs("debug", args);
if (parsed) {
this.logs.push(parsed);
}
return;
this.saveLog("debug", args);
},
warn: (...args: any) => {
warn.apply(this, args);
const parsed = this.parseLogs("warn", args);
if (parsed) {
this.logs.push(parsed);
}
return;
this.saveLog("warn", args);
},
info: (...args: any) => {
info.apply(this, args);
const parsed = this.parseLogs("info", args);
if (parsed) {
this.logs.push(parsed);
}
return;
this.saveLog("info", args);
},
};
}
public getTimestamp() {
return moment().format("hh:mm:ss");
}
public replaceFunctionWithNamesFromObjects(data: any) {
if (typeof data === "object") {
for (const key in data) {
if (typeof data[key] === "function") {
data[key] = `func() ${data[key].name}`;
} else if (data[key] instanceof Promise) {
data[key] = "Promise";
} else {
this.replaceFunctionWithNamesFromObjects(data[key]);
}
}
}
return data;
private replaceFunctionWithNamesFromObjects(data: any) {
if (typeof data === "function") return `func() ${data.name}`;
if (!data || typeof data !== "object") return data;
if (data instanceof Promise) return "Promise";
const acc: any =
Object.prototype.toString.call(data) === "[object Array]" ? [] : {};
return Object.keys(data).reduce((acc, key) => {
acc[key] = this.replaceFunctionWithNamesFromObjects(data[key]);
return acc;
}, acc);
}
// iterates over the data and if data is object/array, then it will remove any functions from it
public sanitizeData(data: any): any {
let returnData = [];
private sanitizeData(data: any): any {
try {
// cloning the object to avoid mutation
const dataObject = klona(data);
returnData = dataObject.map((item: any) => {
if (typeof item === "object") {
return this.replaceFunctionWithNamesFromObjects(item);
}
// if the item is a function, then remove it from the data and return it as name of the function
if (typeof item === "function") {
return `func() item.name`;
}
return item;
});
} catch (e) {
returnData = [`There was some error: ${e} ${JSON.stringify(data)}`];
}
const returnData = this.replaceFunctionWithNamesFromObjects(data);
return returnData;
} catch (e) {
return [`There was some error: ${e} ${JSON.stringify(data)}`];
}
}
// returns the logs from the function execution after sanitising them and resets the logs object after that
public flushLogs(softFlush = false): LogObject[] {
const userLogs = this.logs;
if (!softFlush) this.resetLogs();
// sanitise the data key of the user logs
const sanitisedLogs = userLogs.map((log) => {
public flushLogs(): LogObject[] {
const sanitisedLogs = this.logs.map((log) => {
return {
...log,
data: this.sanitizeData(log.data),
};
});
this.resetLogs();
return sanitisedLogs;
}
// parses the incoming log and converts it to the log object
public parseLogs(method: Methods, data: any[]): LogObject {
// Create an ID
const id = uuid4();
const timestamp = this.getTimestamp();
const timestamp = moment().format("hh:mm:ss");
// Parse the methods
let output = data;
// For logs UI we only keep 3 levels of severity, info, warn, error

View File

@ -110,24 +110,24 @@ describe("evaluateSync", () => {
expect(response.result).toBe("value");
});
it("disallows unsafe function calls", () => {
const js = "setTimeout(() => {}, 100)";
const js = "setImmediate(() => {}, 100)";
const response = evaluate(js, dataTree, {}, false);
expect(response).toStrictEqual({
result: undefined,
logs: [],
errors: [
{
errorMessage: "TypeError: setTimeout is not a function",
errorMessage: "ReferenceError: setImmediate is not defined",
errorType: "PARSE",
raw: `
function closedFunction () {
const result = setTimeout(() => {}, 100)
const result = setImmediate(() => {}, 100)
return result;
}
closedFunction.call(THIS_CONTEXT)
`,
severity: "error",
originalBinding: "setTimeout(() => {}, 100)",
originalBinding: "setImmediate(() => {}, 100)",
},
],
});

View File

@ -14,6 +14,8 @@ import { completePromise } from "workers/PromisifyAction";
import { ActionDescription } from "entities/DataTree/actionTriggers";
import userLogs from "./UserLog";
import { EventType } from "constants/AppsmithActionConstants/ActionConstants";
import overrideTimeout from "./TimeoutOverride";
import { TriggerMeta } from "sagas/ActionExecution/ActionExecutionSagas";
export type EvalResult = {
result: any;
@ -115,6 +117,8 @@ export function setupEvaluationEnvironment() {
// @ts-expect-error: Types are not available
self[func] = undefined;
});
userLogs.overrideConsoleAPI();
overrideTimeout();
}
const beginsWithLineBreakRegex = /^\s+|\s+$/;
@ -221,6 +225,7 @@ export type EvaluateContext = {
globalContext?: Record<string, any>;
requestId?: string;
eventType?: EventType;
triggerMeta?: TriggerMeta;
};
export const getUserScriptToEvaluate = (
@ -330,6 +335,11 @@ export async function evaluateAsync(
let logs;
/**** Setting the eval context ****/
userLogs.resetLogs();
userLogs.setCurrentRequestInfo({
requestId,
eventType: context?.eventType,
triggerMeta: context?.triggerMeta,
});
const GLOBAL_DATA: Record<string, any> = createGlobalData({
dataTree,
resolvedFunctions,

View File

@ -241,6 +241,7 @@ ctx.addEventListener(
dynamicTrigger,
eventType,
globalContext,
triggerMeta,
} = requestData;
if (!dataTreeEvaluator) {
return { triggers: [], errors: [] };
@ -258,6 +259,7 @@ ctx.addEventListener(
{
globalContext,
eventType,
triggerMeta,
},
);

View File

@ -0,0 +1,120 @@
import { PluginType } from "entities/Action";
import { DataTree, ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import { createGlobalData } from "./evaluate";
import "./TimeoutOverride";
import overrideTimeout from "./TimeoutOverride";
describe("Expects appsmith setTimeout to pass the following criteria", () => {
overrideTimeout();
jest.useFakeTimers();
jest.spyOn(self, "setTimeout");
self.postMessage = jest.fn();
it("returns a number a timerId", () => {
const timerId = setTimeout(jest.fn(), 1000);
expect(timerId).toBeDefined();
expect(typeof timerId).toBe("number");
});
it("Passes arguments into callback", () => {
const cb = jest.fn();
const args = [1, 2, "3", [4]];
setTimeout(cb, 1000, ...args);
expect(cb.mock.calls.length).toBe(0);
jest.runAllTimers();
expect(cb).toHaveBeenCalledWith(...args);
});
it("Has weird behavior with 'this' keyword", () => {
const cb = jest.fn();
const error = jest.fn();
const obj = {
var1: "myVar1",
getVar() {
try {
cb(this.var1);
} catch (e) {
error(e);
}
},
};
setTimeout(obj.getVar, 1000);
expect(cb.mock.calls.length).toBe(0);
jest.runAllTimers();
expect(error).toBeCalled();
});
it("Has weird behavior with 'this' keyword", () => {
const cb = jest.fn();
const error = jest.fn();
const obj = {
var1: "myVar1",
getVar() {
try {
cb(this.var1);
} catch (e) {
error(e);
}
},
};
setTimeout(obj.getVar.bind(obj), 1000);
expect(cb.mock.calls.length).toBe(0);
jest.runAllTimers();
expect(cb).toBeCalledWith(obj.var1);
});
it("'this' behavior should be fixed by binding this", () => {
const cb = jest.fn();
const error = jest.fn();
const obj = {
var1: "myVar1",
getVar() {
try {
cb(this.var1);
} catch (e) {
error(e);
}
},
};
setTimeout(obj.getVar.bind(obj), 1000);
expect(cb.mock.calls.length).toBe(0);
jest.runAllTimers();
expect(cb).toBeCalledWith(obj.var1);
});
it("Checks the behavior of clearTimeout", () => {
const cb = jest.fn();
const timerId = setTimeout(cb, 1000);
expect(cb.mock.calls.length).toBe(0);
clearTimeout(timerId);
jest.runAllTimers();
expect(cb.mock.calls.length).toBe(0);
});
it("Access to appsmith functions inside setTimeout", async () => {
const dataTree: DataTree = {
action1: {
actionId: "123",
pluginId: "",
data: {},
config: {},
datasourceUrl: "",
pluginType: PluginType.API,
dynamicBindingPathList: [],
name: "action1",
bindingPaths: {},
reactivePaths: {},
isLoading: false,
run: {},
clear: {},
responseMeta: { isExecutionSuccess: false },
ENTITY_TYPE: ENTITY_TYPE.ACTION,
dependencyMap: {},
logBlackList: {},
},
};
self.ALLOW_ASYNC = true;
const dataTreeWithFunctions = createGlobalData({
dataTree,
resolvedFunctions: {},
isTriggerBased: true,
context: {},
});
setTimeout(() => dataTreeWithFunctions.action1.run(), 1000);
jest.runAllTimers();
expect(self.postMessage).toBeCalled();
});
});