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..5a434b23e3 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.test.ts @@ -0,0 +1,389 @@ +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, mockNodeTypeDescription } from '@/__tests__/mocks'; +import { nodeViewEventBus } from '@/event-bus'; +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'; +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: () => ({}), + useRoute: () => reactive({}), + RouterLink: vi.fn(), +})); + +vi.mock('@/composables/useToast', () => { + const showError = vi.fn(); + const showMessage = vi.fn(); + return { + useToast: () => ({ + showError, + showMessage, + }), + }; +}); + +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; +let uiStore: MockedStore; +let nodeTypesStore: MockedStore; +let ndvStore: MockedStore; + +let runWorkflow: ReturnType; +let externalHooks: ReturnType; +let pinnedData: ReturnType; +let message: ReturnType; +let toast: 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); + + runWorkflow = useRunWorkflow({ router: useRouter() }); + externalHooks = useExternalHooks(); + message = useMessage(); + toast = useToast(); + + workflowsStore.workflowId = 'abc123'; + }); + + it('renders without error', () => { + 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 }), + ); + + const { getByRole, queryByRole } = renderComponent(); + + 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-node', type: SET_NODE_TYPE }), + ); + + const { getByRole, queryByRole } = renderComponent(); + + 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: SET_NODE_TYPE, + issues: { + typeUnknown: true, + }, + }), + ); + + 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(runWorkflow.stopCurrentExecution).toHaveBeenCalledTimes(1); + expect(emitted().stopExecution).toBeTruthy(); + }); + + it('runs workflow when clicking button normally', async () => { + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + 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: node.name, + source: 'RunData.ExecuteNodeButton', + }); + expect(emitted().execute).toBeTruthy(); + }); + + it('opens chat when clicking button for chat node', async () => { + 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(); + + await userEvent.click(getByRole('button')); + + expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null); + expect(workflowsStore.chatPartialExecutionDestinationNode).toBe(node.name); + expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat'); + }); + + it('opens chat when clicking button for chat child node', async () => { + const node = mockNode({ name: 'test-node', type: SET_NODE_TYPE }); + workflowsStore.getNodeByName.mockReturnValue(node); + workflowsStore.checkIfNodeHasChatParent.mockReturnValue(true); + + const { getByRole } = renderComponent(); + + await userEvent.click(getByRole('button')); + + expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null); + 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', + }, + ], + ]); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue index 6085e9eb86..dd31507cff 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'; @@ -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'); } @@ -367,28 +367,21 @@ async function onClick() { 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')); }); 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" >
- + + +