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', + }, + ], + ]); + }); });