diff --git a/src/platform/packages/shared/kbn-workflows/common/errors/index.ts b/src/platform/packages/shared/kbn-workflows/common/errors/index.ts new file mode 100644 index 0000000000000..096f77f6d0c00 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/common/errors/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { WorkflowExecutionNotFoundError } from './workflow_execution_not_found_error'; +export { WorkflowNotFoundError } from './workflow_not_found_error'; diff --git a/src/platform/packages/shared/kbn-workflows/common/errors.ts b/src/platform/packages/shared/kbn-workflows/common/errors/workflow_execution_not_found_error.ts similarity index 100% rename from src/platform/packages/shared/kbn-workflows/common/errors.ts rename to src/platform/packages/shared/kbn-workflows/common/errors/workflow_execution_not_found_error.ts diff --git a/src/platform/packages/shared/kbn-workflows/common/errors/workflow_not_found_error.ts b/src/platform/packages/shared/kbn-workflows/common/errors/workflow_not_found_error.ts new file mode 100644 index 0000000000000..69b8779a0fbc0 --- /dev/null +++ b/src/platform/packages/shared/kbn-workflows/common/errors/workflow_not_found_error.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export class WorkflowNotFoundError extends Error { + constructor(workflowId: string) { + super(`Workflow with id "${workflowId}" not found.`); + this.name = 'WorkflowNotFoundError'; + } +} diff --git a/src/platform/packages/shared/kbn-workflows/types/v1.ts b/src/platform/packages/shared/kbn-workflows/types/v1.ts index db890c8d0553b..8d80344a828db 100644 --- a/src/platform/packages/shared/kbn-workflows/types/v1.ts +++ b/src/platform/packages/shared/kbn-workflows/types/v1.ts @@ -157,6 +157,7 @@ export interface WorkflowExecutionDto { spaceId: string; id: string; status: ExecutionStatus; + isTestRun: boolean; startedAt: string; finishedAt: string; workflowId?: string; diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/run_workflow_thunk.test.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/run_workflow_thunk.test.ts new file mode 100644 index 0000000000000..cf56ede056aec --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/run_workflow_thunk.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { WorkflowDetailDto } from '@kbn/workflows'; +import { runWorkflowThunk } from './run_workflow_thunk'; +import { createMockStore, getMockServices } from '../../__mocks__/store.mock'; +import type { MockServices, MockStore } from '../../__mocks__/store.mock'; + +describe('runWorkflowThunk', () => { + let store: MockStore; + let mockServices: MockServices; + + const mockWorkflow = { + id: 'workflow-1', + } as WorkflowDetailDto; + + beforeEach(() => { + jest.clearAllMocks(); + + store = createMockStore(); + mockServices = getMockServices(store); + }); + + it('should run workflow successfully', async () => { + const mockResponse = { + workflowExecutionId: 'execution-123', + }; + + const testInputs = { + param1: 'value1', + param2: 'value2', + }; + + // Set up state with mock workflow + store.dispatch({ type: 'detail/setWorkflow', payload: mockWorkflow }); + + mockServices.http.post.mockResolvedValue(mockResponse); + + const result = await store.dispatch(runWorkflowThunk({ inputs: testInputs })); + + expect(mockServices.http.post).toHaveBeenCalledWith(`/api/workflows/${mockWorkflow.id}/run`, { + body: JSON.stringify({ + inputs: testInputs, + }), + }); + expect(mockServices.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'Workflow execution started', + { toastLifeTimeMs: 2000 } + ); + expect(result.type).toBe('detail/runWorkflowThunk/fulfilled'); + expect(result.payload).toEqual(mockResponse); + }); + + it('should reject when no workflow in state', async () => { + // Set up state with no workflow + store.dispatch({ type: 'detail/setWorkflow', payload: null }); + + const result = await store.dispatch(runWorkflowThunk({ inputs: {} })); + + expect(result.type).toBe('detail/runWorkflowThunk/rejected'); + expect(result.payload).toBe('No workflow to run'); + }); + + it('should handle HTTP error with body message', async () => { + const error = { + body: { message: 'Failed to run workflow' }, + message: 'Bad Request', + }; + + store.dispatch({ type: 'detail/setWorkflow', payload: mockWorkflow }); + mockServices.http.post.mockRejectedValue(error); + + const result = await store.dispatch(runWorkflowThunk({ inputs: {} })); + + expect(mockServices.notifications.toasts.addError).toHaveBeenCalledWith( + new Error('Failed to run workflow'), + { + title: 'Failed to run workflow', + } + ); + expect(result.type).toBe('detail/runWorkflowThunk/rejected'); + expect(result.payload).toBe('Failed to run workflow'); + }); + + it('should handle HTTP error without body message', async () => { + const error = { + message: 'Network Error', + }; + + store.dispatch({ type: 'detail/setWorkflow', payload: mockWorkflow }); + mockServices.http.post.mockRejectedValue(error); + + const result = await store.dispatch(runWorkflowThunk({ inputs: {} })); + + expect(mockServices.notifications.toasts.addError).toHaveBeenCalledWith( + new Error('Network Error'), + { + title: 'Failed to run workflow', + } + ); + expect(result.type).toBe('detail/runWorkflowThunk/rejected'); + expect(result.payload).toBe('Network Error'); + }); + + it('should handle error without message', async () => { + const error = {}; + + store.dispatch({ type: 'detail/setWorkflow', payload: mockWorkflow }); + mockServices.http.post.mockRejectedValue(error); + + const result = await store.dispatch(runWorkflowThunk({ inputs: {} })); + + expect(mockServices.notifications.toasts.addError).toHaveBeenCalledWith( + new Error('Failed to run workflow'), + { + title: 'Failed to run workflow', + } + ); + expect(result.type).toBe('detail/runWorkflowThunk/rejected'); + expect(result.payload).toBe('Failed to run workflow'); + }); + + it('should handle empty inputs object', async () => { + const mockResponse = { + workflowExecutionId: 'execution-456', + }; + + store.dispatch({ type: 'detail/setWorkflow', payload: mockWorkflow }); + mockServices.http.post.mockResolvedValue(mockResponse); + + const result = await store.dispatch(runWorkflowThunk({ inputs: {} })); + + expect(mockServices.http.post).toHaveBeenCalledWith(`/api/workflows/${mockWorkflow.id}/run`, { + body: JSON.stringify({ + inputs: {}, + }), + }); + expect(mockServices.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'Workflow execution started', + { toastLifeTimeMs: 2000 } + ); + expect(result.type).toBe('detail/runWorkflowThunk/fulfilled'); + expect(result.payload).toEqual(mockResponse); + }); +}); diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/run_workflow_thunk.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/run_workflow_thunk.ts new file mode 100644 index 0000000000000..fe22de2998c68 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/run_workflow_thunk.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { i18n } from '@kbn/i18n'; +import type { WorkflowsServices } from '../../../../../types'; +import type { RootState } from '../../types'; +import { selectWorkflow } from '../selectors'; + +export interface RunWorkflowParams { + inputs: Record; +} + +export interface RunWorkflowResponse { + workflowExecutionId: string; +} + +export const runWorkflowThunk = createAsyncThunk< + RunWorkflowResponse, + RunWorkflowParams, + { state: RootState; extra: { services: WorkflowsServices } } +>( + 'detail/runWorkflowThunk', + async ({ inputs }, { getState, rejectWithValue, extra: { services } }) => { + const { http, notifications } = services; + try { + const workflow = selectWorkflow(getState()); + + if (!workflow) { + return rejectWithValue('No workflow to run'); + } + + // Make the API call to run the workflow + const response = await http.post(`/api/workflows/${workflow.id}/run`, { + body: JSON.stringify({ + inputs, + }), + }); + // Show success notification + notifications.toasts.addSuccess( + i18n.translate('workflows.detail.runWorkflow.success', { + defaultMessage: 'Workflow execution started', + }), + { toastLifeTimeMs: 2000 } + ); + return response; + } catch (error) { + // Extract error message from HTTP error body if available + const errorMessage = error.body?.message || error.message || 'Failed to run workflow'; + + notifications.toasts.addError(new Error(errorMessage), { + title: i18n.translate('workflows.detail.runWorkflow.error', { + defaultMessage: 'Failed to run workflow', + }), + }); + return rejectWithValue(errorMessage); + } + } +); diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/test_workflow_thunk.test.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/test_workflow_thunk.test.ts index 0d37bc1c2fb1b..3e8112100cfdb 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/test_workflow_thunk.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/test_workflow_thunk.test.ts @@ -22,7 +22,7 @@ describe('testWorkflowThunk', () => { mockServices = getMockServices(store); }); - it('should test workflow successfully', async () => { + it('should test workflow successfully without workflow id', async () => { const mockResponse = { workflowExecutionId: 'execution-123', }; @@ -34,6 +34,8 @@ describe('testWorkflowThunk', () => { // Set up state with yaml content store.dispatch({ type: 'detail/setYamlString', payload: 'name: Test Workflow\nsteps: []' }); + // Set workflow to undefined + store.dispatch({ type: 'detail/setWorkflow', payload: undefined }); mockServices.http.post.mockResolvedValue(mockResponse); @@ -53,6 +55,40 @@ describe('testWorkflowThunk', () => { expect(result.payload).toEqual(mockResponse); }); + it('should test workflow successfully with workflow id when workflow is available', async () => { + const mockResponse = { + workflowExecutionId: 'execution-123', + }; + + const testInputs = { + param1: 'value1', + param2: 'value2', + }; + + // Set up state with yaml content + store.dispatch({ type: 'detail/setYamlString', payload: 'name: Test Workflow\nsteps: []' }); + // Set workflow + store.dispatch({ type: 'detail/setWorkflow', payload: { id: 'workflow-123' } }); + + mockServices.http.post.mockResolvedValue(mockResponse); + + const result = await store.dispatch(testWorkflowThunk({ inputs: testInputs })); + + expect(mockServices.http.post).toHaveBeenCalledWith('/api/workflows/test', { + body: JSON.stringify({ + workflowYaml: 'name: Test Workflow\nsteps: []', + inputs: testInputs, + workflowId: 'workflow-123', + }), + }); + expect(mockServices.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'Workflow test execution started', + { toastLifeTimeMs: 2000 } + ); + expect(result.type).toBe('detail/testWorkflowThunk/fulfilled'); + expect(result.payload).toEqual(mockResponse); + }); + it('should reject when no YAML content to test', async () => { // Set up state with empty yaml store.dispatch({ type: 'detail/setYamlString', payload: '' }); diff --git a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/test_workflow_thunk.ts b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/test_workflow_thunk.ts index 879716260e6e6..8e336d14b83a9 100644 --- a/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/test_workflow_thunk.ts +++ b/src/platform/plugins/shared/workflows_management/public/entities/workflows/store/workflow_detail/thunks/test_workflow_thunk.ts @@ -11,7 +11,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { i18n } from '@kbn/i18n'; import type { WorkflowsServices } from '../../../../../types'; import type { RootState } from '../../types'; -import { selectYamlString } from '../selectors'; +import { selectWorkflow, selectYamlString } from '../selectors'; export interface TestWorkflowParams { inputs: Record; @@ -31,13 +31,24 @@ export const testWorkflowThunk = createAsyncThunk< const { http, notifications } = services; try { const yamlString = selectYamlString(getState()); + const workflow = selectWorkflow(getState()); + if (!yamlString) { return rejectWithValue('No YAML content to test'); } + const requestBody: Record = { + workflowYaml: yamlString, + inputs, + }; + + if (workflow?.id) { + requestBody.workflowId = workflow.id; + } + // Make the API call to test the workflow const response = await http.post(`/api/workflows/test`, { - body: JSON.stringify({ workflowYaml: yamlString, inputs }), + body: JSON.stringify(requestBody), }); // Show success notification diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/hooks/use_workflow_execution_polling.test.ts b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/hooks/use_workflow_execution_polling.test.ts index ea7bd2383366d..f388a569ff23b 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/hooks/use_workflow_execution_polling.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/hooks/use_workflow_execution_polling.test.ts @@ -74,6 +74,7 @@ describe('useWorkflowExecutionPolling', () => { spaceId: 'default', id: mockWorkflowExecutionId, status, + isTestRun: false, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), workflowId: 'test-workflow-id', diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_tree.stories.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_tree.stories.tsx index 7d2d6db021651..9482282c8ddfa 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_tree.stories.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_detail/ui/workflow_step_execution_tree.stories.tsx @@ -122,6 +122,7 @@ export const Default: StoryObj = { workflowDefinition: definition, yaml, status: ExecutionStatus.COMPLETED, + isTestRun: false, triggeredBy: 'manual', startedAt: '2025-09-02T20:43:57.441Z', finishedAt: '2025-09-02T20:44:15.945Z', @@ -491,6 +492,7 @@ export const NoStepExecutionsExecuting: StoryObj = { workflowDefinition: definition, yaml, status: ExecutionStatus.COMPLETED, + isTestRun: false, triggeredBy: 'manual', startedAt: '2025-09-02T20:43:57.441Z', finishedAt: '2025-09-02T20:44:15.945Z', diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.stories.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.stories.tsx index 69b87de5fdce7..7ef0df578e869 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.stories.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.stories.tsx @@ -47,6 +47,7 @@ export const Default: Story = { status: ExecutionStatus.RUNNING, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), + isTestRun: false, spaceId: 'default', duration: parseDuration('1m28s'), stepId: 'my_first_step', @@ -54,6 +55,7 @@ export const Default: Story = { { id: '1', status: ExecutionStatus.COMPLETED, + isTestRun: true, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), spaceId: 'default', @@ -63,6 +65,7 @@ export const Default: Story = { { id: '2', status: ExecutionStatus.FAILED, + isTestRun: false, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), spaceId: 'default', @@ -72,6 +75,7 @@ export const Default: Story = { { id: '4', status: ExecutionStatus.PENDING, + isTestRun: false, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), duration: parseDuration('1w2d'), @@ -81,6 +85,7 @@ export const Default: Story = { { id: '5', status: ExecutionStatus.WAITING_FOR_INPUT, + isTestRun: false, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), duration: parseDuration('1m28s'), @@ -90,6 +95,7 @@ export const Default: Story = { { id: '6', status: ExecutionStatus.CANCELLED, + isTestRun: true, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), duration: parseDuration('280ms'), @@ -99,6 +105,7 @@ export const Default: Story = { { id: '7', status: ExecutionStatus.SKIPPED, + isTestRun: true, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), duration: parseDuration('28s'), @@ -173,6 +180,7 @@ export const LoadingMore: Story = { { id: '1', status: ExecutionStatus.COMPLETED, + isTestRun: false, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), spaceId: 'default', @@ -182,6 +190,7 @@ export const LoadingMore: Story = { { id: '2', status: ExecutionStatus.FAILED, + isTestRun: false, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), spaceId: 'default', diff --git a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.tsx b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.tsx index 396293576daa8..735edb6a63554 100644 --- a/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.tsx +++ b/src/platform/plugins/shared/workflows_management/public/features/workflow_execution_list/ui/workflow_execution_list.tsx @@ -133,6 +133,7 @@ export const WorkflowExecutionList = ({ void; } export const WorkflowExecutionListItem = React.memo( - ({ status, startedAt, duration, selected, onClick }) => { + ({ status, isTestRun, startedAt, duration, selected, onClick }) => { const { euiTheme } = useEuiTheme(); const styles = useMemoCss(componentStyles); const getFormattedDate = useGetFormattedDateTime(); @@ -85,20 +88,38 @@ export const WorkflowExecutionListItem = React.memo - {startedAt ? ( - - - - - - ) : ( - - - - )} + + {isTestRun && ( + + + + )} + + {startedAt ? ( + + + + + + ) : ( + + + + )} + + diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_test_modal.test.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_test_modal.test.tsx index 41e9d8950f5b6..f394e64822799 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_test_modal.test.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_test_modal.test.tsx @@ -9,14 +9,15 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; -import type { WorkflowYaml } from '@kbn/workflows/spec/schema'; import { WorkflowDetailTestModal } from './workflow_detail_test_modal'; -import { createMockStore } from '../../../entities/workflows/store/__mocks__/store.mock'; import { - _setComputedDataInternal, - setIsTestModalOpen, - setWorkflow, -} from '../../../entities/workflows/store/workflow_detail/slice'; + selectHasChanges, + selectIsTestModalOpen, + selectWorkflowDefinition, +} from '../../../entities/workflows/store'; +import { createMockStore } from '../../../entities/workflows/store/__mocks__/store.mock'; +import { runWorkflowThunk } from '../../../entities/workflows/store/workflow_detail/thunks/run_workflow_thunk'; +import { testWorkflowThunk } from '../../../entities/workflows/store/workflow_detail/thunks/test_workflow_thunk'; import { TestWrapper } from '../../../shared/test_utils'; // Mock hooks @@ -38,7 +39,12 @@ jest.mock('../../../hooks/use_workflow_url_state', () => ({ })); jest.mock('../../../hooks/use_async_thunk', () => ({ - useAsyncThunk: () => mockUseAsyncThunk(), + useAsyncThunk: (...args: unknown[]) => mockUseAsyncThunk(...args), +})); +jest.mock('../../../entities/workflows/store/workflow_detail/selectors', () => ({ + selectHasChanges: jest.fn(), + selectIsTestModalOpen: jest.fn(), + selectWorkflowDefinition: jest.fn(), })); // Mock WorkflowExecuteModal @@ -77,26 +83,11 @@ describe('WorkflowDetailTestModal', () => { steps: [], }; - const mockWorkflow = { - id: 'test-123', - name: 'Test Workflow', - enabled: true, - yaml: 'version: "1"', - lastUpdatedAt: '2024-01-01T00:00:00Z', - createdAt: '2024-01-01T00:00:00Z', - createdBy: 'test-user', - lastUpdatedBy: 'test-user', - definition: null, - valid: true, - }; + let mockTestWorkflow: jest.Mock; + let mockRunWorkflow: jest.Mock; - const renderModal = (storeSetup?: (store: ReturnType) => void) => { + const renderModal = () => { const store = createMockStore(); - store.dispatch(setWorkflow(mockWorkflow)); - - if (storeSetup) { - storeSetup(store); - } const wrapper = ({ children }: { children: React.ReactNode }) => { return {children}; @@ -107,6 +98,19 @@ describe('WorkflowDetailTestModal', () => { beforeEach(() => { jest.clearAllMocks(); + mockTestWorkflow = jest.fn(); + mockRunWorkflow = jest.fn(); + + (selectIsTestModalOpen as unknown as jest.Mock).mockReturnValue(true); + (selectWorkflowDefinition as unknown as jest.Mock).mockReturnValue(mockDefinition); + + mockUseAsyncThunk.mockImplementation((thunk) => { + if (thunk === testWorkflowThunk) { + return mockTestWorkflow; + } else if (thunk === runWorkflowThunk) { + return mockRunWorkflow; + } + }); mockUseKibana.mockReturnValue({ services: { @@ -125,26 +129,20 @@ describe('WorkflowDetailTestModal', () => { mockUseWorkflowUrlState.mockReturnValue({ setSelectedExecution: jest.fn(), }); - - mockUseAsyncThunk.mockReturnValue( - jest.fn().mockResolvedValue({ workflowExecutionId: 'exec-123' }) - ); }); describe('modal rendering', () => { it('should not render when modal is closed', () => { - const { queryByTestId } = renderModal((store) => { - store.dispatch(setIsTestModalOpen(false)); - }); + (selectIsTestModalOpen as unknown as jest.Mock).mockReturnValue(false); + + const { queryByTestId } = renderModal(); expect(queryByTestId('workflow-execute-modal')).not.toBeInTheDocument(); }); it('should not render when no definition', () => { - const { queryByTestId } = renderModal((store) => { - store.dispatch(setIsTestModalOpen(true)); - // Don't set definition - }); + (selectWorkflowDefinition as unknown as jest.Mock).mockReturnValue(undefined); + const { queryByTestId } = renderModal(); expect(queryByTestId('workflow-execute-modal')).not.toBeInTheDocument(); }); @@ -154,27 +152,13 @@ describe('WorkflowDetailTestModal', () => { canExecuteWorkflow: false, }); - const { queryByTestId } = renderModal((store) => { - store.dispatch(setIsTestModalOpen(true)); - store.dispatch( - _setComputedDataInternal({ - workflowDefinition: mockDefinition as WorkflowYaml, - }) - ); - }); + const { queryByTestId } = renderModal(); expect(queryByTestId('workflow-execute-modal')).not.toBeInTheDocument(); }); it('should render modal when all conditions are met', () => { - const { getByTestId } = renderModal((store) => { - store.dispatch(setIsTestModalOpen(true)); - store.dispatch( - _setComputedDataInternal({ - workflowDefinition: mockDefinition as WorkflowYaml, - }) - ); - }); + const { getByTestId } = renderModal(); expect(getByTestId('workflow-execute-modal')).toBeInTheDocument(); }); @@ -182,28 +166,14 @@ describe('WorkflowDetailTestModal', () => { describe('modal behavior', () => { it('should pass definition to WorkflowExecuteModal', () => { - const { getByTestId } = renderModal((store) => { - store.dispatch(setIsTestModalOpen(true)); - store.dispatch( - _setComputedDataInternal({ - workflowDefinition: mockDefinition as WorkflowYaml, - }) - ); - }); + const { getByTestId } = renderModal(); const modalDefinition = getByTestId('modal-definition'); expect(modalDefinition).toHaveTextContent(JSON.stringify(mockDefinition)); }); it('should close modal when close button is clicked', () => { - const { getByTestId } = renderModal((store) => { - store.dispatch(setIsTestModalOpen(true)); - store.dispatch( - _setComputedDataInternal({ - workflowDefinition: mockDefinition as WorkflowYaml, - }) - ); - }); + const { getByTestId } = renderModal(); const closeButton = getByTestId('close-modal'); fireEvent.click(closeButton); @@ -212,29 +182,51 @@ describe('WorkflowDetailTestModal', () => { // In a real scenario, we'd need to await and check state }); - it('should call test workflow when submit button is clicked', async () => { - const mockTestWorkflow = jest.fn().mockResolvedValue({ workflowExecutionId: 'exec-123' }); - mockUseAsyncThunk.mockReturnValue(mockTestWorkflow); + describe('when yaml changed', () => { + beforeEach(() => { + (selectHasChanges as unknown as jest.Mock).mockReturnValue(true); + }); + + it('should call test workflow when submit button is clicked', async () => { + mockTestWorkflow.mockResolvedValue({ workflowExecutionId: 'exec-123' }); + + const mockSetSelectedExecution = jest.fn(); + mockUseWorkflowUrlState.mockReturnValue({ + setSelectedExecution: mockSetSelectedExecution, + }); + + const { getByTestId } = renderModal(); + + const submitButton = getByTestId('submit-modal'); + fireEvent.click(submitButton); - const mockSetSelectedExecution = jest.fn(); - mockUseWorkflowUrlState.mockReturnValue({ - setSelectedExecution: mockSetSelectedExecution, + await waitFor(() => { + expect(mockTestWorkflow).toHaveBeenCalledWith({ inputs: { test: 'input' } }); + }); }); + }); - const { getByTestId } = renderModal((store) => { - store.dispatch(setIsTestModalOpen(true)); - store.dispatch( - _setComputedDataInternal({ - workflowDefinition: mockDefinition as WorkflowYaml, - }) - ); + describe('when no changes', () => { + beforeEach(() => { + (selectHasChanges as unknown as jest.Mock).mockReturnValue(false); }); - const submitButton = getByTestId('submit-modal'); - fireEvent.click(submitButton); + it('should call run workflow when submit button is clicked', async () => { + mockRunWorkflow.mockResolvedValue({ workflowExecutionId: 'exec-123AAAAA' }); + + const mockSetSelectedExecution = jest.fn(); + mockUseWorkflowUrlState.mockReturnValue({ + setSelectedExecution: mockSetSelectedExecution, + }); + + const { getByTestId } = renderModal(); + + const submitButton = getByTestId('submit-modal'); + fireEvent.click(submitButton); - await waitFor(() => { - expect(mockTestWorkflow).toHaveBeenCalledWith({ inputs: { test: 'input' } }); + await waitFor(() => { + expect(mockRunWorkflow).toHaveBeenCalledWith({ inputs: { test: 'input' } }); + }); }); }); }); @@ -256,14 +248,7 @@ describe('WorkflowDetailTestModal', () => { canExecuteWorkflow: false, }); - renderModal((store) => { - store.dispatch(setIsTestModalOpen(true)); - store.dispatch( - _setComputedDataInternal({ - workflowDefinition: mockDefinition as WorkflowYaml, - }) - ); - }); + renderModal(); expect(addWarningSpy).toHaveBeenCalledWith( expect.stringContaining('do not have permission to run workflows'), @@ -282,12 +267,9 @@ describe('WorkflowDetailTestModal', () => { }, }, }); + (selectWorkflowDefinition as unknown as jest.Mock).mockReturnValue(undefined); - renderModal((store) => { - store.dispatch(setIsTestModalOpen(true)); - // Don't set definition - }); - + renderModal(); expect(addWarningSpy).toHaveBeenCalledWith( expect.stringContaining('Please fix the errors to run the workflow'), { toastLifeTimeMs: 3000 } diff --git a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_test_modal.tsx b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_test_modal.tsx index 4a3002d87a0bd..7c75825c121c1 100644 --- a/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_test_modal.tsx +++ b/src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_test_modal.tsx @@ -10,11 +10,13 @@ import React, { useCallback, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; +import { setIsTestModalOpen } from '../../../entities/workflows/store'; import { + selectHasChanges, selectIsTestModalOpen, selectWorkflowDefinition, } from '../../../entities/workflows/store/workflow_detail/selectors'; -import { setIsTestModalOpen } from '../../../entities/workflows/store/workflow_detail/slice'; +import { runWorkflowThunk } from '../../../entities/workflows/store/workflow_detail/thunks/run_workflow_thunk'; import { testWorkflowThunk } from '../../../entities/workflows/store/workflow_detail/thunks/test_workflow_thunk'; import { WorkflowExecuteModal } from '../../../features/run_workflow/ui/workflow_execute_modal'; import { useAsyncThunk } from '../../../hooks/use_async_thunk'; @@ -31,16 +33,22 @@ export const WorkflowDetailTestModal = () => { const isTestModalOpen = useSelector(selectIsTestModalOpen); const definition = useSelector(selectWorkflowDefinition); + const hasChanges = useSelector(selectHasChanges); const testWorkflow = useAsyncThunk(testWorkflowThunk); + const runWorkflow = useAsyncThunk(runWorkflowThunk); + const handleRunWorkflow = useCallback( async (inputs: Record) => { - const result = await testWorkflow({ inputs }); - if (result) { - setSelectedExecution(result.workflowExecutionId); + const workflowExecutionId = hasChanges + ? (await testWorkflow({ inputs }))?.workflowExecutionId + : (await runWorkflow({ inputs }))?.workflowExecutionId; + + if (workflowExecutionId) { + setSelectedExecution(workflowExecutionId); } }, - [testWorkflow, setSelectedExecution] + [hasChanges, runWorkflow, testWorkflow, setSelectedExecution] ); const closeModal = useCallback(() => { diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts index b4783abfd0204..1573709e3465b 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/get_workflow_execution.ts @@ -92,6 +92,7 @@ function transformToWorkflowExecutionDetailDto( return { ...workflowExecution, id, + isTestRun: workflowExecution.isTestRun ?? false, stepId: workflowExecution.stepId, stepExecutions, triggeredBy: workflowExecution.triggeredBy, // <-- Include the triggeredBy field diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_workflow_executions.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_workflow_executions.ts index 520d811fc7231..20b11949fa8b0 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_workflow_executions.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/lib/search_workflow_executions.ts @@ -87,6 +87,7 @@ function transformToWorkflowExecutionListModel( id: hit._id!, stepId: workflowExecution.stepId, status: workflowExecution.status, + isTestRun: workflowExecution.isTestRun ?? false, startedAt: workflowExecution.startedAt, finishedAt: workflowExecution.finishedAt, duration: workflowExecution.duration, diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_executions.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_executions.ts index aa8a66d7615e5..1a1d886e9a9a6 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_executions.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/get_workflow_executions.ts @@ -13,7 +13,7 @@ import { ExecutionStatusValues, ExecutionTypeValues } from '@kbn/workflows'; import { WORKFLOW_ROUTE_OPTIONS } from './route_constants'; import { handleRouteError } from './route_error_handlers'; import { WORKFLOW_EXECUTION_READ_SECURITY } from './route_security'; -import { MAX_PAGE_SIZE, parseExecutionStatuses } from './types'; +import { MAX_PAGE_SIZE, parseExecutionStatuses, parseExecutionTypes } from './types'; import type { RouteDependencies } from './types'; import type { SearchWorkflowExecutionsParams } from '../workflows_management_service'; @@ -81,8 +81,7 @@ export function registerGetWorkflowExecutionsRoute({ const params: SearchWorkflowExecutionsParams = { workflowId: request.query.workflowId, statuses: parseExecutionStatuses(request.query.statuses), - // Execution type filter is not supported yet - // executionTypes: parseExecutionTypes(request.query.executionTypes), + executionTypes: parseExecutionTypes(request.query.executionTypes), page: request.query.page, perPage: request.query.perPage, }; diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/post_test_workflow.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/post_test_workflow.test.ts index 0c5d8ac5ef05b..ddf743a84ce88 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/post_test_workflow.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/post_test_workflow.test.ts @@ -54,6 +54,7 @@ describe('POST /api/workflows/test', () => { const mockContext = {}; const mockRequest = { body: { + workflowId: 'workflow-123', workflowYaml: 'name: Test Workflow\nenabled: true\nsteps:\n - id: step1\n name: First Step\n type: action\n action: test-action', inputs: { @@ -68,12 +69,13 @@ describe('POST /api/workflows/test', () => { await routeHandler(mockContext, mockRequest, mockResponse); - expect(workflowsApi.testWorkflow).toHaveBeenCalledWith( - mockRequest.body.workflowYaml, - mockRequest.body.inputs, - 'default', - mockRequest - ); + expect(workflowsApi.testWorkflow).toHaveBeenCalledWith({ + workflowId: mockRequest.body.workflowId, + workflowYaml: mockRequest.body.workflowYaml, + inputs: mockRequest.body.inputs, + spaceId: 'default', + request: mockRequest, + }); expect(mockResponse.ok).toHaveBeenCalledWith({ body: { workflowExecutionId: mockExecutionId, @@ -127,12 +129,13 @@ describe('POST /api/workflows/test', () => { await routeHandler(mockContext, mockRequest, mockResponse); - expect(workflowsApi.testWorkflow).toHaveBeenCalledWith( - mockRequest.body.workflowYaml, - mockRequest.body.inputs, - 'custom-space', - mockRequest - ); + expect(workflowsApi.testWorkflow).toHaveBeenCalledWith({ + workflowId: undefined, + workflowYaml: mockRequest.body.workflowYaml, + inputs: mockRequest.body.inputs, + spaceId: 'custom-space', + request: mockRequest, + }); expect(mockResponse.ok).toHaveBeenCalledWith({ body: { workflowExecutionId: mockExecutionId, diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/post_test_workflow.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/post_test_workflow.ts index 5250928fe04f6..05d87db2459a7 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/post_test_workflow.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/post_test_workflow.ts @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { schema } from '@kbn/config-schema'; +import { z } from '@kbn/zod'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { WORKFLOW_ROUTE_OPTIONS } from './route_constants'; import { handleRouteError } from './route_error_handlers'; import { WORKFLOW_EXECUTE_SECURITY } from './route_security'; @@ -20,22 +21,31 @@ export function registerPostTestWorkflowRoute({ router, api, logger, spaces }: R options: WORKFLOW_ROUTE_OPTIONS, security: WORKFLOW_EXECUTE_SECURITY, validate: { - body: schema.object({ - inputs: schema.recordOf(schema.string(), schema.any()), - workflowYaml: schema.string(), - }), + body: buildRouteValidationWithZod( + z + .object({ + workflowId: z.string().optional(), + workflowYaml: z.string().optional(), + inputs: z.record(z.string(), z.any()), + }) + .refine((data) => data.workflowId || data.workflowYaml, { + message: "Either 'workflowId' or 'workflowYaml' or both must be provided", + path: ['workflowId', 'workflowYaml'], + }) + ), }, }, async (context, request, response) => { try { const spaceId = spaces.getSpaceId(request); - const workflowExecutionId = await api.testWorkflow( - request.body.workflowYaml, - request.body.inputs, + const workflowExecutionId = await api.testWorkflow({ + workflowId: request.body.workflowId, + workflowYaml: request.body.workflowYaml, + inputs: request.body.inputs, spaceId, - request - ); + request, + }); return response.ok({ body: { diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/route_error_handlers.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/route_error_handlers.ts index a7602ece7692b..fafeff5c167e1 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/route_error_handlers.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/route_error_handlers.ts @@ -8,7 +8,10 @@ */ import type { KibanaResponseFactory } from '@kbn/core/server'; -import { WorkflowExecutionNotFoundError } from '@kbn/workflows/common/errors'; +import { + WorkflowExecutionNotFoundError, + WorkflowNotFoundError, +} from '@kbn/workflows/common/errors'; import { InvalidYamlSchemaError, InvalidYamlSyntaxError, @@ -46,6 +49,15 @@ export function handleRouteError( }); } + if (error instanceof WorkflowNotFoundError) { + return response.notFound({ + body: { + message: error.message, + }, + }); + } + + // Generic error handler if (isWorkflowConflictError(error)) { return response.conflict({ body: error.toJSON(), diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/types.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/types.ts index c1d77f39e3d95..30f384154186b 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/types.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/routes/types.ts @@ -9,7 +9,7 @@ import type { IRouter, Logger } from '@kbn/core/server'; import type { SpacesServiceStart } from '@kbn/spaces-plugin/server'; -import type { ExecutionStatus } from '@kbn/workflows'; +import type { ExecutionStatus, ExecutionType } from '@kbn/workflows'; import type { WorkflowsManagementApi } from '../workflows_management_api'; // Pagination constants @@ -26,6 +26,19 @@ export function parseExecutionStatuses( return typeof statuses === 'string' ? ([statuses] as ExecutionStatus[]) : statuses; } +/** + * Helper function to parse execution types from query parameters + * Handles both single string and array of strings + */ +export function parseExecutionTypes( + executionTypes?: ExecutionType | ExecutionType[] | undefined +): ExecutionType[] | undefined { + if (!executionTypes) return undefined; + return typeof executionTypes === 'string' + ? ([executionTypes] as ExecutionType[]) + : executionTypes; +} + export interface RouteDependencies { router: IRouter; api: WorkflowsManagementApi; diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.test.ts index db21f32c64214..f43c601f6d377 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.test.ts @@ -9,7 +9,9 @@ import type { KibanaRequest } from '@kbn/core/server'; import { httpServerMock } from '@kbn/core-http-server-mocks'; -import type { WorkflowDetailDto } from '@kbn/workflows'; +import { type WorkflowDetailDto } from '@kbn/workflows'; +import { WorkflowNotFoundError } from '@kbn/workflows/common/errors'; +import type { WorkflowsExecutionEnginePluginStart } from '@kbn/workflows-execution-engine/server'; import { z } from '@kbn/zod'; import { WorkflowsManagementApi } from './workflows_management_api'; import type { WorkflowsService } from './workflows_management_service'; @@ -22,6 +24,7 @@ describe('WorkflowsManagementApi', () => { beforeEach(() => { mockWorkflowsService = { + getWorkflow: jest.fn(), getWorkflowZodSchema: jest.fn(), createWorkflow: jest.fn(), } as any; @@ -29,10 +32,21 @@ describe('WorkflowsManagementApi', () => { mockGetWorkflowsExecutionEngine = jest.fn(); api = new WorkflowsManagementApi(mockWorkflowsService, mockGetWorkflowsExecutionEngine); + const mockZodSchema = createMockZodSchema(); + mockWorkflowsService.getWorkflowZodSchema.mockResolvedValue(mockZodSchema); mockRequest = httpServerMock.createKibanaRequest(); }); + const createMockZodSchema = () => { + return z.object({ + name: z.string(), + description: z.string().optional(), + enabled: z.boolean().optional(), + steps: z.array(z.any()).optional(), + }); + }; + describe('cloneWorkflow', () => { const createMockWorkflow = (overrides: Partial = {}): WorkflowDetailDto => ({ id: 'workflow-123', @@ -49,21 +63,8 @@ describe('WorkflowsManagementApi', () => { ...overrides, }); - const createMockZodSchema = () => { - return z.object({ - name: z.string(), - description: z.string().optional(), - enabled: z.boolean().optional(), - steps: z.array(z.any()).optional(), - }); - }; - it('should clone workflow successfully with updated name', async () => { const originalWorkflow = createMockWorkflow(); - const mockZodSchema = createMockZodSchema(); - - mockWorkflowsService.getWorkflowZodSchema.mockResolvedValue(mockZodSchema); - const clonedWorkflow: WorkflowDetailDto = { ...originalWorkflow, id: 'workflow-clone-456', @@ -241,4 +242,328 @@ steps: ); }); }); + + describe('testWorkflow', () => { + let underTest: WorkflowsManagementApi; + let mockWorkflowsExecutionEngine: jest.Mocked; + + const mockWorkflowYaml = `name: Test Workflow +enabled: true +trigger: + schedule: + cron: "0 0 * * *" +steps: + - name: step1 + action: test + config: {}`; + + const mockWorkflowDetailDto: WorkflowDetailDto = { + id: 'existing-workflow-id', + name: 'Existing Workflow', + yaml: mockWorkflowYaml, + enabled: true, + definition: { + name: 'Existing Workflow', + enabled: true, + version: '1', + triggers: [ + { + type: 'scheduled', + with: { + every: '0 0 * * *', + }, + }, + ], + steps: [ + { + name: 'step1', + type: 'test', + }, + ], + }, + createdBy: 'test-user', + lastUpdatedBy: 'test-user', + valid: true, + createdAt: '2023-01-01T00:00:00.000Z', + lastUpdatedAt: '2023-01-01T00:00:00.000Z', + }; + + beforeEach(() => { + mockWorkflowsExecutionEngine = jest.mocked({} as any); + mockWorkflowsExecutionEngine.executeWorkflow = jest.fn(); + + mockGetWorkflowsExecutionEngine = jest.fn().mockResolvedValue(mockWorkflowsExecutionEngine); + + mockRequest = { + auth: { + credentials: { + username: 'test-user', + }, + }, + } as any; + + underTest = new WorkflowsManagementApi(mockWorkflowsService, mockGetWorkflowsExecutionEngine); + + // Setup default mock implementations + mockWorkflowsExecutionEngine.executeWorkflow.mockResolvedValue({ + workflowExecutionId: 'test-execution-id', + } as any); + }); + + const spaceId = 'default'; + const inputs = { + event: { type: 'test-event' }, + param1: 'value1', + }; + + describe('when testing with workflowYaml parameter', () => { + it('should successfully test workflow with valid YAML', async () => { + const result = await underTest.testWorkflow({ + workflowYaml: mockWorkflowYaml, + inputs, + spaceId, + request: mockRequest, + }); + + expect(result).toBe('test-execution-id'); + expect(mockWorkflowsService.getWorkflowZodSchema).toHaveBeenCalledWith( + expect.anything(), + spaceId, + mockRequest + ); + expect(mockWorkflowsExecutionEngine.executeWorkflow).toHaveBeenCalledWith( + { + id: 'test-workflow', + name: 'Test Workflow', + enabled: true, + definition: { + name: 'Test Workflow', + enabled: true, + steps: [ + { + name: 'step1', + action: 'test', + config: {}, + }, + ], + }, + yaml: `name: Test Workflow +enabled: true +trigger: + schedule: + cron: "0 0 * * *" +steps: + - name: step1 + action: test + config: {}`, + isTestRun: true, + }, + { + event: { type: 'test-event' }, + spaceId, + inputs: { param1: 'value1' }, + }, + mockRequest + ); + }); + + it('should throw error when YAML parsing fails', async () => { + await expect( + underTest.testWorkflow({ + workflowYaml: 'invalid: yaml: content', + inputs, + spaceId, + request: mockRequest, + }) + ).rejects.toThrow(); + + expect(mockWorkflowsExecutionEngine.executeWorkflow).not.toHaveBeenCalled(); + }); + + it('should separate event from manual inputs when executing workflow', async () => { + const complexInputs = { + event: { type: 'test-event', data: { foo: 'bar' } }, + param1: 'value1', + param2: 'value2', + }; + + await underTest.testWorkflow({ + workflowYaml: mockWorkflowYaml, + inputs: complexInputs, + spaceId, + request: mockRequest, + }); + + expect(mockWorkflowsExecutionEngine.executeWorkflow).toHaveBeenCalledWith( + expect.any(Object), + { + event: { type: 'test-event', data: { foo: 'bar' } }, + spaceId, + inputs: { + param1: 'value1', + param2: 'value2', + }, + }, + mockRequest + ); + }); + }); + + describe('when testing with workflowId parameter', () => { + it('should fetch workflow YAML by ID and execute it', async () => { + mockWorkflowsService.getWorkflow.mockResolvedValue(mockWorkflowDetailDto); + + const result = await underTest.testWorkflow({ + workflowId: 'existing-workflow-id', + inputs, + spaceId, + request: mockRequest, + }); + + expect(result).toBe('test-execution-id'); + expect(mockWorkflowsService.getWorkflow).toHaveBeenCalledWith( + 'existing-workflow-id', + spaceId + ); + expect(mockWorkflowsExecutionEngine.executeWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'existing-workflow-id', + yaml: mockWorkflowYaml, + }), + expect.any(Object), + mockRequest + ); + }); + + it('should throw WorkflowNotFoundError when workflow does not exist', async () => { + mockWorkflowsService.getWorkflow.mockResolvedValue(null); + + await expect( + underTest.testWorkflow({ + workflowId: 'non-existent-workflow-id', + inputs, + spaceId, + request: mockRequest, + }) + ).rejects.toThrow(WorkflowNotFoundError); + + expect(mockWorkflowsService.getWorkflow).toHaveBeenCalledWith( + 'non-existent-workflow-id', + spaceId + ); + expect(mockWorkflowsExecutionEngine.executeWorkflow).not.toHaveBeenCalled(); + }); + + it('should validate fetched workflow YAML', async () => { + mockWorkflowsService.getWorkflow.mockResolvedValue({ + ...mockWorkflowDetailDto, + yaml: 'invalid: yaml: content', + }); + + await expect( + underTest.testWorkflow({ + workflowId: 'existing-workflow-id', + inputs, + spaceId, + request: mockRequest, + }) + ).rejects.toThrow(); + + expect(mockWorkflowsExecutionEngine.executeWorkflow).not.toHaveBeenCalled(); + }); + }); + + describe('when missing required parameters', () => { + it('should throw error when neither workflowId nor workflowYaml is provided', async () => { + await expect( + underTest.testWorkflow({ + inputs, + spaceId, + request: mockRequest, + }) + ).rejects.toThrow('Either workflowId or workflowYaml must be provided'); + + expect(mockWorkflowsExecutionEngine.executeWorkflow).not.toHaveBeenCalled(); + }); + + it('should handle empty workflowYaml as missing parameter', async () => { + await expect( + underTest.testWorkflow({ + workflowYaml: '', + inputs, + spaceId, + request: mockRequest, + }) + ).rejects.toThrow('Either workflowId or workflowYaml must be provided'); + }); + }); + + describe('workflow execution configuration', () => { + it('should set isTestRun flag to true', async () => { + await underTest.testWorkflow({ + workflowYaml: mockWorkflowYaml, + inputs, + spaceId, + request: mockRequest, + }); + + expect(mockWorkflowsExecutionEngine.executeWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + isTestRun: true, + }), + expect.any(Object), + mockRequest + ); + }); + + it('should pass spaceId in execution context', async () => { + const customSpaceId = 'custom-space'; + + await underTest.testWorkflow({ + workflowYaml: mockWorkflowYaml, + inputs, + spaceId: customSpaceId, + request: mockRequest, + }); + + expect(mockWorkflowsExecutionEngine.executeWorkflow).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + spaceId: customSpaceId, + }), + mockRequest + ); + }); + + it('should pass request object to execution engine', async () => { + await underTest.testWorkflow({ + workflowYaml: mockWorkflowYaml, + inputs, + spaceId, + request: mockRequest, + }); + + expect(mockWorkflowsExecutionEngine.executeWorkflow).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + mockRequest + ); + }); + + it('should not use loose schema validation mode', async () => { + await underTest.testWorkflow({ + workflowYaml: mockWorkflowYaml, + inputs, + spaceId, + request: mockRequest, + }); + + expect(mockWorkflowsService.getWorkflowZodSchema).toHaveBeenCalledWith( + { loose: false }, + spaceId, + mockRequest + ); + }); + }); + }); }); diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts index 11067bd223685..07d9d7bb0b264 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts @@ -26,6 +26,7 @@ import type { WorkflowYaml, } from '@kbn/workflows'; import { getJsonSchemaFromYamlSchema, transformWorkflowYamlJsontoEsWorkflow } from '@kbn/workflows'; +import { WorkflowNotFoundError } from '@kbn/workflows/common/errors'; import type { WorkflowsExecutionEnginePluginStart } from '@kbn/workflows-execution-engine/server'; import type { LogSearchResult } from './lib/workflow_logger'; import type { @@ -105,6 +106,14 @@ export interface GetAvailableConnectorsResponse { totalConnectors: number; } +export interface TestWorkflowParams { + workflowId?: string; + workflowYaml?: string; + inputs: Record; + spaceId: string; + request: KibanaRequest; +} + export class WorkflowsManagementApi { constructor( private readonly workflowsService: WorkflowsService, @@ -197,18 +206,38 @@ export class WorkflowsManagementApi { return executeResponse.workflowExecutionId; } - public async testWorkflow( - workflowYaml: string, - inputs: Record, - spaceId: string, - request: KibanaRequest - ): Promise { + public async testWorkflow({ + workflowId, + workflowYaml, + inputs, + spaceId, + request, + }: TestWorkflowParams): Promise { + let resolvedYaml = workflowYaml; + let resolvedWorkflowId = workflowId; + + if (workflowId && !workflowYaml) { + const existingWorkflow = await this.workflowsService.getWorkflow(workflowId, spaceId); + if (!existingWorkflow) { + throw new WorkflowNotFoundError(workflowId); + } + resolvedYaml = existingWorkflow.yaml; + } + + if (!resolvedWorkflowId) { + resolvedWorkflowId = 'test-workflow'; + } + + if (!resolvedYaml) { + throw new Error('Either workflowId or workflowYaml must be provided'); + } + const zodSchema = await this.workflowsService.getWorkflowZodSchema( { loose: false }, spaceId, request ); - const parsedYaml = parseWorkflowYamlToJSON(workflowYaml, zodSchema); + const parsedYaml = parseWorkflowYamlToJSON(resolvedYaml, zodSchema); if (parsedYaml.error) { // TODO: handle error properly @@ -226,7 +255,7 @@ export class WorkflowsManagementApi { ); } - const workflowToCreate = transformWorkflowYamlJsontoEsWorkflow(parsedYaml.data as WorkflowYaml); + const workflowJson = transformWorkflowYamlJsontoEsWorkflow(parsedYaml.data as WorkflowYaml); const { event, ...manualInputs } = inputs; const context = { event, @@ -236,11 +265,11 @@ export class WorkflowsManagementApi { const workflowsExecutionEngine = await this.getWorkflowsExecutionEngine(); const executeResponse = await workflowsExecutionEngine.executeWorkflow( { - id: 'test-workflow', - name: workflowToCreate.name, - enabled: workflowToCreate.enabled, - definition: workflowToCreate.definition, - yaml: workflowYaml, + id: resolvedWorkflowId, + name: workflowJson.name, + enabled: workflowJson.enabled, + definition: workflowJson.definition, + yaml: resolvedYaml, isTestRun: true, }, context, diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts index d05f91fbc3b7a..48850b9e712fc 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.test.ts @@ -1387,6 +1387,7 @@ steps: spaceId: 'default', id: 'execution-1', status: 'completed', + isTestRun: false, startedAt: '2023-01-01T00:00:00Z', finishedAt: '2023-01-01T00:05:00Z', duration: 300000, @@ -1475,7 +1476,7 @@ steps: ); }); - it('should return workflow executions with execution type filter', async () => { + describe('execution type filter', () => { const mockExecutionsResponse = { hits: { hits: [ @@ -1495,32 +1496,112 @@ steps: total: { value: 1 }, }, }; + it('should add filter excluding test runs when filter is production', async () => { + mockEsClient.search.mockResolvedValue(mockExecutionsResponse as any); - mockEsClient.search.mockResolvedValue(mockExecutionsResponse as any); + await service.getWorkflowExecutions( + { + workflowId: 'workflow-1', + executionTypes: [ExecutionType.PRODUCTION], + }, + 'default' + ); - await service.getWorkflowExecutions( - { - workflowId: 'workflow-1', - executionTypes: [ExecutionType.PRODUCTION, ExecutionType.TEST], - }, - 'default' - ); + expect(mockEsClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + must: expect.arrayContaining([ + { + bool: { + should: [ + { term: { isTestRun: false } }, + { bool: { must_not: { exists: { field: 'isTestRun' } } } }, + ], + minimum_should_match: 1, + }, + }, + ]), + }), + }), + }) + ); + }); - expect(mockEsClient.search).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - bool: expect.objectContaining({ - must: expect.arrayContaining([ - { - terms: { - executionType: [ExecutionType.PRODUCTION, ExecutionType.TEST], + it('should add filter excluding production runs when filter is test', async () => { + mockEsClient.search.mockResolvedValue(mockExecutionsResponse as any); + + await service.getWorkflowExecutions( + { + workflowId: 'workflow-1', + executionTypes: [ExecutionType.TEST], + }, + 'default' + ); + + expect(mockEsClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + must: expect.arrayContaining([ + { + term: { + isTestRun: true, + }, }, - }, - ]), + ]), + }), }), - }), - }) - ); + }) + ); + }); + + it('should not add test/production run related filters if no execution type is specified', async () => { + mockEsClient.search.mockResolvedValue(mockExecutionsResponse as any); + + await service.getWorkflowExecutions( + { + workflowId: 'workflow-1', + executionTypes: [], + }, + 'default' + ); + + expect(mockEsClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + must: expect.not.arrayContaining([ + { + term: { + isTestRun: true, + }, + }, + ]), + }), + }), + }) + ); + expect(mockEsClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + must: expect.not.arrayContaining([ + { + bool: { + should: [ + { term: { isTestRun: false } }, + { bool: { must_not: { exists: { field: 'isTestRun' } } } }, + ], + minimum_should_match: 1, + }, + }, + ]), + }), + }), + }) + ); + }); }); it('should handle empty results', async () => { diff --git a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts index 76e048977d62e..c629dfbad75a3 100644 --- a/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts +++ b/src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_service.ts @@ -33,7 +33,6 @@ import type { EsWorkflowExecution, EsWorkflowStepExecution, ExecutionStatus, - ExecutionType, UpdatedWorkflowResponseDto, WorkflowAggsDto, WorkflowDetailDto, @@ -44,7 +43,7 @@ import type { WorkflowStatsDto, WorkflowYaml, } from '@kbn/workflows'; -import { transformWorkflowYamlJsontoEsWorkflow } from '@kbn/workflows'; +import { ExecutionType, transformWorkflowYamlJsontoEsWorkflow } from '@kbn/workflows'; import type { z } from '@kbn/zod'; import { getWorkflowExecution } from './lib/get_workflow_execution'; @@ -848,12 +847,28 @@ export class WorkflowsService { }, }); } - if (params.executionTypes) { - must.push({ - terms: { - executionType: params.executionTypes, - }, - }); + if (params.executionTypes && params.executionTypes?.length === 1) { + const isTestRun = params.executionTypes[0] === ExecutionType.TEST; + + if (isTestRun) { + must.push({ + term: { + isTestRun, + }, + }); + } else { + // the field isTestRun do not exist for regular runs + // so we need to check for both cases: field not existing or field being false + must.push({ + bool: { + should: [ + { term: { isTestRun: false } }, + { bool: { must_not: { exists: { field: 'isTestRun' } } } }, + ], + minimum_should_match: 1, + }, + }); + } } const page = params.page ?? 1; diff --git a/src/platform/plugins/shared/workflows_management/tsconfig.json b/src/platform/plugins/shared/workflows_management/tsconfig.json index 25d9abe7f025e..6e782626b8758 100644 --- a/src/platform/plugins/shared/workflows_management/tsconfig.json +++ b/src/platform/plugins/shared/workflows_management/tsconfig.json @@ -65,6 +65,7 @@ "@kbn/connector-schemas", "@kbn/react-query", "@kbn/core-http-server", + "@kbn/zod-helpers", "@kbn/test-jest-helpers", "@kbn/es-errors", "@kbn/core-http-server-mocks"