PromucFlow_constructor/app/client/src/sagas/EvaluationsSaga.test.ts
Vemparala Surya Vamsi 9c632b5a68
chore: merge updateActionData with an evaluation (#38793)
Whenever we have debounced updateActionData and an evalTree we will
merge that to a single evaluation. Seeing a 10-20% reduction in LCP, for
a configured customer app we are seeing the LCP reduce from 20-23
seconds to about 15-20 seconds.


## Description
> [!TIP]  
> _Add a TL;DR when the description is longer than 500 words or
extremely technical (helps the content, marketing, and DevRel team)._
>
> _Please also include relevant motivation and context. List any
dependencies that are required for this change. Add links to Notion,
Figma or any other documents that might be relevant to the PR._


Fixes #`Issue Number`  
_or_  
Fixes `Issue URL`
> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.All"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/12908010770>
> Commit: fc26db67fec9390b10e217706059278b7d4bb6a0
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=12908010770&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.All`
> Spec:
> <hr>Wed, 22 Jan 2025 13:35:05 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Tests**
	- Enhanced test coverage for evaluation and action update processes.
- Added new test scenarios for evaluation loop and action data handling.
- Introduced a new test suite for `DataTreeEvaluator` focusing on
`actionsToUpdate`.

- **Refactor**
	- Improved evaluation saga logic with debounce mechanism.
	- Modularized action data update handling.
	- Refined evaluation tree update process.

- **Performance**
	- Optimized evaluation process to reduce unnecessary updates.
	- Introduced more efficient action data consolidation.
	- Enhanced handling of action data during evaluations.

- **New Features**
	- Added support for consolidated action data in evaluation processes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Diljit VJ <diljit@appsmith.com>
2025-01-23 15:17:47 +05:30

528 lines
17 KiB
TypeScript

import {
defaultAffectedJSObjects,
evalAndLintingHandler,
evalQueueBuffer,
evaluateTreeSaga,
evaluationLoopWithDebounce,
type BUFFERED_ACTION,
} from "./EvaluationsSaga";
import { evalWorker } from "utils/workerInstances";
import { expectSaga, testSaga } from "redux-saga-test-plan";
import { EVAL_WORKER_ACTIONS } from "ee/workers/Evaluation/evalWorkerActions";
import { select } from "redux-saga/effects";
import { getMetaWidgets, getWidgets, getWidgetsMeta } from "./selectors";
import { getAllActionValidationConfig } from "ee//selectors/entitiesSelector";
import { getSelectedAppTheme } from "selectors/appThemingSelectors";
import { getAppMode } from "ee/selectors/applicationSelectors";
import * as log from "loglevel";
import type { ReduxAction } from "actions/ReduxActionTypes";
import {
ReduxActionErrorTypes,
ReduxActionTypes,
} from "ee/constants/ReduxActionConstants";
import { fetchPluginFormConfigsSuccess } from "actions/pluginActions";
import { createJSCollectionSuccess } from "actions/jsActionActions";
import { getInstanceId } from "ee/selectors/tenantSelectors";
import {
getApplicationLastDeployedAt,
getCurrentApplicationId,
getCurrentPageId,
} from "selectors/editorSelectors";
import { updateActionData } from "actions/pluginActionActions";
jest.mock("loglevel");
describe("evaluateTreeSaga", () => {
afterAll(() => {
jest.unmock("loglevel");
});
test("should set 'shouldRespondWithLogs'to evaluations when the log level is debug", async () => {
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(log.getLevel as any).mockReturnValue(log.levels.DEBUG);
const unEvalAndConfigTree = { unEvalTree: {}, configTree: {} };
return expectSaga(evaluateTreeSaga, unEvalAndConfigTree)
.provide([
[select(getAllActionValidationConfig), {}],
[select(getWidgets), {}],
[select(getMetaWidgets), {}],
[select(getSelectedAppTheme), {}],
[select(getAppMode), false],
[select(getWidgetsMeta), {}],
[select(getInstanceId), "instanceId"],
[select(getCurrentApplicationId), "applicationId"],
[select(getCurrentPageId), "pageId"],
[
select(getApplicationLastDeployedAt),
new Date("11 September 2024").toISOString(),
],
])
.call(evalWorker.request, EVAL_WORKER_ACTIONS.EVAL_TREE, {
cacheProps: {
instanceId: "instanceId",
appId: "applicationId",
pageId: "pageId",
appMode: false,
timestamp: new Date("11 September 2024").toISOString(),
},
unevalTree: unEvalAndConfigTree,
widgetTypeConfigMap: undefined,
widgets: {},
theme: {},
shouldReplay: true,
allActionValidationConfig: {},
forceEvaluation: false,
metaWidgets: {},
appMode: false,
widgetsMeta: {},
shouldRespondWithLogs: true,
affectedJSObjects: { ids: [], isAllAffected: false },
actionDataPayloadConsolidated: undefined,
})
.run();
});
test("should set 'shouldRespondWithLogs' to false when the log level is not debug", async () => {
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(log.getLevel as any).mockReturnValue(log.levels.INFO);
const unEvalAndConfigTree = { unEvalTree: {}, configTree: {} };
return expectSaga(evaluateTreeSaga, unEvalAndConfigTree)
.provide([
[select(getAllActionValidationConfig), {}],
[select(getWidgets), {}],
[select(getMetaWidgets), {}],
[select(getSelectedAppTheme), {}],
[select(getAppMode), false],
[select(getWidgetsMeta), {}],
[select(getInstanceId), "instanceId"],
[select(getCurrentApplicationId), "applicationId"],
[select(getCurrentPageId), "pageId"],
[
select(getApplicationLastDeployedAt),
new Date("11 September 2024").toISOString(),
],
])
.call(evalWorker.request, EVAL_WORKER_ACTIONS.EVAL_TREE, {
cacheProps: {
instanceId: "instanceId",
appId: "applicationId",
pageId: "pageId",
appMode: false,
timestamp: new Date("11 September 2024").toISOString(),
},
unevalTree: unEvalAndConfigTree,
widgetTypeConfigMap: undefined,
widgets: {},
theme: {},
shouldReplay: true,
allActionValidationConfig: {},
forceEvaluation: false,
metaWidgets: {},
appMode: false,
widgetsMeta: {},
shouldRespondWithLogs: false,
affectedJSObjects: { ids: [], isAllAffected: false },
actionDataPayloadConsolidated: undefined,
})
.run();
});
test("should propagate affectedJSObjects property to evaluation action", async () => {
const unEvalAndConfigTree = { unEvalTree: {}, configTree: {} };
const affectedJSObjects = {
isAllAffected: false,
ids: ["1", "2"],
};
return expectSaga(
evaluateTreeSaga,
unEvalAndConfigTree,
[],
undefined,
undefined,
undefined,
affectedJSObjects,
)
.provide([
[select(getAllActionValidationConfig), {}],
[select(getWidgets), {}],
[select(getMetaWidgets), {}],
[select(getSelectedAppTheme), {}],
[select(getAppMode), false],
[select(getWidgetsMeta), {}],
[select(getInstanceId), "instanceId"],
[select(getCurrentApplicationId), "applicationId"],
[select(getCurrentPageId), "pageId"],
[
select(getApplicationLastDeployedAt),
new Date("11 September 2024").toISOString(),
],
])
.call(evalWorker.request, EVAL_WORKER_ACTIONS.EVAL_TREE, {
cacheProps: {
instanceId: "instanceId",
appId: "applicationId",
pageId: "pageId",
appMode: false,
timestamp: new Date("11 September 2024").toISOString(),
},
unevalTree: unEvalAndConfigTree,
widgetTypeConfigMap: undefined,
widgets: {},
theme: {},
shouldReplay: true,
allActionValidationConfig: {},
forceEvaluation: false,
metaWidgets: {},
appMode: false,
widgetsMeta: {},
shouldRespondWithLogs: false,
affectedJSObjects,
actionDataPayloadConsolidated: undefined,
})
.run();
});
});
describe("evalQueueBuffer", () => {
test("should return a buffered action with the default affectedJSObjects state for an action which does not have affectedJSObjects associated to it", () => {
const buffer = evalQueueBuffer();
// this action does not generate an affectedJSObject
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(fetchPluginFormConfigsSuccess({} as any));
const bufferedAction = buffer.take();
expect(bufferedAction).toEqual({
actionDataPayloadConsolidated: [],
hasBufferedAction: true,
hasDebouncedHandleUpdate: false,
type: ReduxActionTypes.BUFFERED_ACTION,
affectedJSObjects: defaultAffectedJSObjects,
postEvalActions: [],
});
});
test("should club all JS actions affectedJSObjects's ids", () => {
const buffer = evalQueueBuffer();
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(createJSCollectionSuccess({ id: "1" } as any));
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(createJSCollectionSuccess({ id: "2" } as any));
const bufferedAction = buffer.take();
expect(bufferedAction).toEqual({
actionDataPayloadConsolidated: [],
hasBufferedAction: true,
hasDebouncedHandleUpdate: false,
type: ReduxActionTypes.BUFFERED_ACTION,
affectedJSObjects: { ids: ["1", "2"], isAllAffected: false },
postEvalActions: [],
});
});
test("should return all JS actions that have changed when there is a pending action which affects all JS actions ", () => {
const buffer = evalQueueBuffer();
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(createJSCollectionSuccess({ id: "1" } as any));
// this action triggers an isAllAffected flag
buffer.put({
type: ReduxActionErrorTypes.FETCH_JS_ACTIONS_ERROR,
} as ReduxAction<unknown>);
// queue is not empty
expect(buffer.isEmpty()).not.toBeTruthy();
const bufferedAction = buffer.take();
expect(bufferedAction).toEqual({
actionDataPayloadConsolidated: [],
hasBufferedAction: true,
hasDebouncedHandleUpdate: false,
type: ReduxActionTypes.BUFFERED_ACTION,
affectedJSObjects: { ids: [], isAllAffected: true },
postEvalActions: [],
});
expect(buffer.isEmpty()).toBeTruthy();
});
test("should reset the collectedAffectedJSObjects after the buffered action has been dequeued and the subsequent actions should have the defaultAffectedJSObjects", () => {
const buffer = evalQueueBuffer();
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(createJSCollectionSuccess({ id: "1" } as any));
const bufferedAction = buffer.take();
expect(bufferedAction).toEqual({
actionDataPayloadConsolidated: [],
hasBufferedAction: true,
hasDebouncedHandleUpdate: false,
type: ReduxActionTypes.BUFFERED_ACTION,
affectedJSObjects: { ids: ["1"], isAllAffected: false },
postEvalActions: [],
});
expect(buffer.isEmpty()).toBeTruthy();
// this action does not generate an affectedJSObject, So the subsequent buffered action should have default affectedJSObjects
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(fetchPluginFormConfigsSuccess({ id: "1" } as any));
const bufferedActionsWithDefaultAffectedJSObjects = buffer.take();
expect(bufferedActionsWithDefaultAffectedJSObjects).toEqual({
actionDataPayloadConsolidated: [],
hasBufferedAction: true,
hasDebouncedHandleUpdate: false,
type: ReduxActionTypes.BUFFERED_ACTION,
affectedJSObjects: defaultAffectedJSObjects,
postEvalActions: [],
});
});
test("should debounce UPDATE_ACTION_DATA actions together when the buffer is busy", () => {
const buffer = evalQueueBuffer();
buffer.put(
updateActionData([
{
entityName: "widget1",
dataPath: "data",
data: { a: 1 },
dataPathRef: "",
},
]),
);
buffer.put(
updateActionData([
{
entityName: "widget2",
dataPath: "data",
data: { a: 2 },
dataPathRef: "",
},
]),
);
const bufferedActionsWithDefaultAffectedJSObjects = buffer.take();
expect(bufferedActionsWithDefaultAffectedJSObjects).toEqual({
actionDataPayloadConsolidated: [
{
data: {
a: 1,
},
dataPath: "data",
dataPathRef: "",
entityName: "widget1",
},
{
data: {
a: 2,
},
dataPath: "data",
dataPathRef: "",
entityName: "widget2",
},
],
hasBufferedAction: false,
hasDebouncedHandleUpdate: true,
type: ReduxActionTypes.BUFFERED_ACTION,
affectedJSObjects: defaultAffectedJSObjects,
postEvalActions: [],
});
});
test("should be able to debounce UPDATE_ACTION_DATA actions and BUFFERED_ACTION together when the buffer is busy", () => {
const buffer = evalQueueBuffer();
buffer.put(
updateActionData([
{
entityName: "widget1",
dataPath: "data",
data: { a: 1 },
dataPathRef: "",
},
]),
);
buffer.put(
updateActionData([
{
entityName: "widget2",
dataPath: "data",
data: { a: 2 },
dataPathRef: "",
},
]),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(createJSCollectionSuccess({ id: "1" } as any));
const bufferedActionsWithDefaultAffectedJSObjects = buffer.take();
expect(bufferedActionsWithDefaultAffectedJSObjects).toEqual({
actionDataPayloadConsolidated: [
{
data: {
a: 1,
},
dataPath: "data",
dataPathRef: "",
entityName: "widget1",
},
{
data: {
a: 2,
},
dataPath: "data",
dataPathRef: "",
entityName: "widget2",
},
],
hasBufferedAction: true,
hasDebouncedHandleUpdate: true,
type: ReduxActionTypes.BUFFERED_ACTION,
affectedJSObjects: { ids: ["1"], isAllAffected: false },
postEvalActions: [],
});
});
});
describe("evaluationLoopWithDebounce", () => {
describe("debounce", () => {
test("should call a regular evaluation with the consolidated action data payload when both updateActionData and evaluation action is triggered", async () => {
const buffer = evalQueueBuffer();
buffer.put(
updateActionData([
{
entityName: "widget1",
dataPath: "data",
data: { a: 1 },
dataPathRef: "",
},
]),
);
buffer.put(
updateActionData([
{
entityName: "widget2",
dataPath: "data",
data: { a: 2 },
dataPathRef: "",
},
]),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(createJSCollectionSuccess({ id: "1" } as any));
const action = buffer.take();
const mockChannel = "mock-channel";
// assert that a regular evaluation is only triggered and no evalTreeWithChanges evaluation is triggered
return (
testSaga(evaluationLoopWithDebounce, mockChannel)
.next()
.take(mockChannel)
.next(action)
.call(evalAndLintingHandler, true, action, {
actionDataPayloadConsolidated: [
{
entityName: "widget1",
dataPath: "data",
data: { a: 1 },
dataPathRef: "",
},
{
entityName: "widget2",
dataPath: "data",
data: { a: 2 },
dataPathRef: "",
},
],
shouldReplay: undefined,
forceEvaluation: false,
requiresLogging: undefined,
affectedJSObjects: { isAllAffected: false, ids: ["1"] },
})
.next()
// wait for the next action in the event loop
.take(mockChannel)
);
});
test("should call an evalTreeWithChanges when only updateActionData actions are triggered", async () => {
const buffer = evalQueueBuffer();
buffer.put(
updateActionData([
{
entityName: "widget1",
dataPath: "data",
data: { a: 1 },
dataPathRef: "",
},
]),
);
buffer.put(
updateActionData([
{
entityName: "widget2",
dataPath: "data",
data: { a: 2 },
dataPathRef: "",
},
]),
);
const action = buffer.take() as unknown as BUFFERED_ACTION;
const mockChannel = "mock-channel";
return (
testSaga(evaluationLoopWithDebounce, mockChannel)
.next()
.take(mockChannel)
.next(action)
.call(
evalWorker.request,
EVAL_WORKER_ACTIONS.UPDATE_ACTION_DATA,
action.actionDataPayloadConsolidated,
)
.next()
// wait for the next action in the event loop
.take(mockChannel)
);
});
test("should call a regular evaluation when evaluation actions are triggered", async () => {
const buffer = evalQueueBuffer();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buffer.put(createJSCollectionSuccess({ id: "1" } as any));
const action = buffer.take();
const mockChannel = "mock-channel";
return (
testSaga(evaluationLoopWithDebounce, mockChannel)
.next()
.take(mockChannel)
.next(action)
.call(evalAndLintingHandler, true, action, {
shouldReplay: undefined,
forceEvaluation: false,
requiresLogging: undefined,
affectedJSObjects: { isAllAffected: false, ids: ["1"] },
})
.next()
// wait for the next action in the event loop
.take(mockChannel)
);
});
});
});