diff --git a/public/pages/workflow_detail/workflow_detail.test.tsx b/public/pages/workflow_detail/workflow_detail.test.tsx index 65953214..974d22ad 100644 --- a/public/pages/workflow_detail/workflow_detail.test.tsx +++ b/public/pages/workflow_detail/workflow_detail.test.tsx @@ -41,7 +41,13 @@ const renderWithRouter = ( return { ...render( - + { + beforeEach(() => { + jest.clearAllMocks(); + }); Object.values(WORKFLOW_TYPE).forEach((type) => { test(`renders the WorkflowDetail page with ${type} type`, async () => { const { @@ -113,6 +122,9 @@ describe('WorkflowDetail Page with create ingestion option', () => { }); describe('WorkflowDetail Page Functionality (Custom Workflow)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('tests Export button, Tools panel toggling, and Workspace preview', async () => { const { getByText, container, getByTestId } = renderWithRouter( workflowId, diff --git a/public/pages/workflows/import_workflow/import_workflow_modal.test.tsx b/public/pages/workflows/import_workflow/import_workflow_modal.test.tsx index dd601a53..1a2fbdb1 100644 --- a/public/pages/workflows/import_workflow/import_workflow_modal.test.tsx +++ b/public/pages/workflows/import_workflow/import_workflow_modal.test.tsx @@ -38,6 +38,9 @@ const renderWithRouter = () => ); describe('ImportWorkflowModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('renders the page', () => { const { getAllByText } = renderWithRouter(); expect( diff --git a/public/pages/workflows/import_workflow/import_workflow_modal.tsx b/public/pages/workflows/import_workflow/import_workflow_modal.tsx index c2205519..71f8b472 100644 --- a/public/pages/workflows/import_workflow/import_workflow_modal.tsx +++ b/public/pages/workflows/import_workflow/import_workflow_modal.tsx @@ -134,7 +134,10 @@ export function ImportWorkflowModal(props: ImportWorkflowModalProps) { - onModalClose()}> + onModalClose()} + data-testid="cancelImportButton" + > Cancel { const { mockCoreServices } = require('../../../../test'); @@ -19,6 +25,18 @@ jest.mock('../../../services', () => { }; }); +const mockStore = configureStore([]); +const initialState = { + ml: INITIAL_ML_STATE, + presets: { + loading: false, + presetWorkflows: loadPresetWorkflowTemplates(), + }, +}; +const store = mockStore(initialState); + +const mockDispatch = jest.fn(); + const renderWithRouter = () => render( @@ -31,8 +49,48 @@ const renderWithRouter = () => ); describe('NewWorkflow', () => { - test('renders the search bar', () => { - const { getByPlaceholderText } = renderWithRouter(); + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(ReactReduxHooks, 'useAppDispatch').mockReturnValue(mockDispatch); + }); + + test('renders the preset workflow templates', () => { + const { getByPlaceholderText, getAllByText } = renderWithRouter(); expect(getByPlaceholderText('Search')).toBeInTheDocument(); + Object.values(WORKFLOW_TYPE).forEach((type) => { + if (type !== WORKFLOW_TYPE.UNKNOWN) { + expect(getAllByText(capitalizeEachWord(type))).toHaveLength(1); + } + }); + }); + + test('renders the quick configure for preset workflow templates', async () => { + const { + getAllByTestId, + getAllByText, + getByTestId, + queryByText, + } = renderWithRouter(); + + // Click the first "Go" button on the templates and test Quick Configure. + const goButtons = getAllByTestId('goButton'); + userEvent.click(goButtons[0]); + await waitFor(() => + expect(getAllByText('Quick configure')).toHaveLength(1) + ); + + // Verify that the create button is present in the Quick Configure pop-up. + expect(getByTestId('quickConfigureCreateButton')).toBeInTheDocument(); + + // Click the "Cancel" button in the Quick Configure pop-up. + const quickConfigureCancelButton = getByTestId( + 'quickConfigureCancelButton' + ); + userEvent.click(quickConfigureCancelButton); + + // Ensure the quick configure pop-up is closed after canceling. + await waitFor(() => + expect(queryByText('quickConfigureCreateButton')).toBeNull() + ); }); }); diff --git a/public/pages/workflows/new_workflow/quick_configure_modal.tsx b/public/pages/workflows/new_workflow/quick_configure_modal.tsx index 53d868a0..07255dbc 100644 --- a/public/pages/workflows/new_workflow/quick_configure_modal.tsx +++ b/public/pages/workflows/new_workflow/quick_configure_modal.tsx @@ -123,7 +123,10 @@ export function QuickConfigureModal(props: QuickConfigureModalProps) { /> - props.onClose()}> + props.onClose()} + data-testid="quickConfigureCancelButton" + > Cancel diff --git a/public/pages/workflows/new_workflow/use_case.tsx b/public/pages/workflows/new_workflow/use_case.tsx index 66c76725..741b9aa1 100644 --- a/public/pages/workflows/new_workflow/use_case.tsx +++ b/public/pages/workflows/new_workflow/use_case.tsx @@ -55,6 +55,7 @@ export function UseCase(props: UseCaseProps) { onClick={() => { setIsNameModalOpen(true); }} + data-testid="goButton" > Go diff --git a/public/pages/workflows/workflow_list/delete_workflow_modal.tsx b/public/pages/workflows/workflow_list/delete_workflow_modal.tsx index f0bf94f3..649e5723 100644 --- a/public/pages/workflows/workflow_list/delete_workflow_modal.tsx +++ b/public/pages/workflows/workflow_list/delete_workflow_modal.tsx @@ -98,7 +98,10 @@ export function DeleteWorkflowModal(props: DeleteWorkflowModalProps) { - props.clearDeleteState()}> + props.clearDeleteState()} + data-testid="cancelDeleteWorkflowButton" + > {' '} Cancel @@ -135,6 +138,7 @@ export function DeleteWorkflowModal(props: DeleteWorkflowModalProps) { setIsDeleting(false); props.clearDeleteState(); }} + data-testid="deleteWorkflowButton" fill={true} color="danger" > diff --git a/public/pages/workflows/workflow_list/workflow_list.test.tsx b/public/pages/workflows/workflow_list/workflow_list.test.tsx index 1e39ca72..a89a30e4 100644 --- a/public/pages/workflows/workflow_list/workflow_list.test.tsx +++ b/public/pages/workflows/workflow_list/workflow_list.test.tsx @@ -4,11 +4,16 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; import { Provider } from 'react-redux'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; -import { store } from '../../../store'; import { WorkflowList } from './workflow_list'; +import { mockStore } from '../../../../test/utils'; +import { WORKFLOW_TYPE } from '../../../../common'; +import configureStore from 'redux-mock-store'; +import { WorkflowInput } from '../../../../test/interfaces'; jest.mock('../../../services', () => { const { mockCoreServices } = require('../../../../test'); @@ -18,6 +23,25 @@ jest.mock('../../../services', () => { }; }); +const workflowSet: WorkflowInput[] = Array.from({ length: 20 }, (_, index) => ({ + id: `workflow_id_${index}`, + name: `workflow_name_${index}`, + type: Object.values(WORKFLOW_TYPE)[ + Math.floor(Math.random() * Object.values(WORKFLOW_TYPE).length) + ], +})); + +const mockStore1 = configureStore([]); +const initialState = { + workflows: { + loading: false, + errorMessage: '', + workflows: mockStore(...workflowSet).getState().workflows.workflows, // The `mockStore` used here is from the Test Utils. + }, +}; + +const store = mockStore1(initialState); + const renderWithRouter = () => render( @@ -30,8 +54,111 @@ const renderWithRouter = () => ); describe('WorkflowList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('renders the page', () => { const { getAllByText } = renderWithRouter(); expect(getAllByText('Manage existing workflows').length).toBeGreaterThan(0); + expect(getAllByText('Name').length).toBeGreaterThan(0); + expect(getAllByText('Type').length).toBeGreaterThan(0); + expect(getAllByText('Last saved').length).toBeGreaterThan(0); + expect(getAllByText('Actions').length).toBeGreaterThan(0); + expect(getAllByText('workflow_name_0').length).toBeGreaterThan(0); + }); + + test('sorting functionality', async () => { + const { container, getAllByText, queryByText } = renderWithRouter(); + expect(getAllByText('workflow_name_0').length).toBeGreaterThan(0); + + const sortButtons = container.querySelectorAll( + '[data-test-subj="tableHeaderSortButton"]' + ); + + // Sort workflows list by Name + expect(sortButtons[0]).toBeInTheDocument(); + userEvent.click(sortButtons[0]!); + await waitFor(() => { + expect(queryByText('workflow_name_19')).toBeInTheDocument(); + expect(queryByText('workflow_name_0')).toBeNull(); + }); + userEvent.click(sortButtons[0]!); + await waitFor(() => { + expect(queryByText('workflow_name_0')).toBeInTheDocument(); + expect(queryByText('workflow_name_9')).toBeInTheDocument(); + expect(queryByText('workflow_name_10')).toBeNull(); + expect(queryByText('workflow_name_19')).toBeNull(); + }); + + // Sort workflows list by Type + expect(sortButtons[1]).toBeInTheDocument(); + userEvent.click(sortButtons[1]!); + await waitFor(() => { + expect(getAllByText('Custom').length).toBeGreaterThan(0); + expect(queryByText('Unknown')).toBeNull(); + }); + userEvent.click(sortButtons[1]!); + await waitFor(() => { + expect(queryByText('Unknown')).toBeNull(); + expect(getAllByText('Custom').length).toBeGreaterThan(0); + }); + }); + + test('pagination functionality', async () => { + const { container, getByText, queryByText } = renderWithRouter(); + + // Rows per page 10 + const rowsPerPageButton = container.querySelector( + '[data-test-subj="tablePaginationPopoverButton"]' + ) as HTMLButtonElement; + expect(rowsPerPageButton).toHaveTextContent('Rows per page: 10'); + + // Default view 10 items per page + expect(getByText('workflow_name_0')).toBeInTheDocument(); + expect(queryByText('workflow_name_19')).toBeNull(); + + // Navigate to next page + const nextButton = container.querySelector( + '[data-test-subj="pagination-button-next"]' + ) as HTMLButtonElement; + userEvent.click(nextButton); + await waitFor(() => { + expect(getByText('workflow_name_19')).toBeInTheDocument(); + expect(queryByText('workflow_name_0')).toBeNull(); + }); + + // Navigate to previous page + const previousButton = container.querySelector( + '[data-test-subj="pagination-button-previous"]' + ) as HTMLButtonElement; + userEvent.click(previousButton); + await waitFor(() => { + expect(getByText('workflow_name_0')).toBeInTheDocument(); + expect(queryByText('workflow_name_19')).toBeNull(); + }); + }); + + test('delete action functionality', async () => { + const { getByText, getByTestId, getAllByLabelText } = renderWithRouter(); + const deleteButtons = getAllByLabelText('Delete'); + userEvent.click(deleteButtons[0]); + await waitFor(() => { + expect(getByText('Delete associated resources')).toBeInTheDocument(); + }); + expect(getByTestId('deleteWorkflowButton')).toBeInTheDocument(); + const cancelDeleteWorkflowButton = getByTestId( + 'cancelDeleteWorkflowButton' + ); + expect(cancelDeleteWorkflowButton).toBeInTheDocument(); + userEvent.click(cancelDeleteWorkflowButton); + }); + + test('view resources functionality', async () => { + const { getByText, getAllByLabelText } = renderWithRouter(); + const viewResourcesButtons = getAllByLabelText('View resources'); + userEvent.click(viewResourcesButtons[0]); + await waitFor(() => { + expect(getByText('No existing resources found')).toBeInTheDocument(); + }); }); }); diff --git a/public/pages/workflows/workflows.test.tsx b/public/pages/workflows/workflows.test.tsx index 5d82ce9e..d8a24390 100644 --- a/public/pages/workflows/workflows.test.tsx +++ b/public/pages/workflows/workflows.test.tsx @@ -4,7 +4,9 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { @@ -41,8 +43,35 @@ const renderWithRouter = () => ({ }); describe('Workflows', () => { - test('renders the page', () => { - const { getAllByText } = renderWithRouter(); + beforeEach(() => { + jest.clearAllMocks(); + }); + test('renders the page', async () => { + const { getAllByText, getByTestId, queryByText } = renderWithRouter(); + + // The "Manage Workflows" tab is displayed by default + + // Import Workflow Testing expect(getAllByText('Workflows').length).toBeGreaterThan(0); + const importWorkflowButton = getByTestId('importWorkflowButton'); + await waitFor(() => userEvent.click(importWorkflowButton)); + expect( + getAllByText('Select or drag and drop a file').length + ).toBeGreaterThan(0); + + // Closing or canceling the import + const cancelImportButton = getByTestId('cancelImportButton'); + await waitFor(() => userEvent.click(cancelImportButton)); + expect( + queryByText('Select or drag and drop a file') + ).not.toBeInTheDocument(); + expect(getAllByText('Manage existing workflows').length).toBeGreaterThan(0); + + // When the "Create Workflow" button is clicked, the "New workflow" tab opens + // Create Workflow Testing + const createWorkflowButton = getByTestId('createWorkflowButton'); + expect(createWorkflowButton).toBeInTheDocument(); + await waitFor(() => userEvent.click(createWorkflowButton)); + expect(getAllByText('Create from a template').length).toBeGreaterThan(0); }); }); diff --git a/public/pages/workflows/workflows.tsx b/public/pages/workflows/workflows.tsx index 9239fedc..54d45680 100644 --- a/public/pages/workflows/workflows.tsx +++ b/public/pages/workflows/workflows.tsx @@ -284,6 +284,7 @@ export function Workflows(props: WorkflowsProps) { onClick={() => { setSelectedTabId(WORKFLOWS_TAB.CREATE); }} + data-testid="createWorkflowButton" > Create workflow , @@ -292,6 +293,7 @@ export function Workflows(props: WorkflowsProps) { onClick={() => { setIsImportModalOpen(true); }} + data-testid="importWorkflowButton" > Import workflow , @@ -302,6 +304,7 @@ export function Workflows(props: WorkflowsProps) { onClick={() => { setIsImportModalOpen(true); }} + data-testid="importWorkflowButton" > Import workflow , diff --git a/test/interfaces.ts b/test/interfaces.ts new file mode 100644 index 00000000..d9c3e20e --- /dev/null +++ b/test/interfaces.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKFLOW_TYPE } from '../common/constants'; + +export type WorkflowInput = { + id: string; + name: string; + type: WORKFLOW_TYPE; +}; diff --git a/test/utils.ts b/test/utils.ts index 68c9af5b..03d067de 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -9,6 +9,7 @@ import { INITIAL_PRESETS_STATE, INITIAL_WORKFLOWS_STATE, } from '../public/store'; +import { WorkflowInput } from '../test/interfaces'; import { WORKFLOW_TYPE } from '../common/constants'; import { UIState, Workflow } from '../common/interfaces'; import { @@ -17,25 +18,23 @@ import { fetchMultimodalSearchMetadata, fetchSemanticSearchMetadata, } from '../public/pages/workflows/new_workflow/utils'; +import fs from 'fs'; +import path from 'path'; -export function mockStore( - workflowId: string, - workflowName: string, - workflowType: WORKFLOW_TYPE -) { +export function mockStore(...workflowSets: WorkflowInput[]) { return { getState: () => ({ opensearch: INITIAL_OPENSEARCH_STATE, ml: INITIAL_ML_STATE, workflows: { ...INITIAL_WORKFLOWS_STATE, - workflows: { - [workflowId]: generateWorkflow( - workflowId, - workflowName, - workflowType - ), - }, + workflows: workflowSets.reduce( + (acc, workflowInput) => ({ + ...acc, + [workflowInput.id]: generateWorkflow(workflowInput), + }), + {} + ), }, presets: INITIAL_PRESETS_STATE, }), @@ -46,18 +45,15 @@ export function mockStore( }; } -function generateWorkflow( - workflowId: string, - workflowName: string, - workflowType: WORKFLOW_TYPE -): Workflow { +function generateWorkflow({ id, name, type }: WorkflowInput): Workflow { return { - id: workflowId, - name: workflowName, + id, + name, version: { template: '1.0.0', compatibility: ['2.17.0', '3.0.0'] }, - ui_metadata: getConfig(workflowType), + ui_metadata: getConfig(type), }; } + function getConfig(workflowType: WORKFLOW_TYPE) { let uiMetadata = {} as UIState; switch (workflowType) { @@ -81,6 +77,26 @@ function getConfig(workflowType: WORKFLOW_TYPE) { return uiMetadata; } +const templatesDir = path.resolve( + __dirname, + '..', + 'server', + 'resources', + 'templates' +); + +export const loadPresetWorkflowTemplates = () => + fs + .readdirSync(templatesDir) + .filter((file) => file.endsWith('.json')) + .map((file) => + JSON.parse(fs.readFileSync(path.join(templatesDir, file), 'utf8')) + ); + +export function capitalizeEachWord(input: string): string { + return input.replace(/\b\w/g, (match) => match.toUpperCase()); +} + export const resizeObserverMock = jest.fn().mockImplementation(() => ({ observe: jest.fn(), unobserve: jest.fn(),