From ce9bedf42374042cf405e40553a83914c4e07f43 Mon Sep 17 00:00:00 2001 From: cyril-ui-developer Date: Thu, 2 Apr 2026 11:30:50 -0400 Subject: [PATCH] Extend RTL Test Coverage for Components Migrated from Enzyme --- .../__tests__/ResourceLimitsModal.spec.tsx | 238 ++++++++++++---- .../__tests__/empty-state.spec.tsx | 84 ++++-- .../__tests__/NumberSpinnerField.spec.tsx | 80 ++++-- .../__tests__/KeyValueFileInputField.spec.tsx | 164 +++++------ .../__tests__/GettingStartedCard.spec.tsx | 138 +++++---- .../loading/__tests__/loading.spec.tsx | 70 +++-- .../status/__tests__/status-box.spec.tsx | 60 ++-- .../__tests__/catalog-source.spec.tsx | 130 +++++---- .../operand/__tests__/index.spec.tsx | 263 ++++++++++++++---- .../utils/__tests__/selector-input.spec.tsx | 106 +++++++ .../components/utils/selector-input.jsx | 1 - 11 files changed, 931 insertions(+), 403 deletions(-) create mode 100644 frontend/public/components/utils/__tests__/selector-input.spec.tsx diff --git a/frontend/packages/console-app/src/components/modals/resource-limits/__tests__/ResourceLimitsModal.spec.tsx b/frontend/packages/console-app/src/components/modals/resource-limits/__tests__/ResourceLimitsModal.spec.tsx index b5737c0208d..9e1e07a80d9 100644 --- a/frontend/packages/console-app/src/components/modals/resource-limits/__tests__/ResourceLimitsModal.spec.tsx +++ b/frontend/packages/console-app/src/components/modals/resource-limits/__tests__/ResourceLimitsModal.spec.tsx @@ -1,67 +1,106 @@ -import type { ComponentProps } from 'react'; -import { screen, fireEvent } from '@testing-library/react'; -import type { FormikProps, FormikValues } from 'formik'; -import { formikFormProps } from '@console/shared/src/test-utils/formik-props-utils'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Formik } from 'formik'; +import type { TFunction } from 'i18next'; +import * as yup from 'yup'; +import { limitsValidationSchema } from '@console/dev-console/src/components/import/validation-schema'; +import type { K8sResourceKind } from '@console/internal/module/k8s'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { getLimitsDataFromResource } from '@console/shared/src/utils/resource-utils'; import ResourceLimitsModal from '../ResourceLimitsModal'; -jest.mock('@console/dev-console/src/components/import/advanced/ResourceLimitSection', () => ({ - default: () => null, -})); +jest.mock('@patternfly/react-topology', () => ({})); -type ResourceLimitsModalProps = ComponentProps; +const emptyLimits = { + cpu: { + request: '', + requestUnit: '', + defaultRequestUnit: '', + limit: '', + limitUnit: '', + defaultLimitUnit: '', + }, + memory: { + request: '', + requestUnit: 'Mi', + defaultRequestUnit: 'Mi', + limit: '', + limitUnit: 'Mi', + defaultLimitUnit: 'Mi', + }, +}; -describe('ResourceLimitsModal Form', () => { - let formProps: ResourceLimitsModalProps; +const t = ((key: string) => key) as TFunction; - type Props = FormikProps & ResourceLimitsModalProps; +const resourceLimitsSchema = yup.object().shape({ + limits: limitsValidationSchema(t), +}); - beforeEach(() => { - jest.clearAllMocks(); - formProps = { - ...formikFormProps, - isSubmitting: false, - cancel: jest.fn(), - resource: { - apiVersion: 'apps/v1', - kind: 'Deployment', +const baseDeployment = (): K8sResourceKind => + ({ + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: 'xyz-deployment', + }, + spec: { + selector: { + matchLabels: { + app: 'hello-openshift', + }, + }, + replicas: 1, + template: { metadata: { - name: 'xyz-deployment', + labels: { + app: 'hello-openshift', + }, }, spec: { - selector: { - matchLabels: { - app: 'hello-openshift', - }, - }, - replicas: 1, - template: { - metadata: { - labels: { - app: 'hello-openshift', - }, - }, - spec: { - containers: [ + containers: [ + { + name: 'hello-openshift', + image: 'openshift/hello-openshift', + ports: [ { - name: 'hello-openshift', - image: 'openshift/hello-openshift', - ports: [ - { - containerPort: 8080, - }, - ], + containerPort: 8080, }, ], }, - }, + ], }, }, - } as Props; + }, + } as K8sResourceKind); + +describe('ResourceLimitsModal Form', () => { + const limitsFormValues = { + limits: emptyLimits, + container: 'hello-openshift', + }; + + const renderModalWithFormikContext = (options?: { onSubmit?: jest.Mock; cancel?: jest.Mock }) => { + const onSubmit = options?.onSubmit ?? jest.fn(); + const cancel = options?.cancel ?? jest.fn(); + return { + onSubmit, + cancel, + ...renderWithProviders( + + {(formikProps) => ( + + )} + , + ), + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); }); it('renders the modal with the correct title and initial elements', () => { - renderWithProviders(); + renderModalWithFormikContext(); expect(screen.getByText('Edit resource limits')).toBeVisible(); expect(screen.getByRole('form')).toBeVisible(); @@ -70,16 +109,111 @@ describe('ResourceLimitsModal Form', () => { }); it('calls the cancel function when the Cancel button is clicked', async () => { - renderWithProviders(); + const user = userEvent.setup(); + const cancel = jest.fn(); + renderModalWithFormikContext({ cancel }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('submits the form when Save is clicked', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + renderModalWithFormikContext({ onSubmit }); + + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); + +describe('ResourceLimitsModal with validation (resource limits schema)', () => { + const renderModalWithFormik = (resource: K8sResourceKind) => { + const initialValues = { + limits: getLimitsDataFromResource(resource), + container: resource.spec.template.spec.containers[0].name, + }; + const onSubmit = jest.fn(); + + return { + onSubmit, + ...renderWithProviders( + + {(formikProps) => ( + + )} + , + ), + }; + }; + + it('populates CPU and Memory request/limit fields from the workload resource', () => { + const resource = baseDeployment(); + resource.spec.template.spec.containers[0].resources = { + requests: { cpu: '100m', memory: '128Mi' }, + limits: { cpu: '500m', memory: '256Mi' }, + }; + + renderModalWithFormik(resource); - await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - expect(formProps.cancel).toHaveBeenCalledTimes(1); + expect(screen.getByDisplayValue('100')).toBeVisible(); + expect(screen.getByDisplayValue('500')).toBeVisible(); + expect(screen.getByDisplayValue('128')).toBeVisible(); + expect(screen.getByDisplayValue('256')).toBeVisible(); }); - it('calls the handleSubmit function when the form is submitted', async () => { - renderWithProviders(); + it('disables Save when CPU request is greater than CPU limit', async () => { + const user = userEvent.setup(); + const resource = baseDeployment(); + resource.spec.template.spec.containers[0].resources = { + requests: { cpu: '100m', memory: '128Mi' }, + limits: { cpu: '200m', memory: '256Mi' }, + }; + + renderModalWithFormik(resource); + + const save = screen.getByRole('button', { name: 'Save' }); + expect(save).not.toBeDisabled(); + + const spinbuttons = screen.getAllByRole('spinbutton'); + await user.click(spinbuttons[0]); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('300'); + + await waitFor(() => { + expect(save).toBeDisabled(); + }); + expect( + screen.getByText('devconsole~CPU request must be less than or equal to limit.'), + ).toBeVisible(); + }); + + it('disables Save when Memory request is greater than Memory limit', async () => { + const user = userEvent.setup(); + const resource = baseDeployment(); + resource.spec.template.spec.containers[0].resources = { + requests: { cpu: '100m', memory: '128Mi' }, + limits: { cpu: '500m', memory: '256Mi' }, + }; + + renderModalWithFormik(resource); + + const save = screen.getByRole('button', { name: 'Save' }); + const spinbuttons = screen.getAllByRole('spinbutton'); + + await user.click(spinbuttons[2]); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('512'); - await fireEvent.submit(screen.getByRole('form')); - expect(formProps.handleSubmit).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(save).toBeDisabled(); + }); + expect( + screen.getByText('devconsole~Memory request must be less than or equal to limit.'), + ).toBeVisible(); }); }); diff --git a/frontend/packages/console-shared/src/components/empty-state/__tests__/empty-state.spec.tsx b/frontend/packages/console-shared/src/components/empty-state/__tests__/empty-state.spec.tsx index cae63450fdc..7d75f3f9008 100644 --- a/frontend/packages/console-shared/src/components/empty-state/__tests__/empty-state.spec.tsx +++ b/frontend/packages/console-shared/src/components/empty-state/__tests__/empty-state.spec.tsx @@ -1,33 +1,83 @@ -import { render } from '@testing-library/react'; +import { Button } from '@patternfly/react-core'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import { AccessDenied, EmptyBox, ConsoleEmptyState } from '..'; +const TestIcon = () => 'TestIcon'; + describe('EmptyBox', () => { - it('should render without label', () => { - const { getByText } = render(); - getByText('Not found'); + it('renders default "Not found" message without label', () => { + renderWithProviders(); + expect(screen.getByText('Not found')).toBeVisible(); }); - it('should render with label', () => { - const { getByText } = render(); - getByText('No test-label found'); + it('renders message with label when provided', () => { + renderWithProviders(); + expect(screen.getByText('No resources found')).toBeVisible(); }); }); -describe('MsgBox', () => { - it('should render title', () => { - const { getByText } = render(); - getByText('test-title'); +describe('ConsoleEmptyState', () => { + it('renders title and children in body', () => { + renderWithProviders( + Body content, + ); + expect(screen.getByText('Empty State Title')).toBeVisible(); + expect(screen.getByText('Body content')).toBeVisible(); + }); + + it('renders Icon when provided', () => { + renderWithProviders(); + expect(screen.getByText('TestIcon')).toBeVisible(); }); - it('should render children', () => { - const { getByText } = render(test-child); - getByText('test-child'); + it('renders primary and secondary actions when provided', () => { + const primaryActions = []; + const secondaryActions = [ + , + ]; + renderWithProviders( + , + ); + expect(screen.getByRole('button', { name: 'Create Resource' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Learn more' })).toBeVisible(); + }); + + it('does not render body or footer when not provided', () => { + renderWithProviders(); + expect(screen.queryByTestId('console-empty-state-body')).not.toBeInTheDocument(); + expect(screen.queryByTestId('console-empty-state-footer')).not.toBeInTheDocument(); }); }); describe('AccessDenied', () => { - it('should render message', () => { - const { getByText } = render(test-message); - getByText('test-message'); + it('renders restricted access title and message', () => { + renderWithProviders(); + expect(screen.getByText('Restricted access')).toBeVisible(); + expect( + screen.getByText("You don't have access to this section due to cluster policy"), + ).toBeVisible(); + }); + + it('renders error details alert when children provided', () => { + renderWithProviders(Permission denied for resource xyz); + expect(screen.getByText('Error details')).toBeVisible(); + expect(screen.getByText('Permission denied for resource xyz')).toBeVisible(); + }); + + it('does not render error alert when no children provided', () => { + renderWithProviders(); + expect(screen.queryByText('Error details')).not.toBeInTheDocument(); + }); + + it('renders restricted sign icon', () => { + renderWithProviders(); + expect(screen.getByAltText('Restricted access')).toBeVisible(); }); }); diff --git a/frontend/packages/console-shared/src/components/formik-fields/__tests__/NumberSpinnerField.spec.tsx b/frontend/packages/console-shared/src/components/formik-fields/__tests__/NumberSpinnerField.spec.tsx index e66c97001cf..f1383619d90 100644 --- a/frontend/packages/console-shared/src/components/formik-fields/__tests__/NumberSpinnerField.spec.tsx +++ b/frontend/packages/console-shared/src/components/formik-fields/__tests__/NumberSpinnerField.spec.tsx @@ -1,47 +1,69 @@ -import { screen, configure, fireEvent, act, waitFor } from '@testing-library/react'; +import { screen, configure, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { mockFormikRenderer } from '../../../test-utils/unit-test-utils'; import NumberSpinnerField from '../NumberSpinnerField'; -const getInput = () => screen.getByTestId('number-spinner-field') as HTMLInputElement; - configure({ testIdAttribute: 'data-test-id' }); -describe('Number Spinner Field', () => { - it('should render input field with type number', () => { - mockFormikRenderer(); - const input = getInput(); - expect(input.type).toEqual('number'); +const getInput = () => screen.getByRole('spinbutton') as HTMLInputElement; + +describe('NumberSpinnerField', () => { + it('renders input with label, help text, and increment/decrement buttons', () => { + mockFormikRenderer( + , + ); + expect(getInput()).toHaveAttribute('type', 'number'); + expect(screen.getByText('Replica Count')).toBeVisible(); + expect(screen.getByText('Enter a number')).toBeVisible(); + expect(screen.getByRole('button', { name: /increment/i })).toBeVisible(); + expect(screen.getByRole('button', { name: /decrement/i })).toBeVisible(); }); - it('should render input with increment decrement button', () => { - mockFormikRenderer(); - screen.getByTestId('Increment'); - screen.getByTestId('Decrement'); + it('displays initial value from formik', () => { + mockFormikRenderer(, { spinnerField: 5 }); + expect(getInput()).toHaveValue(5); }); - it('should only put numbers in input field', async () => { + it('displays 0 when initial value is empty', () => { mockFormikRenderer(, { spinnerField: '' }); + expect(getInput()).toHaveValue(0); + }); - expect(getInput().value).toEqual('0'); + it('updates value when typing in input field', async () => { + const user = userEvent.setup(); + mockFormikRenderer(, { spinnerField: '' }); - await act(async () => { - await fireEvent.change(getInput(), { - currentTarget: { value: '12' }, - target: { value: '12' }, - }); - }); + await user.clear(getInput()); + await user.type(getInput(), '42'); - await waitFor(() => expect(getInput().value).toEqual('12')); + await waitFor(() => expect(getInput()).toHaveValue(42)); }); - it('should increment or decrement value based on clicked button', () => { - mockFormikRenderer(, { spinnerField: '' }); + it('increments value when increment button is clicked', async () => { + const user = userEvent.setup(); + mockFormikRenderer(, { spinnerField: 0 }); + + await user.click(screen.getByRole('button', { name: /increment/i })); + + expect(getInput()).toHaveValue(1); + }); + + it('decrements value when decrement button is clicked', async () => { + const user = userEvent.setup(); + mockFormikRenderer(, { spinnerField: 5 }); + + await user.click(screen.getByRole('button', { name: /decrement/i })); + + expect(getInput()).toHaveValue(4); + }); + + it('associates help text with input via aria-describedby', () => { + mockFormikRenderer(); + expect(getInput()).toHaveAttribute('aria-describedby'); + }); - expect(getInput().value).toEqual('0'); - fireEvent.click(screen.getByTestId('Increment')); - fireEvent.click(screen.getByTestId('Increment')); - expect(getInput().value).toEqual('2'); - fireEvent.click(screen.getByTestId('Decrement')); - expect(getInput().value).toEqual('1'); + it('input is accessible by spinbutton role', () => { + mockFormikRenderer(); + expect(screen.getByRole('spinbutton')).toBeVisible(); }); }); diff --git a/frontend/packages/console-shared/src/components/formik-fields/key-value-file-input-field/__tests__/KeyValueFileInputField.spec.tsx b/frontend/packages/console-shared/src/components/formik-fields/key-value-file-input-field/__tests__/KeyValueFileInputField.spec.tsx index d5d61180493..ac43db67bb2 100644 --- a/frontend/packages/console-shared/src/components/formik-fields/key-value-file-input-field/__tests__/KeyValueFileInputField.spec.tsx +++ b/frontend/packages/console-shared/src/components/formik-fields/key-value-file-input-field/__tests__/KeyValueFileInputField.spec.tsx @@ -1,10 +1,15 @@ import type { FC } from 'react'; -import { screen, render, fireEvent, act, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import type { FormikConfig, FormikErrors } from 'formik'; import { Formik } from 'formik'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import KeyValueFileInputField from '../KeyValueFileInputField'; +jest.mock('@patternfly/react-topology', () => ({})); + const onSubmit = jest.fn(); + const TestKeyValueInputField: FC & { disableRemoveAction?: boolean }> = ({ initialValues, disableRemoveAction, @@ -23,97 +28,98 @@ const TestKeyValueInputField: FC & { disableRemoveAction?: boo }} > ); -test('should have validation error given input field is touched and error exists on form', async () => { - render( - , - ); - - const KeyField = screen.getByTestId('key-0'); - act(() => { - fireEvent.click(KeyField); - fireEvent.blur(KeyField); +describe('KeyValueFileInputField', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - const validationErrors = await screen.findByText(`Required`); - expect(validationErrors.innerHTML).toContain('Required'); -}); + it('shows validation error when key is empty and field is touched', async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); -test('should have remove key value pair button if there are more than one key value entries', async () => { - render( - , - ); - - expect(await screen.findAllByTestId('remove-key-value-button')).toHaveLength(2); -}); + const keyField = screen.getByRole('textbox', { name: 'Key' }); + await user.click(keyField); + await user.tab(); -test('should not contain remove key value pair button if there is one entry', async () => { - render( - , - ); - - await waitFor(() => { - const removeButton = screen.queryByTestId('remove-key-value-button'); - expect(removeButton).toBeNull(); + expect(await screen.findByText('Required')).toBeVisible(); }); -}); -test('should contain remove key value pair button if there is one entry and remove actions is set to false', async () => { - render( - , - ); - - await waitFor(() => { - const removeButton = screen.queryByTestId('remove-key-value-button'); - expect(removeButton).not.toBeNull(); + it('shows a remove control for each row when there is more than one entry', async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: 'Remove key/value' })).toHaveLength(2); + }); }); -}); -test('should add new entry on clicking Add key/value button', async () => { - render( - , - ); - const addKeyValueButton = screen.getByTestId('add-key-value-button'); - fireEvent.click(addKeyValueButton); - - await waitFor(() => { - const keyValuePair = screen.queryAllByTestId('key-value-pair'); - expect(keyValuePair).toHaveLength(2); + it('does not show remove controls when disableRemoveAction is true', async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Remove key/value' })).not.toBeInTheDocument(); + }); + }); + + it('shows remove control for a single entry when disableRemoveAction is false', async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Remove key/value' })).toBeVisible(); + }); + }); + + it('adds a new row when Add key/value is activated', async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + expect(screen.getAllByRole('textbox', { name: 'Key' })).toHaveLength(1); + + await user.click(screen.getByRole('button', { name: 'Add key/value' })); + + await waitFor(() => { + expect(screen.getAllByRole('textbox', { name: 'Key' })).toHaveLength(2); + }); }); }); diff --git a/frontend/packages/console-shared/src/components/getting-started/__tests__/GettingStartedCard.spec.tsx b/frontend/packages/console-shared/src/components/getting-started/__tests__/GettingStartedCard.spec.tsx index cde12f6c5af..26dc475e0b8 100644 --- a/frontend/packages/console-shared/src/components/getting-started/__tests__/GettingStartedCard.spec.tsx +++ b/frontend/packages/console-shared/src/components/getting-started/__tests__/GettingStartedCard.spec.tsx @@ -1,91 +1,113 @@ -import { screen, fireEvent } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import type { GettingStartedCardProps } from '../GettingStartedCard'; import { GettingStartedCard } from '../GettingStartedCard'; +jest.mock('@console/shared/src/hooks/useTelemetry', () => ({ + useTelemetry: () => jest.fn(), +})); + +jest.mock('@console/dynamic-plugin-sdk/src/perspective/useActivePerspective', () => ({ + default: jest.fn(() => ['admin', jest.fn()]), +})); + +jest.mock('@patternfly/react-icons', () => ({ + ArrowRightIcon: () => 'ArrowRightIcon', + ExternalLinkAltIcon: () => 'ExternalLinkAltIcon', +})); + describe('GettingStartedCard', () => { const defaultProps: GettingStartedCardProps = { id: 'test-card', title: 'Test Card', description: 'This is a test card.', links: [ - { - id: 'link-1', - title: 'Internal Link', - href: '/internal', - }, - { - id: 'link-2', - title: 'External Link', - href: 'https://example.com', - external: true, - }, + { id: 'link-1', title: 'Internal Link', href: '/internal' }, + { id: 'link-2', title: 'External Link', href: 'https://example.com', external: true }, ], - moreLink: { - id: 'more-link', - title: 'More Info', - href: '/more', - }, + moreLink: { id: 'more-link', title: 'More Info', href: '/more' }, }; - it('renders title and description', () => { + it('renders card title, description, and icon', async () => { + const TestIcon = () =>
TestIcon
; + renderWithProviders(} />); + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Test Card/i, level: 3 })).toBeVisible(); + }); + expect(screen.getByText('This is a test card.')).toBeVisible(); + expect(screen.getByText('TestIcon')).toBeVisible(); + }); + + it('renders internal links with arrow icon', async () => { + renderWithProviders(); + const internalLink = await screen.findByTestId('item link-1'); + expect(within(internalLink).getByText('Internal Link')).toBeVisible(); + expect(within(internalLink).getByText('ArrowRightIcon')).toBeVisible(); + }); + + it('renders external links with external link icon and target blank', async () => { renderWithProviders(); - expect(screen.getByText('Test Card')).toBeInTheDocument(); - expect(screen.getByText('This is a test card.')).toBeInTheDocument(); + const externalLink = await screen.findByTestId('item link-2'); + expect(within(externalLink).getByText('External Link')).toBeVisible(); + expect(within(externalLink).getByText('ExternalLinkAltIcon')).toBeVisible(); + expect(externalLink).toHaveAttribute('target', '_blank'); + expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer'); }); - it('renders all links', () => { + it('renders moreLink as internal link or button based on props', async () => { renderWithProviders(); - expect(screen.getByTestId('item link-1')).toBeInTheDocument(); - expect(screen.getByTestId('item link-2')).toBeInTheDocument(); - expect(screen.getByTestId('item more-link')).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'More Info' })).toHaveAttribute('href', '/more'); }); - it('calls onClick for internal link', () => { + it('renders moreLink as button when onClick is provided and calls handler', async () => { + const user = userEvent.setup(); const onClick = jest.fn(); - const props = { - ...defaultProps, - links: [ - { - id: 'link-1', - title: 'Internal Link', - href: '/internal', - onClick, - }, - ], - }; + const props = { ...defaultProps, moreLink: { id: 'more-link', title: 'More Info', onClick } }; renderWithProviders(); - fireEvent.click(screen.getByTestId('item link-1')); - expect(onClick).toHaveBeenCalled(); + await user.click(await screen.findByRole('button', { name: 'More Info' })); + expect(onClick).toHaveBeenCalledTimes(1); }); - it('calls onClick for moreLink', () => { + it('calls onClick for internal link when clicked', async () => { + const user = userEvent.setup(); const onClick = jest.fn(); const props = { ...defaultProps, - moreLink: { - id: 'more-link', - title: 'More Info', - href: '/more', - onClick, - }, + links: [{ id: 'link-1', title: 'Internal Link', href: '/internal', onClick }], }; renderWithProviders(); - fireEvent.click(screen.getByTestId('item more-link')); - expect(onClick).toHaveBeenCalled(); + await user.click(await screen.findByTestId('item link-1')); + expect(onClick).toHaveBeenCalledTimes(1); }); - it('renders skeleton for loading links', () => { - const props = { - ...defaultProps, - links: [ - { - id: 'loading-link', - loading: true, - }, - ], - }; + it('renders skeleton for loading links', async () => { + const props = { ...defaultProps, links: [{ id: 'loading-link', loading: true }] }; + renderWithProviders(); + expect(await screen.findByTestId('getting-started-skeleton')).toBeVisible(); + }); + + it('does not render links section when links array is empty', async () => { + const props = { ...defaultProps, links: [] }; + renderWithProviders(); + await waitFor(() => { + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + }); + + it('does not render moreLink when not provided', async () => { + const props = { ...defaultProps, moreLink: undefined }; + renderWithProviders(); + await waitFor(() => { + expect(screen.queryByRole('link', { name: 'More Info' })).not.toBeInTheDocument(); + }); + }); + + it('applies custom title color when provided', async () => { + const props = { ...defaultProps, titleColor: '#ff0000' }; renderWithProviders(); - expect(screen.getByTestId('getting-started-skeleton')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'Test Card' })).toHaveStyle({ + color: '#ff0000', + }); }); }); diff --git a/frontend/packages/console-shared/src/components/loading/__tests__/loading.spec.tsx b/frontend/packages/console-shared/src/components/loading/__tests__/loading.spec.tsx index e0c35d02913..fe37965cafc 100644 --- a/frontend/packages/console-shared/src/components/loading/__tests__/loading.spec.tsx +++ b/frontend/packages/console-shared/src/components/loading/__tests__/loading.spec.tsx @@ -1,4 +1,5 @@ -import { render } from '@testing-library/react'; +import { screen, render } from '@testing-library/react'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import { LoadError } from '../LoadError'; import { Loading } from '../Loading'; import { LoadingBox } from '../LoadingBox'; @@ -8,57 +9,66 @@ const label = 'foo'; const message = 'bar'; describe('LoadError', () => { - it('should render info with label and message', () => { - const { getByText } = render({message}); - getByText(`Error loading ${label}`); - getByText(message); + it('renders info with label and message', () => { + renderWithProviders({message}); + + expect(screen.getByText(`Error loading ${label}`)).toBeVisible(); + expect(screen.getByText(message)).toBeVisible(); }); - it('should render info with label and without message', () => { - const { getByText } = render(); - getByText(`Error loading ${label}`); + it('renders info with label and without message', () => { + renderWithProviders(); + + expect(screen.getByText(`Error loading ${label}`)).toBeVisible(); }); - it('should render with retry button', () => { - const { getByText } = render(); - getByText('Try again'); + it('renders with retry button', () => { + renderWithProviders(); + + expect(screen.getByRole('button', { name: 'Try again' })).toBeVisible(); }); - it('should render without retry button', () => { - const { queryByText } = render(); - expect(queryByText('Try again')).toBeNull(); + it('renders without retry button', () => { + renderWithProviders(); + + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); }); }); describe('Loading', () => { - it('should render loading indicator', () => { - const { getByTestId } = render(); - getByTestId('loading-indicator'); + it('renders loading indicator', () => { + render(); + + expect(screen.getByTestId('loading-indicator')).toBeVisible(); }); }); describe('LoadingInline', () => { - it('should render inline loading indicator', async () => { - const { getByTestId } = render(); - const el = await getByTestId('loading-indicator'); + it('renders inline loading indicator', () => { + render(); + + const el = screen.getByTestId('loading-indicator'); + expect(el).toBeVisible(); expect(el).toHaveClass('co-m-loader--inline'); }); }); describe('LoadingBox', () => { - it('should render loading box', () => { - const { getByTestId } = render(); - getByTestId('loading-indicator'); + it('renders loading box', () => { + render(); + + expect(screen.getByTestId('loading-indicator')).toBeVisible(); }); - it('should render children', () => { - const { getByText } = render({message}); - getByText(message); + it('renders children', () => { + render({message}); + + expect(screen.getByText(message)).toBeVisible(); }); - it('should not render blame info when query param disabled', () => { - // can't test the other way around without some hacks - const { queryByText } = render(); - expect(queryByText(label)).toBeNull(); + it('does not render blame info when query param disabled', () => { + render(); + + expect(screen.queryByText(label)).not.toBeInTheDocument(); }); }); diff --git a/frontend/packages/console-shared/src/components/status/__tests__/status-box.spec.tsx b/frontend/packages/console-shared/src/components/status/__tests__/status-box.spec.tsx index f8e753fcf2f..5543d9132ce 100644 --- a/frontend/packages/console-shared/src/components/status/__tests__/status-box.spec.tsx +++ b/frontend/packages/console-shared/src/components/status/__tests__/status-box.spec.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { useLocation } from 'react-router'; import { IncompleteDataError, @@ -19,24 +19,29 @@ jest.mock('@console/internal/components/useFavoritesOptions', () => ({ const useLocationMock = useLocation as jest.Mock; describe('StatusBox', () => { - it('should render 404: Page Not Found if the loadError status is 404', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders 404: Page Not Found when loadError status is 404', () => { useLocationMock.mockReturnValue({ pathname: '' }); useFavoritesOptionsMock.mockReturnValue([[], jest.fn(), true]); - const { getByText } = render(); - getByText('404: Page Not Found'); + render(); + + expect(screen.getByText('404: Page Not Found')).toBeVisible(); }); - it('should render access denied info together with the error message', () => { - const { getByText } = render( - , - ); + it('renders access denied info together with the error message', () => { + render(); - getByText("You don't have access to this section due to cluster policy"); - getByText('test-message'); + expect( + screen.getByText("You don't have access to this section due to cluster policy"), + ).toBeVisible(); + expect(screen.getByText('test-message')).toBeVisible(); }); - it('should render a patternfly alert together with its children when an IncompleteDataError occured', () => { - const { getByText } = render( + it('renders a PatternFly alert with children when IncompleteDataError occurs', () => { + render( { , ); - getByText( - 'Test, RedHat, and Hello World content is not available in the catalog at this time due to loading failures.', - ); - getByText('my-children'); + expect( + screen.getByText( + 'Test, RedHat, and Hello World content is not available in the catalog at this time due to loading failures.', + ), + ).toBeVisible(); + expect(screen.getByText('my-children')).toBeVisible(); }); - it('should render an info together with its children when loaded and a TimeOutError ocurred', () => { - const { getByText } = render( + it('renders stale-data info with children when loaded and TimeoutError occurs', () => { + render( my-children , ); - getByText('Timed out fetching new data. The data below is stale.'); - getByText('my-children'); + expect(screen.getByText('Timed out fetching new data. The data below is stale.')).toBeVisible(); + expect(screen.getByText('my-children')).toBeVisible(); }); - it("should render skeleton when not loaded and there's no error", () => { - const { getByTestId } = render(); - getByTestId('loading-indicator'); + it('renders skeleton when not loaded and there is no error', () => { + render(); + + expect(screen.getByTestId('loading-indicator')).toBeVisible(); }); - it("should render its children when loaded and there's no error", () => { - const { getByText } = render( + it('renders children when loaded and there is no error', () => { + render( my-children , ); - getByText('my-children'); + expect(screen.getByText('my-children')).toBeVisible(); }); }); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/__tests__/catalog-source.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/catalog-source.spec.tsx index 13d3a38630d..4f70acd9640 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/__tests__/catalog-source.spec.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/__tests__/catalog-source.spec.tsx @@ -9,14 +9,16 @@ import { import { referenceForModel } from '@console/internal/module/k8s'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import { testCatalogSource, testPackageManifest, dummyPackageManifest } from '../../../mocks'; -import { CatalogSourceModel, PackageManifestModel } from '../../models'; +import { DEFAULT_SOURCE_NAMESPACE } from '../../const'; +import { CatalogSourceModel } from '../../models'; import { CatalogSourceDetails, CatalogSourceDetailsPage, CreateSubscriptionYAML, - CatalogSourceOperatorsPage, } from '../catalog-source'; +jest.mock('@patternfly/react-topology', () => ({})); + jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({ useK8sWatchResource: jest.fn(), useK8sWatchResources: jest.fn(), @@ -38,13 +40,25 @@ jest.mock('@console/internal/components/factory', () => ({ jest.mock('@console/internal/components/utils', () => ({ ...jest.requireActual('@console/internal/components/utils'), LoadingBox: jest.fn(() => 'Loading...'), - ResourceSummary: jest.fn(() => null), - SectionHeading: jest.fn(() => null), - DetailsItem: jest.fn(({ obj, path, children }) => { - if (children) return children; + ResourceSummary: jest.fn(() => 'ResourceSummary'), + SectionHeading: jest.fn(({ text }) => text), + DetailsItem: jest.fn(({ obj, path, children, label }) => { + if (children) { + return ( +
+ {label && {label}} + {children} +
+ ); + } if (path) { const value = path.split('.').reduce((acc, key) => acc?.[key], obj); - return value || null; + return value ? ( +
+ {label && {label}} + {value} +
+ ) : null; } return null; }), @@ -94,14 +108,34 @@ describe('CatalogSourceDetails', () => { obj = _.cloneDeep(testCatalogSource); }); - it('displays catalog source name and publisher', () => { + it('renders catalog source details with display name and publisher', () => { renderWithProviders( , ); - + expect(screen.getByText(/CatalogSource details/i)).toBeVisible(); expect(screen.getByText(obj.spec.displayName, { exact: false })).toBeVisible(); expect(screen.getByText(obj.spec.publisher, { exact: false })).toBeVisible(); }); + + it('displays availability based on namespace', () => { + const clusterWideSource = { + ...obj, + metadata: { ...obj.metadata, namespace: DEFAULT_SOURCE_NAMESPACE }, + }; + renderWithProviders( + , + ); + expect(screen.getByText('Cluster wide')).toBeVisible(); + }); + + it('displays image endpoint when spec.image is set', () => { + const sourceWithImage = { + ...obj, + spec: { ...obj.spec, image: 'quay.io/my-registry/my-catalog:latest' }, + }; + renderWithProviders(); + expect(screen.getByText('quay.io/my-registry/my-catalog:latest')).toBeVisible(); + }); }); describe('CatalogSourceDetailsPage', () => { @@ -115,42 +149,26 @@ describe('CatalogSourceDetailsPage', () => { jest.restoreAllMocks(); }); - it('renders catalog source details page without errors', () => { - expect(() => { - renderWithProviders(); - }).not.toThrow(); - }); + it('renders without errors and configures DetailsPage with correct props', () => { + jest.spyOn(Router, 'useParams').mockReturnValue({ ns: 'my-namespace', name: 'my-catalog' }); - // TODO: Refactor to test user behavior instead of implementation details - it('configures DetailsPage with correct navigation and resources', () => { renderWithProviders(); expect(mockDetailsPage).toHaveBeenCalledTimes(1); const [detailsPageProps] = mockDetailsPage.mock.calls[0]; - + expect(detailsPageProps.namespace).toEqual('my-namespace'); + expect(detailsPageProps.name).toEqual('my-catalog'); expect(detailsPageProps.kind).toEqual(referenceForModel(CatalogSourceModel)); + }); - expect(detailsPageProps.pages).toHaveLength(3); - expect(detailsPageProps.pages[0]).toMatchObject({ - nameKey: 'public~Details', - component: CatalogSourceDetails, - }); - expect(detailsPageProps.pages[1]).toMatchObject({ - nameKey: 'public~YAML', - }); - expect(detailsPageProps.pages[2]).toMatchObject({ - nameKey: 'olm~Operators', - component: CatalogSourceOperatorsPage, - }); + it('configures DetailsPage with Details, YAML, and Operators tabs', () => { + renderWithProviders(); - expect(detailsPageProps.resources).toEqual([ - { - kind: referenceForModel(PackageManifestModel), - isList: true, - prop: 'packageManifests', - namespace: 'default', - }, - ]); + const [detailsPageProps] = mockDetailsPage.mock.calls[0]; + expect(detailsPageProps.pages).toHaveLength(3); + expect(detailsPageProps.pages[0].nameKey).toEqual('public~Details'); + expect(detailsPageProps.pages[1].nameKey).toEqual('public~YAML'); + expect(detailsPageProps.pages[2].nameKey).toEqual('olm~Operators'); }); }); @@ -175,38 +193,46 @@ describe('CreateSubscriptionYAML', () => { jest.restoreAllMocks(); }); - it('displays package name in the subscription YAML when loaded', () => { + it('displays loading indicator when package manifest is not yet loaded', () => { mockUseK8sWatchResources.mockReturnValue({ - packageManifest: { loaded: true, data: testPackageManifest, loadError: null }, - operatorGroup: { loaded: true, data: [], loadError: null }, + packageManifest: { loaded: false, data: undefined, loadError: null }, + operatorGroup: { loaded: false, data: undefined, loadError: null }, }); renderWithProviders(); - - expect(screen.getByText(new RegExp(testPackageManifest.metadata.name))).toBeVisible(); + expect(screen.getByText('Loading...')).toBeVisible(); }); - it('displays loading indicator when package manifest is not yet loaded', () => { + it('displays subscription YAML with package name, channel, and catalog info when loaded', () => { mockUseK8sWatchResources.mockReturnValue({ - packageManifest: { loaded: false, data: undefined, loadError: null }, - operatorGroup: { loaded: false, data: undefined, loadError: null }, + packageManifest: { loaded: true, data: testPackageManifest, loadError: null }, + operatorGroup: { loaded: true, data: [], loadError: null }, }); renderWithProviders(); - expect(screen.getByText('Loading...')).toBeVisible(); + expect(screen.getByText(new RegExp(testPackageManifest.metadata.name))).toBeVisible(); + expect(screen.getByText(/channel:\s*alpha/)).toBeVisible(); + expect(screen.getByText(/source:\s*ocs/)).toBeVisible(); + expect(screen.getByText(/startingCSV:\s*testapp/)).toBeVisible(); }); - it('displays subscription YAML with default channel information', () => { + it('uses first channel when no default channel is specified', () => { + const packageWithoutDefault = { + ...testPackageManifest, + status: { + ...testPackageManifest.status, + defaultChannel: undefined, + channels: [{ name: 'beta', currentCSV: 'testapp-beta' }], + }, + }; + mockUseK8sWatchResources.mockReturnValue({ - packageManifest: { loaded: true, data: testPackageManifest, loadError: null }, + packageManifest: { loaded: true, data: packageWithoutDefault, loadError: null }, operatorGroup: { loaded: true, data: [], loadError: null }, }); renderWithProviders(); - - expect(screen.getByText(/channel:\s*alpha/)).toBeInTheDocument(); - expect(screen.getByText(/source:\s*ocs/)).toBeInTheDocument(); - expect(screen.getByText(/startingCSV:\s*testapp/)).toBeInTheDocument(); + expect(screen.getByText(/channel:\s*beta/)).toBeVisible(); }); }); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/index.spec.tsx b/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/index.spec.tsx index 26117e0dd34..73062aeb872 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/index.spec.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/operand/__tests__/index.spec.tsx @@ -1,18 +1,46 @@ +/** + * Tests multiple exports from `operand/index.tsx` (large barrel module). + * Intentionally keeps several describe blocks in one file; see CONSOLE RTL guidance for this pattern. + */ +import type { ReactNode } from 'react'; import { screen } from '@testing-library/react'; import * as _ from 'lodash'; import * as ReactRouter from 'react-router'; import { DetailsPage } from '@console/internal/components/factory'; +import type { + CustomResourceDefinitionKind, + K8sKind, + K8sResourceKind, +} from '@console/internal/module/k8s'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; -import { testResourceInstance, testClusterServiceVersion } from '../../../../mocks'; +import { testClusterServiceVersion, testResourceInstance } from '../../../../mocks'; import { ClusterServiceVersionModel } from '../../../models'; +import type { ClusterServiceVersionKind } from '../../../types'; import { - OperandTableRow, + OperandDetails, OperandDetailsPage, - ProvidedAPIsPage, - ProvidedAPIPage, OperandStatus, + OperandTableRow, + ProvidedAPIPage, + ProvidedAPIsPage, } from '../index'; +jest.mock('@patternfly/react-topology', () => ({})); + +jest.mock('@console/internal/components/utils/k8s-watch-hook', () => { + const actual = jest.requireActual('@console/internal/components/utils/k8s-watch-hook'); + return { + ...actual, + // Avoid reselect dev warning ("result function returned its own inputs") from real hook in tests. + useK8sWatchResources: jest.fn(() => ({})), + }; +}); + +jest.mock('@console/internal/kinds', () => ({ + ...jest.requireActual('@console/internal/kinds'), + connectToModel: (Component: unknown) => Component, +})); + jest.mock('react-router', () => ({ ...jest.requireActual('react-router'), useParams: jest.fn(), @@ -68,8 +96,91 @@ jest.mock('@console/internal/components/factory', () => ({ DetailsPage: jest.fn(() => null), })); +jest.mock('@patternfly/react-core', () => { + const actual = jest.requireActual('@patternfly/react-core'); + return { + ...actual, + Grid: ({ children }: { children?: ReactNode }) =>
{children}
, + GridItem: ({ children }: { children?: ReactNode }) =>
{children}
, + DescriptionList: ({ children }: { children?: ReactNode }) =>
{children}
, + }; +}); + +jest.mock('@console/shared/src/components/layout/PaneBody', () => ({ + __esModule: true, + default: ({ children, ...rest }: { children?: ReactNode; 'data-test'?: string }) => ( +
{children}
+ ), +})); + +jest.mock('@console/internal/components/utils', () => ({ + ...jest.requireActual('@console/internal/components/utils'), + ResourceSummary: () =>
Resource summary
, + SectionHeading: ({ text, ...rest }: { text: string; 'data-test'?: string }) => ( +

{text}

+ ), +})); + +jest.mock('../../descriptors', () => ({ + ...jest.requireActual('../../descriptors'), + DescriptorDetailsItem: () => null, + DescriptorDetailsItems: () => null, +})); + +jest.mock('../../descriptors/status/conditions', () => ({ + DescriptorConditions: () => null, +})); + const mockDetailsPage = DetailsPage as jest.Mock; +const testKindObj = { + apiGroup: 'samples.console.openshift.io', + apiVersion: 'v1alpha1', + kind: 'Sample', + label: 'Sample', + labelPlural: 'Samples', + plural: 'samples', + namespaced: true, + abbr: 'S', + crd: true, +} as K8sKind; + +const testCrd = { + apiVersion: 'apiextensions.k8s.io/v1', + kind: 'CustomResourceDefinition', + metadata: { name: 'samples.samples.console.openshift.io' }, + spec: { + versions: [{ name: 'v1alpha1', schema: { openAPIV3Schema: { type: 'object' } } }], + }, +} as CustomResourceDefinitionKind; + +const testCsv = { + apiVersion: 'operators.coreos.com/v1alpha1', + kind: 'ClusterServiceVersion', + metadata: { name: 'sample-csv', namespace: 'openshift-operators' }, + spec: { + customresourcedefinitions: { + owned: [ + { + name: 'samples.samples.console.openshift.io', + version: 'v1alpha1', + kind: 'Sample', + displayName: 'Sample Operand', + specDescriptors: [], + statusDescriptors: [], + }, + ], + }, + }, +} as ClusterServiceVersionKind; + +const baseOperand = { + apiVersion: 'samples.console.openshift.io/v1alpha1', + kind: 'Sample', + metadata: { name: 'example', namespace: 'ns' }, + status: {}, +} as K8sResourceKind; + describe('OperandTableRow', () => { it('renders operand name and namespace when provided', () => { renderWithProviders( @@ -82,8 +193,8 @@ describe('OperandTableRow', () => { , ); - expect(screen.getByText(testResourceInstance.metadata.name)).toBeInTheDocument(); - expect(screen.getByText(testResourceInstance.metadata.namespace)).toBeInTheDocument(); + expect(screen.getByText(testResourceInstance.metadata.name)).toBeVisible(); + expect(screen.getByText(testResourceInstance.metadata.namespace)).toBeVisible(); }); it('renders operand kind', () => { @@ -97,99 +208,134 @@ describe('OperandTableRow', () => { , ); - expect(screen.getByText(testResourceInstance.kind)).toBeInTheDocument(); + expect(screen.getByText(testResourceInstance.kind)).toBeVisible(); }); }); describe('OperandStatus', () => { - it('displays status when status field is present', () => { + it('displays phase when present (highest priority)', () => { + const operand = { status: { phase: 'Running', status: 'Active', state: 'Ready' } }; + renderWithProviders(); + expect(screen.getByText('Phase')).toBeVisible(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + }); + + it('displays status when phase is not present', () => { + const operand = { status: { status: 'Active', state: 'Ready' } }; + renderWithProviders(); + expect(screen.getByText('Status')).toBeVisible(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Active'); + }); + + it('displays state when phase and status are not present', () => { + const operand = { status: { state: 'Ready' } }; + renderWithProviders(); + expect(screen.getByText('State')).toBeVisible(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Ready'); + }); + + it('displays condition type when conditions array has true status', () => { const operand = { status: { - status: 'Running', - state: 'Degraded', conditions: [ - { - type: 'Failed', - status: 'True', - }, + { type: 'Failed', status: 'False' }, + { type: 'Available', status: 'True' }, ], }, }; - renderWithProviders(); - - expect(screen.getByText('Status')).toBeInTheDocument(); - expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + expect(screen.getByText('Condition')).toBeVisible(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Available'); }); - it('displays phase when phase field is present', () => { + it('displays multiple conditions when more than one is true', () => { const operand = { status: { - phase: 'Running', - status: 'Installed', - state: 'Degraded', conditions: [ - { - type: 'Failed', - status: 'True', - }, + { type: 'Ready', status: 'True' }, + { type: 'Available', status: 'True' }, ], }, }; - renderWithProviders(); - - expect(screen.getByText('Phase')).toBeInTheDocument(); - expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + expect(screen.getByText('Conditions')).toBeVisible(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Ready, Available'); }); - it('displays state when only state field is present', () => { + it('displays condition when status.conditions is a single object with true status', () => { const operand = { status: { - state: 'Running', - conditions: [ - { - type: 'Failed', - status: 'True', - }, - ], + conditions: { type: 'Installed', status: 'True' }, }, }; - renderWithProviders(); - - expect(screen.getByText('State')).toBeInTheDocument(); - expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + expect(screen.getByText('Condition')).toBeVisible(); + expect(screen.getByTestId('status-text')).toHaveTextContent('Installed'); }); - it('displays condition type when condition status is True', () => { + it('displays dash when conditions exist but none are true', () => { const operand = { status: { conditions: [ - { - type: 'Failed', - status: 'False', - }, - { - type: 'Running', - status: 'True', - }, + { type: 'Ready', status: 'False' }, + { type: 'Available', status: 'False' }, ], }, }; - renderWithProviders(); - - expect(screen.getByText('Condition')).toBeInTheDocument(); - expect(screen.getByTestId('status-text')).toHaveTextContent('Running'); + expect(screen.getByText('-')).toBeVisible(); }); it('displays dash when no status information is available', () => { - const operand = {}; + renderWithProviders(); + expect(screen.getByText('-')).toBeVisible(); + }); + it('displays dash when status object exists but has no recognizable fields', () => { + const operand = { status: { someOtherField: 'value' } }; renderWithProviders(); + expect(screen.getByText('-')).toBeVisible(); + }); +}); + +describe('OperandDetails', () => { + it('renders overview heading from CSV display name and resource summary', () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId('operand-overview-heading')).toHaveTextContent(/Sample Operand/); + expect(screen.getByTestId('operand-resource-summary')).toBeVisible(); + expect(screen.getByTestId('operand-pane-body')).toBeVisible(); + }); + + it('renders conditions region when status.conditions is present and no conditions descriptor overrides it', () => { + const operandWithConditions = { + ...baseOperand, + status: { + conditions: [{ type: 'Available', status: 'True', message: 'Ready' }], + }, + } as K8sResourceKind; + + renderWithProviders( + , + ); - expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.getByTestId('operand-conditions-heading')).toBeVisible(); + expect(screen.getByTestId('operand-conditions-heading')).toHaveTextContent('Conditions'); + expect(screen.getByTestId('status.conditions')).toBeVisible(); }); }); @@ -223,7 +369,6 @@ describe('OperandDetailsPage', () => { it('configures DetailsPage with Details, YAML, Resources, and Events tabs', () => { renderWithProviders(); - // One call for initial render and another for when pluginStore is initialized expect(mockDetailsPage).toHaveBeenCalledTimes(2); const [detailsPageProps] = mockDetailsPage.mock.calls[0]; @@ -265,7 +410,7 @@ describe('ProvidedAPIsPage', () => { renderWithProviders(); - expect(screen.getByText('Create new')).toBeInTheDocument(); + expect(screen.getByText('Create new')).toBeVisible(); }); }); diff --git a/frontend/public/components/utils/__tests__/selector-input.spec.tsx b/frontend/public/components/utils/__tests__/selector-input.spec.tsx new file mode 100644 index 00000000000..e7ec58ea4a8 --- /dev/null +++ b/frontend/public/components/utils/__tests__/selector-input.spec.tsx @@ -0,0 +1,106 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { SelectorInput } from '../selector-input'; + +describe('SelectorInput', () => { + const defaultProps = { + tags: [], + onChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('static methods', () => { + it('arrayify converts object to key=value strings', () => { + expect(SelectorInput.arrayify({ app: 'frontend', env: 'prod' })).toEqual([ + 'app=frontend', + 'env=prod', + ]); + expect(SelectorInput.arrayify({ app: null })).toEqual(['app']); + expect(SelectorInput.arrayify({})).toEqual([]); + }); + + it('objectify converts key=value strings to object', () => { + expect(SelectorInput.objectify(['app=frontend', 'env=prod'])).toEqual({ + app: 'frontend', + env: 'prod', + }); + expect(SelectorInput.objectify(['app'])).toEqual({ app: null }); + expect(SelectorInput.objectify([])).toEqual({}); + }); + + it('arrayObjectsToArrayStrings converts requirement objects to strings', () => { + const input = [ + { key: 'app', operator: 'In', values: ['frontend', 'backend'] }, + { key: 'env', operator: 'NotIn', values: ['test'] }, + ]; + expect(SelectorInput.arrayObjectsToArrayStrings(input)).toEqual([ + 'app in (frontend,backend)', + 'env notin (test)', + ]); + }); + + it('arrayToArrayOfObjects converts requirement strings to objects', () => { + expect(SelectorInput.arrayToArrayOfObjects(['app in (frontend,backend)'])).toEqual([ + { key: 'app', operator: 'In', values: ['frontend', 'backend'] }, + ]); + }); + }); + + describe('rendering', () => { + it('renders input field with placeholder and existing tags', () => { + renderWithProviders(); + expect(screen.getByTestId('tags-input')).toHaveAttribute('placeholder', ''); + expect(screen.getByText('app=frontend')).toBeVisible(); + expect(screen.getByText('env=prod')).toBeVisible(); + }); + + it('renders default placeholder when no tags exist', () => { + renderWithProviders(); + expect(screen.getByTestId('tags-input')).toHaveAttribute('placeholder', 'app=frontend'); + }); + }); + + describe('validation', () => { + it('shows invalid state for malformed input and clears when corrected', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByTestId('tags-input'); + await user.type(input, 'invalid tag'); + await waitFor(() => expect(input).toHaveClass('invalid-tag')); + + await user.clear(input); + await waitFor(() => expect(input).not.toHaveClass('invalid-tag')); + }); + + it('calls onValidationChange when validation state changes', async () => { + const user = userEvent.setup(); + const onValidationChange = jest.fn(); + renderWithProviders( + , + ); + + const input = screen.getByTestId('tags-input'); + await user.type(input, 'invalid tag'); + await waitFor(() => expect(onValidationChange).toHaveBeenCalledWith(false)); + }); + }); + + describe('tag removal', () => { + it('calls onChange when close button is clicked', async () => { + const onChange = jest.fn(); + renderWithProviders( + , + ); + + const user = userEvent.setup(); + const closeButtons = screen.getAllByRole('button'); + await user.click(closeButtons[0]); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + }); + }); +}); diff --git a/frontend/public/components/utils/selector-input.jsx b/frontend/public/components/utils/selector-input.jsx index 88a385c4216..e234a94a4b2 100644 --- a/frontend/public/components/utils/selector-input.jsx +++ b/frontend/public/components/utils/selector-input.jsx @@ -148,7 +148,6 @@ export class SelectorInput extends Component { className={css('co-label tag-item-content', this.props.labelClassName)} key={key} onClose={() => onRemove(key)} - isTruncated data-test={`label=${key}`} > {getTagDisplayValue(tag)}