From c4060aa9a46d74209f2dff29ff5cdf511f727c0f Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Mon, 3 Mar 2025 15:39:03 +0100 Subject: [PATCH 1/6] fix(editor): Disable deactivated node execution --- .../src/components/NodeExecuteButton.vue | 2 +- .../elements/nodes/CanvasNodeToolbar.vue | 28 +++++++++++-------- .../src/plugins/i18n/locales/en.json | 1 + 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue index 8ef10dd1be..ea11462dd6 100644 --- a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue @@ -155,7 +155,7 @@ const disabledHint = computed(() => { return i18n.baseText('ndv.execute.generatingCode'); } - if (isTriggerNode.value && node?.value?.disabled) { + if (node?.value?.disabled) { return i18n.baseText('ndv.execute.nodeIsDisabled'); } diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue index 2c552d757c..ae47fe8d01 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue @@ -94,17 +94,23 @@ function onMouseLeave() { @mouseleave="onMouseLeave" >
- + + + Date: Tue, 4 Mar 2025 15:21:54 +0100 Subject: [PATCH 2/6] test(editor): Add node execute button tests --- .../src/components/NodeExecuteButton.test.ts | 109 ++++++++++++++++++ .../src/components/NodeExecuteButton.vue | 43 +++---- 2 files changed, 127 insertions(+), 25 deletions(-) create mode 100644 packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts new file mode 100644 index 0000000000..6dea949378 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts @@ -0,0 +1,109 @@ +import { reactive } from 'vue'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { createComponentRenderer } from '@/__tests__/render'; +import { type MockedStore, mockedStore } from '@/__tests__/utils'; +import { mockNode } from '@/__tests__/mocks'; +import { CODE_NODE_TYPE } from '@/constants'; +import NodeExecuteButton from '@/components/NodeExecuteButton.vue'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useUIStore } from '@/stores/ui.store'; +import { useNodeTypesStore } from '@/stores/nodeTypes.store'; + +vi.mock('vue-router', () => ({ + useRouter: () => ({}), + useRoute: () => reactive({}), + RouterLink: vi.fn(), +})); + +let renderComponent: ReturnType; +let workflowsStore: MockedStore; +let uiStore: MockedStore; +let nodeTypesStore: MockedStore; + +describe('NodeExecuteButton', () => { + beforeEach(() => { + renderComponent = createComponentRenderer(NodeExecuteButton, { + pinia: createTestingPinia(), + }); + + workflowsStore = mockedStore(useWorkflowsStore); + uiStore = mockedStore(useUIStore); + nodeTypesStore = mockedStore(useNodeTypesStore); + }); + + it('renders without error', () => { + expect(() => + renderComponent({ + props: { + nodeName: 'test', + telemetrySource: 'test', + }, + }), + ).not.toThrow(); + }); + + it('should be disabled if the node is disabled and show tooltip', async () => { + workflowsStore.getNodeByName.mockReturnValue( + mockNode({ name: 'test', type: CODE_NODE_TYPE, disabled: true }), + ); + + const { getByRole, queryByRole } = renderComponent({ + props: { + nodeName: 'test', + telemetrySource: 'test', + }, + }); + + const button = getByRole('button'); + expect(button).toBeDisabled(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + + await userEvent.hover(button); + expect(getByRole('tooltip')).toBeVisible(); + expect(getByRole('tooltip')).toHaveTextContent('Enable node to execute'); + }); + + it('should be disabled when workflow is running but node is not executing', async () => { + uiStore.isActionActive.workflowRunning = true; + workflowsStore.isNodeExecuting.mockReturnValue(false); + workflowsStore.getNodeByName.mockReturnValue(mockNode({ name: 'test', type: CODE_NODE_TYPE })); + + const { getByRole, queryByRole } = renderComponent({ + props: { + nodeName: 'test', + telemetrySource: 'test', + }, + }); + + const button = getByRole('button'); + expect(button).toBeDisabled(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + + await userEvent.hover(button); + expect(getByRole('tooltip')).toBeVisible(); + expect(getByRole('tooltip')).toHaveTextContent('Workflow is already running'); + }); + + it('disables button when trigger node has issues', async () => { + nodeTypesStore.isTriggerNode = () => true; + workflowsStore.getNodeByName.mockReturnValue( + mockNode({ + name: 'test', + type: CODE_NODE_TYPE, + issues: { + typeUnknown: true, + }, + }), + ); + + const { getByRole } = renderComponent({ + props: { + nodeName: 'test', + telemetrySource: 'test', + }, + }); + + expect(getByRole('button')).toBeDisabled(); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue index ea11462dd6..a807fdde94 100644 --- a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue @@ -26,7 +26,7 @@ import { useUIStore } from '@/stores/ui.store'; import { useRouter } from 'vue-router'; import { useI18n } from '@/composables/useI18n'; import { useTelemetry } from '@/composables/useTelemetry'; -import { type IUpdateInformation } from '../Interface'; +import { type IUpdateInformation } from '@/Interface'; import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils'; const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT'; @@ -367,28 +367,21 @@ async function onClick() { From eb62bce5d3c0fea8d6535fef40e6e72c2c64681f Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 4 Mar 2025 19:20:39 +0100 Subject: [PATCH 3/6] test(editor): Add more node execute button tests --- .../src/components/NodeExecuteButton.test.ts | 160 ++++++++++++++---- .../src/components/NodeExecuteButton.vue | 2 +- 2 files changed, 130 insertions(+), 32 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts index 6dea949378..1a8d5e076c 100644 --- a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts @@ -1,14 +1,20 @@ import { reactive } from 'vue'; import { createTestingPinia } from '@pinia/testing'; +import { useRouter } from 'vue-router'; import userEvent from '@testing-library/user-event'; import { createComponentRenderer } from '@/__tests__/render'; import { type MockedStore, mockedStore } from '@/__tests__/utils'; -import { mockNode } from '@/__tests__/mocks'; -import { CODE_NODE_TYPE } from '@/constants'; +import { mockNode, mockNodeTypeDescription } from '@/__tests__/mocks'; +import { nodeViewEventBus } from '@/event-bus'; +import { CHAT_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants'; import NodeExecuteButton from '@/components/NodeExecuteButton.vue'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useUIStore } from '@/stores/ui.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; +import { useNDVStore } from '@/stores/ndv.store'; +import { useExecutionsStore } from '@/stores/executions.store'; +import { useRunWorkflow } from '@/composables/useRunWorkflow'; +import { useExternalHooks } from '@/composables/useExternalHooks'; vi.mock('vue-router', () => ({ useRouter: () => ({}), @@ -16,44 +22,65 @@ vi.mock('vue-router', () => ({ RouterLink: vi.fn(), })); +vi.mock('@/composables/useRunWorkflow', () => ({ + useRunWorkflow: () => ({ + runWorkflow: vi.fn(), + stopCurrentExecution: vi.fn(), + }), +})); + +vi.mock('@/composables/useExternalHooks', () => ({ + useExternalHooks: () => ({ + run: vi.fn(), + }), +})); + let renderComponent: ReturnType; let workflowsStore: MockedStore; let uiStore: MockedStore; let nodeTypesStore: MockedStore; +let ndvStore: MockedStore; +let executionsStore: MockedStore; + +let runWorkflow: ReturnType; +let externalHooks: ReturnType; + +const nodeViewEventBusEmitSpy = vi.spyOn(nodeViewEventBus, 'emit'); describe('NodeExecuteButton', () => { beforeEach(() => { + vi.clearAllMocks(); + renderComponent = createComponentRenderer(NodeExecuteButton, { pinia: createTestingPinia(), + props: { + nodeName: 'test-node', + telemetrySource: 'test-source', + }, }); workflowsStore = mockedStore(useWorkflowsStore); uiStore = mockedStore(useUIStore); nodeTypesStore = mockedStore(useNodeTypesStore); + ndvStore = mockedStore(useNDVStore); + executionsStore = mockedStore(useExecutionsStore); + + runWorkflow = useRunWorkflow({ router: useRouter() }); + externalHooks = useExternalHooks(); + + workflowsStore.workflowId = 'abc123'; }); it('renders without error', () => { - expect(() => - renderComponent({ - props: { - nodeName: 'test', - telemetrySource: 'test', - }, - }), - ).not.toThrow(); + expect(() => renderComponent()).not.toThrow(); }); it('should be disabled if the node is disabled and show tooltip', async () => { workflowsStore.getNodeByName.mockReturnValue( - mockNode({ name: 'test', type: CODE_NODE_TYPE, disabled: true }), + mockNode({ name: 'test', type: SET_NODE_TYPE, disabled: true }), ); - const { getByRole, queryByRole } = renderComponent({ - props: { - nodeName: 'test', - telemetrySource: 'test', - }, - }); + const { getByRole, queryByRole } = renderComponent(); const button = getByRole('button'); expect(button).toBeDisabled(); @@ -67,14 +94,11 @@ describe('NodeExecuteButton', () => { it('should be disabled when workflow is running but node is not executing', async () => { uiStore.isActionActive.workflowRunning = true; workflowsStore.isNodeExecuting.mockReturnValue(false); - workflowsStore.getNodeByName.mockReturnValue(mockNode({ name: 'test', type: CODE_NODE_TYPE })); + workflowsStore.getNodeByName.mockReturnValue( + mockNode({ name: 'test-node', type: SET_NODE_TYPE }), + ); - const { getByRole, queryByRole } = renderComponent({ - props: { - nodeName: 'test', - telemetrySource: 'test', - }, - }); + const { getByRole, queryByRole } = renderComponent(); const button = getByRole('button'); expect(button).toBeDisabled(); @@ -90,20 +114,94 @@ describe('NodeExecuteButton', () => { workflowsStore.getNodeByName.mockReturnValue( mockNode({ name: 'test', - type: CODE_NODE_TYPE, + type: SET_NODE_TYPE, issues: { typeUnknown: true, }, }), ); - const { getByRole } = renderComponent({ - props: { - nodeName: 'test', - telemetrySource: 'test', - }, - }); + const { getByRole } = renderComponent(); expect(getByRole('button')).toBeDisabled(); }); + + it('stops webhook when clicking button while listening for events', async () => { + workflowsStore.executionWaitingForWebhook = true; + nodeTypesStore.isTriggerNode = () => true; + workflowsStore.getNodeByName.mockReturnValue( + mockNode({ name: 'test-node', type: SET_NODE_TYPE }), + ); + + const { getByRole } = renderComponent(); + + await userEvent.click(getByRole('button')); + + expect(workflowsStore.removeTestWebhook).toHaveBeenCalledWith('abc123'); + }); + + it('stops execution when clicking button while workflow is running', async () => { + uiStore.isActionActive.workflowRunning = true; + nodeTypesStore.isTriggerNode = () => true; + workflowsStore.activeExecutionId = 'test-execution-id'; + workflowsStore.isNodeExecuting.mockReturnValue(true); + workflowsStore.getNodeByName.mockReturnValue( + mockNode({ name: 'test-node', type: SET_NODE_TYPE }), + ); + + const { getByRole, emitted } = renderComponent(); + + await userEvent.click(getByRole('button')); + + expect(executionsStore.stopCurrentExecution).toHaveBeenCalledWith('test-execution-id'); + expect(emitted().stopExecution).toBeTruthy(); + }); + + it('runs workflow when clicking button normally', async () => { + workflowsStore.getNodeByName.mockReturnValue( + mockNode({ name: 'test-node', type: SET_NODE_TYPE }), + ); + nodeTypesStore.getNodeType = () => mockNodeTypeDescription(); + + const { getByRole, emitted } = renderComponent(); + + await userEvent.click(getByRole('button')); + + expect(externalHooks.run).toHaveBeenCalledWith('nodeExecuteButton.onClick', expect.any(Object)); + expect(runWorkflow.runWorkflow).toHaveBeenCalledWith({ + destinationNode: 'test-node', + source: 'RunData.ExecuteNodeButton', + }); + expect(emitted().execute).toBeTruthy(); + }); + + it('opens chat when clicking button for chat node', async () => { + workflowsStore.getNodeByName.mockReturnValue( + mockNode({ name: 'test-node', type: SET_NODE_TYPE }), + ); + nodeTypesStore.getNodeType = () => mockNodeTypeDescription({ name: CHAT_TRIGGER_NODE_TYPE }); + + const { getByRole } = renderComponent(); + + await userEvent.click(getByRole('button')); + + expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null); + expect(workflowsStore.chatPartialExecutionDestinationNode).toBe('test-node'); + expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat'); + }); + + it('opens chat when clicking button for chat child node', async () => { + workflowsStore.getNodeByName.mockReturnValue( + mockNode({ name: 'test-node', type: SET_NODE_TYPE }), + ); + workflowsStore.checkIfNodeHasChatParent.mockReturnValue(true); + + const { getByRole } = renderComponent(); + + await userEvent.click(getByRole('button')); + + expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null); + expect(workflowsStore.chatPartialExecutionDestinationNode).toBe('test-node'); + expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat'); + }); }); diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue index a807fdde94..dd31507cff 100644 --- a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue @@ -85,7 +85,7 @@ const nodeType = computed((): INodeTypeDescription | null => { }); const isNodeRunning = computed(() => { - if (!uiStore.isActionActive['workflowRunning'] || codeGenerationInProgress.value) return false; + if (!uiStore.isActionActive.workflowRunning || codeGenerationInProgress.value) return false; const triggeredNode = workflowsStore.executedNode; return ( workflowsStore.isNodeExecuting(node.value?.name ?? '') || triggeredNode === node.value?.name From a62eb6f30309172ac94637c1642014781abe324d Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 4 Mar 2025 19:35:09 +0100 Subject: [PATCH 4/6] test(editor): Fix unit test --- .../editor-ui/src/components/NodeExecuteButton.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts index 1a8d5e076c..4023906821 100644 --- a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts @@ -12,7 +12,6 @@ import { useWorkflowsStore } from '@/stores/workflows.store'; import { useUIStore } from '@/stores/ui.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNDVStore } from '@/stores/ndv.store'; -import { useExecutionsStore } from '@/stores/executions.store'; import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useExternalHooks } from '@/composables/useExternalHooks'; @@ -40,7 +39,6 @@ let workflowsStore: MockedStore; let uiStore: MockedStore; let nodeTypesStore: MockedStore; let ndvStore: MockedStore; -let executionsStore: MockedStore; let runWorkflow: ReturnType; let externalHooks: ReturnType; @@ -63,7 +61,6 @@ describe('NodeExecuteButton', () => { uiStore = mockedStore(useUIStore); nodeTypesStore = mockedStore(useNodeTypesStore); ndvStore = mockedStore(useNDVStore); - executionsStore = mockedStore(useExecutionsStore); runWorkflow = useRunWorkflow({ router: useRouter() }); externalHooks = useExternalHooks(); @@ -153,7 +150,7 @@ describe('NodeExecuteButton', () => { await userEvent.click(getByRole('button')); - expect(executionsStore.stopCurrentExecution).toHaveBeenCalledWith('test-execution-id'); + expect(runWorkflow.stopCurrentExecution).toHaveBeenCalledWith('test-execution-id'); expect(emitted().stopExecution).toBeTruthy(); }); From 99dce3f8c6b38d94ddbdc2dd3f80b5c51860e546 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Wed, 5 Mar 2025 15:16:32 +0100 Subject: [PATCH 5/6] test(editor): Fix unit test --- .../src/components/NodeExecuteButton.test.ts | 232 ++++++++++++++++-- 1 file changed, 207 insertions(+), 25 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts index 4023906821..df3b03c6d9 100644 --- a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts @@ -6,7 +6,13 @@ import { createComponentRenderer } from '@/__tests__/render'; import { type MockedStore, mockedStore } from '@/__tests__/utils'; import { mockNode, mockNodeTypeDescription } from '@/__tests__/mocks'; import { nodeViewEventBus } from '@/event-bus'; -import { CHAT_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants'; +import { AI_TRANSFORM_NODE_TYPE, AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT } from 'n8n-workflow'; +import { + CHAT_TRIGGER_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + SET_NODE_TYPE, + WEBHOOK_NODE_TYPE, +} from '@/constants'; import NodeExecuteButton from '@/components/NodeExecuteButton.vue'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useUIStore } from '@/stores/ui.store'; @@ -14,6 +20,10 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNDVStore } from '@/stores/ndv.store'; import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useExternalHooks } from '@/composables/useExternalHooks'; +import { usePinnedData } from '@/composables/usePinnedData'; +import { useMessage } from '@/composables/useMessage'; +import { useToast } from '@/composables/useToast'; +import * as buttonParameterUtils from '@/components/ButtonParameter/utils'; vi.mock('vue-router', () => ({ useRouter: () => ({}), @@ -21,18 +31,56 @@ vi.mock('vue-router', () => ({ RouterLink: vi.fn(), })); -vi.mock('@/composables/useRunWorkflow', () => ({ - useRunWorkflow: () => ({ - runWorkflow: vi.fn(), - stopCurrentExecution: vi.fn(), - }), -})); +vi.mock('@/composables/useToast', () => { + const showError = vi.fn(); + const showMessage = vi.fn(); + return { + useToast: () => ({ + showError, + showMessage, + }), + }; +}); -vi.mock('@/composables/useExternalHooks', () => ({ - useExternalHooks: () => ({ - run: vi.fn(), - }), -})); +vi.mock('@/composables/useRunWorkflow', () => { + const runWorkflow = vi.fn(); + const stopCurrentExecution = vi.fn(); + return { + useRunWorkflow: () => ({ + runWorkflow, + stopCurrentExecution, + }), + }; +}); + +vi.mock('@/composables/useExternalHooks', () => { + const run = vi.fn(); + return { + useExternalHooks: () => ({ + run, + }), + }; +}); + +vi.mock('@/composables/usePinnedData', () => { + const hasData = {}; + const unsetData = vi.fn(); + return { + usePinnedData: () => ({ + hasData, + unsetData, + }), + }; +}); + +vi.mock('@/composables/useMessage', () => { + const confirm = vi.fn(async () => 'confirm'); + return { + useMessage: () => ({ + confirm, + }), + }; +}); let renderComponent: ReturnType; let workflowsStore: MockedStore; @@ -42,6 +90,9 @@ let ndvStore: MockedStore; let runWorkflow: ReturnType; let externalHooks: ReturnType; +let pinnedData: ReturnType; +let message: ReturnType; +let toast: ReturnType; const nodeViewEventBusEmitSpy = vi.spyOn(nodeViewEventBus, 'emit'); @@ -64,6 +115,8 @@ describe('NodeExecuteButton', () => { runWorkflow = useRunWorkflow({ router: useRouter() }); externalHooks = useExternalHooks(); + message = useMessage(); + toast = useToast(); workflowsStore.workflowId = 'abc123'; }); @@ -72,6 +125,90 @@ describe('NodeExecuteButton', () => { expect(() => renderComponent()).not.toThrow(); }); + it('displays correct button label for regular node', () => { + const { getByRole } = renderComponent(); + expect(getByRole('button').textContent).toBe('Test step'); + }); + + it('displays correct button label for webhook node', () => { + const node = mockNode({ name: 'test-node', type: WEBHOOK_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + nodeTypesStore.getNodeType = () => ({ + ...mockNodeTypeDescription(), + name: WEBHOOK_NODE_TYPE, + }); + + const { getByRole } = renderComponent(); + expect(getByRole('button').textContent).toBe('Listen for test event'); + }); + + it('displays correct button label for form trigger node', () => { + const node = mockNode({ name: 'test-node', type: FORM_TRIGGER_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + nodeTypesStore.getNodeType = () => ({ + ...mockNodeTypeDescription(), + name: FORM_TRIGGER_NODE_TYPE, + }); + + const { getByRole } = renderComponent(); + expect(getByRole('button').textContent).toBe('Test step'); + }); + + it('displays correct button label for chat node', () => { + const node = mockNode({ name: 'test-node', type: CHAT_TRIGGER_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + nodeTypesStore.getNodeType = () => ({ + ...mockNodeTypeDescription(), + name: CHAT_TRIGGER_NODE_TYPE, + }); + + const { getByRole } = renderComponent(); + expect(getByRole('button').textContent).toBe('Test chat'); + }); + + it('displays correct button label for polling node', () => { + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + nodeTypesStore.getNodeType = () => ({ + ...mockNodeTypeDescription(), + polling: true, + }); + + const { getByRole } = renderComponent(); + expect(getByRole('button').textContent).toBe('Fetch Test Event'); + }); + + it('displays "Stop Listening" when node is listening for events', () => { + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + workflowsStore.executionWaitingForWebhook = true; + nodeTypesStore.isTriggerNode = () => true; + + const { getByRole } = renderComponent(); + expect(getByRole('button').textContent).toBe('Stop Listening'); + }); + + it('displays "Stop Listening" when node is running and is a trigger node', () => { + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + workflowsStore.isNodeExecuting = vi.fn(() => true); + nodeTypesStore.isTriggerNode = () => true; + uiStore.isActionActive.workflowRunning = true; + + const { getByRole } = renderComponent(); + expect(getByRole('button').textContent).toBe('Stop Listening'); + }); + + it('sets button to loading state when node is executing', () => { + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + workflowsStore.isNodeExecuting = vi.fn(() => true); + uiStore.isActionActive.workflowRunning = true; + + const { getByRole } = renderComponent(); + expect(getByRole('button').querySelector('.n8n-spinner')).toBeVisible(); + }); + it('should be disabled if the node is disabled and show tooltip', async () => { workflowsStore.getNodeByName.mockReturnValue( mockNode({ name: 'test', type: SET_NODE_TYPE, disabled: true }), @@ -150,14 +287,13 @@ describe('NodeExecuteButton', () => { await userEvent.click(getByRole('button')); - expect(runWorkflow.stopCurrentExecution).toHaveBeenCalledWith('test-execution-id'); + expect(runWorkflow.stopCurrentExecution).toHaveBeenCalledTimes(1); expect(emitted().stopExecution).toBeTruthy(); }); it('runs workflow when clicking button normally', async () => { - workflowsStore.getNodeByName.mockReturnValue( - mockNode({ name: 'test-node', type: SET_NODE_TYPE }), - ); + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); nodeTypesStore.getNodeType = () => mockNodeTypeDescription(); const { getByRole, emitted } = renderComponent(); @@ -166,16 +302,15 @@ describe('NodeExecuteButton', () => { expect(externalHooks.run).toHaveBeenCalledWith('nodeExecuteButton.onClick', expect.any(Object)); expect(runWorkflow.runWorkflow).toHaveBeenCalledWith({ - destinationNode: 'test-node', + destinationNode: node.name, source: 'RunData.ExecuteNodeButton', }); expect(emitted().execute).toBeTruthy(); }); it('opens chat when clicking button for chat node', async () => { - workflowsStore.getNodeByName.mockReturnValue( - mockNode({ name: 'test-node', type: SET_NODE_TYPE }), - ); + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); nodeTypesStore.getNodeType = () => mockNodeTypeDescription({ name: CHAT_TRIGGER_NODE_TYPE }); const { getByRole } = renderComponent(); @@ -183,14 +318,13 @@ describe('NodeExecuteButton', () => { await userEvent.click(getByRole('button')); expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null); - expect(workflowsStore.chatPartialExecutionDestinationNode).toBe('test-node'); + expect(workflowsStore.chatPartialExecutionDestinationNode).toBe(node.name); expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat'); }); it('opens chat when clicking button for chat child node', async () => { - workflowsStore.getNodeByName.mockReturnValue( - mockNode({ name: 'test-node', type: SET_NODE_TYPE }), - ); + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); workflowsStore.checkIfNodeHasChatParent.mockReturnValue(true); const { getByRole } = renderComponent(); @@ -198,7 +332,55 @@ describe('NodeExecuteButton', () => { await userEvent.click(getByRole('button')); expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null); - expect(workflowsStore.chatPartialExecutionDestinationNode).toBe('test-node'); + expect(workflowsStore.chatPartialExecutionDestinationNode).toBe(node.name); expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat'); }); + + it('prompts for confirmation when pinned data exists', async () => { + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + pinnedData = usePinnedData(node); + Object.defineProperty(pinnedData.hasData, 'value', { value: true }); + + const { getByRole } = renderComponent(); + + await userEvent.click(getByRole('button')); + + expect(message.confirm).toHaveBeenCalledTimes(1); + expect(pinnedData.unsetData).toHaveBeenCalledWith('unpin-and-execute-modal'); + expect(runWorkflow.runWorkflow).toHaveBeenCalledTimes(1); + }); + + it('generates code for AI Transform node', async () => { + const generateCodeForAiTransformSpy = vi + .spyOn(buttonParameterUtils, 'generateCodeForAiTransform') + .mockImplementation(async () => ({ + name: 'test', + value: 'Test', + })); + const node = mockNode({ + name: 'test-node', + type: AI_TRANSFORM_NODE_TYPE, + parameters: { + instructions: 'Test instructions', + [AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT]: 'Test prompt', + }, + }); + workflowsStore.getNodeByName.mockReturnValue(node); + + const { getByRole, emitted } = renderComponent(); + + await userEvent.click(getByRole('button')); + expect(generateCodeForAiTransformSpy).toHaveBeenCalledTimes(1); + expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(emitted().valueChanged).toEqual([ + [{ name: 'test', value: 'Test' }], + [ + { + name: `parameters.${AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT}`, + value: 'Test instructions', + }, + ], + ]); + }); }); From c39685bbc0e86ebddc7a0be427df22bceaddde94 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Wed, 5 Mar 2025 16:20:52 +0100 Subject: [PATCH 6/6] test(editor): Add more tests --- .../src/components/NodeExecuteButton.test.ts | 3 ++ .../elements/nodes/CanvasNodeToolbar.test.ts | 42 +++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts index df3b03c6d9..5a434b23e3 100644 --- a/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts @@ -221,6 +221,7 @@ describe('NodeExecuteButton', () => { expect(queryByRole('tooltip')).not.toBeInTheDocument(); await userEvent.hover(button); + expect(getByRole('tooltip')).toBeVisible(); expect(getByRole('tooltip')).toHaveTextContent('Enable node to execute'); }); @@ -239,6 +240,7 @@ describe('NodeExecuteButton', () => { expect(queryByRole('tooltip')).not.toBeInTheDocument(); await userEvent.hover(button); + expect(getByRole('tooltip')).toBeVisible(); expect(getByRole('tooltip')).toHaveTextContent('Workflow is already running'); }); @@ -371,6 +373,7 @@ describe('NodeExecuteButton', () => { const { getByRole, emitted } = renderComponent(); await userEvent.click(getByRole('button')); + expect(generateCodeForAiTransformSpy).toHaveBeenCalledTimes(1); expect(toast.showMessage).toHaveBeenCalledTimes(1); expect(emitted().valueChanged).toEqual([ diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.test.ts b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.test.ts index 54680b1347..05416e1995 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.test.ts +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.test.ts @@ -1,4 +1,5 @@ -import { fireEvent, waitFor } from '@testing-library/vue'; +import { waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue'; import { createComponentRenderer } from '@/__tests__/render'; import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data'; @@ -35,6 +36,29 @@ describe('CanvasNodeToolbar', () => { expect(getByTestId('execute-node-button')).toBeDisabled(); }); + it('should render disabled execute node button when node is deactivated', async () => { + const { getByTestId, getByRole } = renderComponent({ + global: { + provide: { + ...createCanvasNodeProvide({ + data: { + disabled: true, + }, + }), + ...createCanvasProvide(), + }, + }, + }); + + const button = getByTestId('execute-node-button'); + expect(button).toBeDisabled(); + + await userEvent.hover(button); + + expect(getByRole('tooltip')).toBeVisible(); + expect(getByRole('tooltip')).toHaveTextContent("This node is deactivated and can't be run"); + }); + it('should not render execute node button when renderType is configuration', async () => { const { queryByTestId } = renderComponent({ global: { @@ -65,7 +89,7 @@ describe('CanvasNodeToolbar', () => { }, }); - await fireEvent.click(getByTestId('execute-node-button')); + await userEvent.click(getByTestId('execute-node-button')); expect(emitted('run')[0]).toEqual([]); }); @@ -80,7 +104,7 @@ describe('CanvasNodeToolbar', () => { }, }); - await fireEvent.click(getByTestId('disable-node-button')); + await userEvent.click(getByTestId('disable-node-button')); expect(emitted('toggle')[0]).toEqual([]); }); @@ -95,7 +119,7 @@ describe('CanvasNodeToolbar', () => { }, }); - await fireEvent.click(getByTestId('delete-node-button')); + await userEvent.click(getByTestId('delete-node-button')); expect(emitted('delete')[0]).toEqual([]); }); @@ -110,7 +134,7 @@ describe('CanvasNodeToolbar', () => { }, }); - await fireEvent.click(getByTestId('overflow-node-button')); + await userEvent.click(getByTestId('overflow-node-button')); expect(emitted('open:contextmenu')[0]).toEqual([expect.any(MouseEvent)]); }); @@ -132,8 +156,8 @@ describe('CanvasNodeToolbar', () => { }, }); - await fireEvent.click(getByTestId('change-sticky-color')); - await fireEvent.click(getAllByTestId('color')[0]); + await userEvent.click(getByTestId('change-sticky-color')); + await userEvent.click(getAllByTestId('color')[0]); expect(emitted('update')[0]).toEqual([{ color: 1 }]); }); @@ -150,7 +174,7 @@ describe('CanvasNodeToolbar', () => { const toolbar = getByTestId('canvas-node-toolbar'); - await fireEvent.mouseEnter(toolbar); + await userEvent.hover(toolbar); expect(toolbar).toHaveClass('forceVisible'); }); @@ -174,7 +198,7 @@ describe('CanvasNodeToolbar', () => { const toolbar = getByTestId('canvas-node-toolbar'); - await fireEvent.click(getByTestId('change-sticky-color')); + await userEvent.click(getByTestId('change-sticky-color')); await waitFor(() => expect(toolbar).toHaveClass('forceVisible')); });