From 21c1d749ce16bf78edf2f2f8c469729bc3b5a186 Mon Sep 17 00:00:00 2001 From: cyril-ui-developer Date: Thu, 14 May 2026 17:14:35 -0400 Subject: [PATCH] Extend Unit Tests: Console-App - Core Navigation & Orchestration --- .../__tests__/access-mode.spec.tsx | 135 +++++++++ .../components/access-modes/access-mode.tsx | 4 +- .../ClusterConfigurationCustomField.tsx | 4 +- .../ClusterConfigurationForm.tsx | 21 +- .../ClusterConfigurationCustomField.spec.tsx | 108 +++++++ .../ClusterConfigurationField.spec.tsx | 96 +++++++ .../ClusterConfigurationForm.spec.tsx | 77 +++++ .../ConsolePluginCSPStatusDetail.spec.tsx | 69 +++++ .../ConsolePluginDescriptionDetail.spec.tsx | 86 ++++++ .../ConsolePluginEnabledStatusDetail.spec.tsx | 139 +++++++++ .../ConsolePluginStatusDetail.spec.tsx | 93 +++++++ .../ConsolePluginVersionDetail.spec.tsx | 81 ++++++ .../__tests__/ClusterUpdateActivity.spec.tsx | 78 ++++++ .../__tests__/ControlPlaneStatus.spec.tsx | 145 ++++++++++ .../__tests__/OperatorStatus.spec.tsx | 132 +++++++++ .../NotLoadedDynamicPlugins.spec.tsx | 117 ++++++++ .../components/favorite/FavoriteNavItem.tsx | 8 +- .../__tests__/FavoriteNavItem.spec.tsx | 81 ++++++ .../src/components/nav/NavItemHref.tsx | 7 +- .../nav/__tests__/NavHeader.spec.tsx | 130 +++++++++ .../nav/__tests__/NavItemHref.spec.tsx | 115 ++++++++ .../components/nav/__tests__/NavLink.spec.tsx | 54 ++++ .../nav/__tests__/NavSection.spec.tsx | 194 +++++++++++++ .../nav/__tests__/Navigation.spec.tsx | 108 +++++++ .../nav/__tests__/PinnedResource.spec.tsx | 123 ++++++++ .../nav/__tests__/PluginNavItem.spec.tsx | 195 +++++++++++++ .../useConfirmNavUnpinModal.spec.tsx | 113 ++++++++ .../useNavExtensionForPerspective.spec.ts | 133 +++++++++ .../useNavExtensionsForSection.spec.ts | 121 ++++++++ .../components/nav/__tests__/utils.spec.ts | 263 ++++++++++++++++++ .../console-app/src/components/nav/index.tsx | 2 +- .../__tests__/NodeDetailsConditions.spec.tsx | 141 ++++++++++ .../__tests__/NodeDetailsImages.spec.tsx | 117 ++++++++ .../nodes/__tests__/NodeIPList.spec.tsx | 71 +++++ .../nodes/__tests__/NodeRoles.spec.tsx | 63 +++++ .../__tests__/GroupsEditorModal.spec.tsx | 6 +- .../BareMetalInventoryItems.spec.tsx | 6 +- .../__tests__/NodeUptime.spec.tsx | 79 ++++++ .../__tests__/SchedulableStatus.spec.tsx | 111 ++++++++ .../__tests__/AvailabilityDisplay.spec.tsx | 95 +++++++ .../AvailabilityRequirement.spec.tsx | 103 +++++++ .../pdb/__tests__/DisruptionsAllowed.spec.tsx | 85 ++++++ .../__tests__/QuickStartDrawer.spec.tsx | 88 ++++++ .../__tests__/QuickStartEmptyState.spec.tsx | 102 +++++++ .../QuickStartPermissionChecker.spec.tsx | 108 +++++++ .../ClusterResourceQuotaCharts.spec.tsx | 115 ++++++++ .../__tests__/ResourceQuotaCharts.spec.tsx | 111 ++++++++ .../resource-quota/__tests__/utils.spec.ts | 124 +++++++++ .../__tests__/volume-mode.spec.tsx | 97 +++++++ .../locales/en/console-shared.json | 2 + .../cluster-configuration/FormLayout.tsx | 4 + .../error/fallbacks/ErrorBoundaryInline.tsx | 10 +- 52 files changed, 4651 insertions(+), 19 deletions(-) create mode 100644 frontend/packages/console-app/src/components/access-modes/__tests__/access-mode.spec.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationCustomField.spec.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationField.spec.tsx create mode 100644 frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationForm.spec.tsx create mode 100644 frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginCSPStatusDetail.spec.tsx create mode 100644 frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginDescriptionDetail.spec.tsx create mode 100644 frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginEnabledStatusDetail.spec.tsx create mode 100644 frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginStatusDetail.spec.tsx create mode 100644 frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginVersionDetail.spec.tsx create mode 100644 frontend/packages/console-app/src/components/dashboards-page/__tests__/ClusterUpdateActivity.spec.tsx create mode 100644 frontend/packages/console-app/src/components/dashboards-page/__tests__/ControlPlaneStatus.spec.tsx create mode 100644 frontend/packages/console-app/src/components/dashboards-page/__tests__/OperatorStatus.spec.tsx create mode 100644 frontend/packages/console-app/src/components/dashboards-page/dynamic-plugins-health-resource/__tests__/NotLoadedDynamicPlugins.spec.tsx create mode 100644 frontend/packages/console-app/src/components/favorite/__tests__/FavoriteNavItem.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/NavItemHref.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/NavLink.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/NavSection.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/Navigation.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/PinnedResource.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/PluginNavItem.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/useConfirmNavUnpinModal.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/useNavExtensionForPerspective.spec.ts create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/useNavExtensionsForSection.spec.ts create mode 100644 frontend/packages/console-app/src/components/nav/__tests__/utils.spec.ts create mode 100644 frontend/packages/console-app/src/components/nodes/__tests__/NodeDetailsConditions.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/__tests__/NodeDetailsImages.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/__tests__/NodeIPList.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/__tests__/NodeRoles.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/node-dashboard/__tests__/NodeUptime.spec.tsx create mode 100644 frontend/packages/console-app/src/components/nodes/status/__tests__/SchedulableStatus.spec.tsx create mode 100644 frontend/packages/console-app/src/components/pdb/__tests__/AvailabilityDisplay.spec.tsx create mode 100644 frontend/packages/console-app/src/components/pdb/__tests__/AvailabilityRequirement.spec.tsx create mode 100644 frontend/packages/console-app/src/components/pdb/__tests__/DisruptionsAllowed.spec.tsx create mode 100644 frontend/packages/console-app/src/components/quick-starts/__tests__/QuickStartDrawer.spec.tsx create mode 100644 frontend/packages/console-app/src/components/quick-starts/__tests__/QuickStartEmptyState.spec.tsx create mode 100644 frontend/packages/console-app/src/components/quick-starts/loader/__tests__/QuickStartPermissionChecker.spec.tsx create mode 100644 frontend/packages/console-app/src/components/resource-quota/__tests__/ClusterResourceQuotaCharts.spec.tsx create mode 100644 frontend/packages/console-app/src/components/resource-quota/__tests__/ResourceQuotaCharts.spec.tsx create mode 100644 frontend/packages/console-app/src/components/resource-quota/__tests__/utils.spec.ts create mode 100644 frontend/packages/console-app/src/components/volume-modes/__tests__/volume-mode.spec.tsx diff --git a/frontend/packages/console-app/src/components/access-modes/__tests__/access-mode.spec.tsx b/frontend/packages/console-app/src/components/access-modes/__tests__/access-mode.spec.tsx new file mode 100644 index 00000000000..20ef7435cac --- /dev/null +++ b/frontend/packages/console-app/src/components/access-modes/__tests__/access-mode.spec.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { PersistentVolumeClaimKind } from '@console/internal/module/k8s'; +import { AccessModeSelector, getPVCAccessModes } from '../access-mode'; + +jest.mock('@console/internal/components/storage/shared', () => ({ + getAccessModeOptions: () => [ + { value: 'ReadWriteOnce', title: 'Single user (RWO)' }, + { value: 'ReadWriteMany', title: 'Shared access (RWX)' }, + { value: 'ReadOnlyMany', title: 'Read only (ROX)' }, + { value: 'ReadWriteOncePod', title: 'Read write once pod (RWOP)' }, + ], + getAccessModeForProvisioner: jest.fn(() => ['ReadWriteOnce', 'ReadWriteMany']), +})); + +describe('AccessModeSelector', () => { + describe('getPVCAccessModes', () => { + it('should return access mode titles from PVC resource', () => { + const mockPVC = { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: 'test-pvc', namespace: 'default' }, + spec: { + accessModes: ['ReadWriteOnce'], + resources: { requests: { storage: '1Gi' } }, + storageClassName: 'gp2', + }, + } as PersistentVolumeClaimKind; + + const result = getPVCAccessModes(mockPVC, 'title'); + expect(result).toEqual(['Single user (RWO)']); + }); + + it('should return access mode values from PVC resource', () => { + const mockPVC = { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: 'test-pvc', namespace: 'default' }, + spec: { + accessModes: ['ReadWriteMany'], + resources: { requests: { storage: '1Gi' } }, + storageClassName: 'gp2', + }, + } as PersistentVolumeClaimKind; + + const result = getPVCAccessModes(mockPVC, 'value'); + expect(result).toEqual(['ReadWriteMany']); + }); + + it('should return empty array when PVC has no access modes', () => { + const mockPVC = { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: 'test-pvc', namespace: 'default' }, + spec: { + accessModes: [], + resources: { requests: { storage: '1Gi' } }, + storageClassName: 'gp2', + }, + } as PersistentVolumeClaimKind; + + const result = getPVCAccessModes(mockPVC, 'value'); + expect(result).toEqual([]); + }); + + it('should handle undefined PVC resource', () => { + const result = getPVCAccessModes(undefined, 'value'); + expect(result).toEqual([]); + }); + }); + + describe('AccessModeSelector', () => { + const defaultProps = { + onChange: jest.fn(), + loaded: true, + provisioner: 'kubernetes.io/aws-ebs', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render access mode label', () => { + render(); + + expect(screen.getByText('Access mode')).toBeVisible(); + }); + + it('should show loading skeleton when not loaded', () => { + render(); + + expect(screen.getByText('Access mode')).toBeVisible(); + expect(screen.getByRole('status', { busy: true })).toBeVisible(); + }); + + it('should render select dropdown when loaded', async () => { + render(); + + expect(await screen.findByRole('button')).toBeVisible(); + }); + + it('should display description when provided', async () => { + const description = 'Select the access mode for your storage'; + render(); + + expect(await screen.findByText(description)).toBeVisible(); + }); + + it('should open dropdown when clicked', async () => { + const user = userEvent.setup(); + render(); + + const toggle = await screen.findByRole('button'); + await user.click(toggle); + + expect(await screen.findByRole('listbox')).toBeVisible(); + }); + + it('should call onChange when access mode is selected', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + const toggle = await screen.findByRole('button'); + await user.click(toggle); + + expect(await screen.findByRole('listbox')).toBeVisible(); + + const option = screen.getByText('Shared access (RWX)'); + await user.click(option); + + expect(onChange).toHaveBeenCalledWith('ReadWriteMany'); + }); + }); +}); diff --git a/frontend/packages/console-app/src/components/access-modes/access-mode.tsx b/frontend/packages/console-app/src/components/access-modes/access-mode.tsx index c979e104dee..556a1af84a2 100644 --- a/frontend/packages/console-app/src/components/access-modes/access-mode.tsx +++ b/frontend/packages/console-app/src/components/access-modes/access-mode.tsx @@ -146,7 +146,9 @@ export const AccessModeSelector: FC = (props) => { {description}

)} - {(!loaded || !allowedAccessModes) &&
} + {(!loaded || !allowedAccessModes) && ( +
+ )} ); }; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCustomField.tsx b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCustomField.tsx index 91ab8cd32a1..975a9bfaa8e 100644 --- a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCustomField.tsx +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationCustomField.tsx @@ -20,7 +20,9 @@ const ClusterConfigurationCustomField: FC return ( - +
+ +
); diff --git a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationForm.tsx b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationForm.tsx index 18a80de41c7..52c3600f3e1 100644 --- a/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationForm.tsx +++ b/frontend/packages/console-app/src/components/cluster-configuration/ClusterConfigurationForm.tsx @@ -1,18 +1,29 @@ import type { FC } from 'react'; import { Form } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; import ClusterConfigurationField from './ClusterConfigurationField'; import type { ResolvedClusterConfigurationItem } from './types'; type ClusterConfigurationFormProps = { - items: ResolvedClusterConfigurationItem[]; + items?: ResolvedClusterConfigurationItem[]; }; -const ClusterConfigurationForm: FC = ({ items }) => - items?.length > 0 ? ( -
event.preventDefault()}> +const ClusterConfigurationForm: FC = ({ items }) => { + const { t } = useTranslation(); + + if (!items?.length) { + return null; + } + + return ( + event.preventDefault()} + > {items.map((item) => ( ))} - ) : null; + ); +}; export default ClusterConfigurationForm; diff --git a/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationCustomField.spec.tsx b/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationCustomField.spec.tsx new file mode 100644 index 00000000000..ad33c793163 --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationCustomField.spec.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import type { ClusterConfigurationCustomField as ClusterConfigurationCustomFieldType } from '@console/dynamic-plugin-sdk/src'; +import { ClusterConfigurationFieldType } from '@console/dynamic-plugin-sdk/src'; +import type { ResolvedCodeRefProperties } from '@console/dynamic-plugin-sdk/src/types'; +import ClusterConfigurationCustomField from '../ClusterConfigurationCustomField'; +import type { ResolvedClusterConfigurationItem } from '../types'; + +const renderWithRouter = (ui: ReactElement) => render({ui}); + +describe('ClusterConfigurationCustomField', () => { + const MockCustomComponent = () =>
Custom Component Content
; + + const createMockItem = (readonly: boolean): ResolvedClusterConfigurationItem => ({ + id: 'test-item', + groupId: 'test-group', + label: 'Test Custom Field', + description: 'Test description', + field: { + type: ClusterConfigurationFieldType.custom, + component: MockCustomComponent, + }, + readonly, + }); + + const createMockField = ( + props?: ClusterConfigurationCustomFieldType['props'], + ): ResolvedCodeRefProperties => ({ + type: ClusterConfigurationFieldType.custom, + component: MockCustomComponent, + props, + }); + + it('should render custom component', () => { + const item = createMockItem(false); + const field = createMockField(); + + renderWithRouter(); + + expect(screen.getByRole('group', { name: 'Test Custom Field' })).toBeVisible(); + expect(screen.getByText('Custom Component Content')).toBeVisible(); + }); + + it('should wrap content in error boundary', () => { + const item = createMockItem(false); + const field = createMockField(); + + renderWithRouter(); + + expect(screen.getByRole('region', { name: 'Inline error boundary' })).toBeVisible(); + }); + + it('should wrap content in form layout', () => { + const item = createMockItem(false); + const field = createMockField(); + + renderWithRouter(); + + expect(screen.getByRole('region', { name: 'Form layout' })).toBeVisible(); + }); + + it('should pass readonly prop to custom component when false', () => { + const item = createMockItem(false); + const field = createMockField(); + + renderWithRouter(); + + expect(screen.getByRole('group', { name: 'Test Custom Field' })).toHaveAttribute( + 'data-readonly', + 'false', + ); + }); + + it('should pass readonly prop to custom component when true', () => { + const item = createMockItem(true); + const field = createMockField(); + + renderWithRouter(); + + expect(screen.getByRole('group', { name: 'Test Custom Field' })).toHaveAttribute( + 'data-readonly', + 'true', + ); + }); + + it('should pass custom props to component', () => { + const CustomComponentWithProps = ({ + customProp, + }: { + readonly: boolean; + customProp?: string; + }) =>

{customProp}

; + + const item = createMockItem(false); + const field: ResolvedCodeRefProperties = { + type: ClusterConfigurationFieldType.custom, + component: CustomComponentWithProps, + props: { customProp: 'test-value' }, + }; + + renderWithRouter(); + + expect( + screen.getByRole('paragraph', { name: 'Extension with custom props' }), + ).toHaveTextContent('test-value'); + }); +}); diff --git a/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationField.spec.tsx b/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationField.spec.tsx new file mode 100644 index 00000000000..a85f16208c8 --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationField.spec.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import { ClusterConfigurationFieldType } from '@console/dynamic-plugin-sdk/src'; +import ClusterConfigurationField from '../ClusterConfigurationField'; +import type { ResolvedClusterConfigurationItem } from '../types'; + +jest.mock('../ClusterConfigurationCustomField', () => ({ + __esModule: true, + default: ({ item }: { item: ResolvedClusterConfigurationItem }) => ( +
+ {item.label} +
+ ), +})); + +describe('ClusterConfigurationField', () => { + const createMockItem = (): ResolvedClusterConfigurationItem => ({ + id: 'test-item', + groupId: 'test-group', + label: 'Test Field Label', + description: 'Test description', + field: { + type: ClusterConfigurationFieldType.custom, + component: jest.fn(), + }, + readonly: false, + }); + + it('should render ClusterConfigurationCustomField for custom field type', () => { + const item = createMockItem(); + render(); + + expect(screen.getByRole('region', { name: 'Test Field Label' })).toBeVisible(); + expect(screen.getByRole('region', { name: 'Test Field Label' })).toHaveAttribute( + 'data-readonly', + 'false', + ); + expect(screen.getByText('Test Field Label')).toBeVisible(); + }); + + it('should render null for unsupported field type', () => { + const item = { + ...createMockItem(), + field: { + type: 'unsupported' as typeof ClusterConfigurationFieldType.custom, + component: jest.fn(), + }, + }; + + render(); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + }); + + it('should pass readonly state to child component', () => { + const item = { + ...createMockItem(), + readonly: true, + }; + render(); + + expect(screen.getByRole('region', { name: 'Test Field Label' })).toHaveAttribute( + 'data-readonly', + 'true', + ); + }); + + it('should render with correct item properties', () => { + const item: ResolvedClusterConfigurationItem = { + id: 'unique-id', + groupId: 'group-1', + label: 'Unique Label', + description: 'Unique description', + field: { + type: ClusterConfigurationFieldType.custom, + component: jest.fn(), + }, + readonly: false, + }; + + render(); + + expect(screen.getByRole('region', { name: 'Unique Label' })).toBeVisible(); + }); + + it('should handle text field type by returning null', () => { + const item = { + ...createMockItem(), + field: { + type: ClusterConfigurationFieldType.text as typeof ClusterConfigurationFieldType.custom, + component: jest.fn(), + }, + }; + + render(); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationForm.spec.tsx b/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationForm.spec.tsx new file mode 100644 index 00000000000..366fc1060bb --- /dev/null +++ b/frontend/packages/console-app/src/components/cluster-configuration/__tests__/ClusterConfigurationForm.spec.tsx @@ -0,0 +1,77 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ClusterConfigurationFieldType } from '@console/dynamic-plugin-sdk/src'; +import ClusterConfigurationForm from '../ClusterConfigurationForm'; +import type { ResolvedClusterConfigurationItem } from '../types'; + +jest.mock('../ClusterConfigurationField', () => ({ + __esModule: true, + default: ({ item }: { item: ResolvedClusterConfigurationItem }) => ( +
{item.label}
+ ), +})); + +describe('ClusterConfigurationForm', () => { + const createMockItem = (id: string, label: string): ResolvedClusterConfigurationItem => ({ + id, + groupId: 'test-group', + label, + description: `Description for ${label}`, + field: { + type: ClusterConfigurationFieldType.custom, + component: jest.fn(), + }, + readonly: false, + }); + + it('should render form with multiple items', () => { + const items = [createMockItem('item-1', 'First Item'), createMockItem('item-2', 'Second Item')]; + + render(); + + expect(screen.getByText('First Item')).toBeVisible(); + expect(screen.getByText('Second Item')).toBeVisible(); + }); + + it('should render null when items array is empty', () => { + render(); + + expect(screen.queryByRole('form', { name: 'Cluster configuration' })).not.toBeInTheDocument(); + }); + + it('should render null when items is undefined', () => { + render(); + + expect(screen.queryByRole('form', { name: 'Cluster configuration' })).not.toBeInTheDocument(); + }); + + it('should render a form and call preventDefault on submit', () => { + const items = [createMockItem('item-1', 'Test Item')]; + render(); + + const form = screen.getByRole('form', { name: 'Cluster configuration' }); + const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); + fireEvent.submit(form); + expect(preventDefaultSpy).toHaveBeenCalled(); + preventDefaultSpy.mockRestore(); + }); + + it('should render each item with unique key', () => { + const items = [ + createMockItem('unique-id-1', 'Item One'), + createMockItem('unique-id-2', 'Item Two'), + createMockItem('unique-id-3', 'Item Three'), + ]; + + render(); + + expect( + screen.getByRole('region', { name: 'Configuration field unique-id-1' }), + ).toHaveTextContent('Item One'); + expect( + screen.getByRole('region', { name: 'Configuration field unique-id-2' }), + ).toHaveTextContent('Item Two'); + expect( + screen.getByRole('region', { name: 'Configuration field unique-id-3' }), + ).toHaveTextContent('Item Three'); + }); +}); diff --git a/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginCSPStatusDetail.spec.tsx b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginCSPStatusDetail.spec.tsx new file mode 100644 index 00000000000..25a02462bb3 --- /dev/null +++ b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginCSPStatusDetail.spec.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react'; +import * as useConsoleSelectorModule from '@console/shared/src/hooks/useConsoleSelector'; +import ConsolePluginCSPStatusDetail from '../ConsolePluginCSPStatusDetail'; + +jest.mock('@console/shared/src/hooks/useConsoleSelector', () => ({ + useConsoleSelector: jest.fn(), +})); + +jest.mock('../ConsoleOperatorConfig', () => ({ + ConsolePluginCSPStatus: ({ hasViolations }: { hasViolations: boolean }) => ( + {hasViolations ? 'Has violations' : 'No violations'} + ), +})); + +const mockUseConsoleSelector = useConsoleSelectorModule.useConsoleSelector as jest.Mock; + +describe('ConsolePluginCSPStatusDetail', () => { + const createMockObj = (name: string) => ({ + metadata: { name }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display no violations when plugin has no CSP violations', () => { + mockUseConsoleSelector.mockReturnValue({ 'test-plugin': false }); + + render(); + + expect(screen.getByText('No violations')).toBeVisible(); + }); + + it('should display has violations when plugin has CSP violations', () => { + mockUseConsoleSelector.mockReturnValue({ 'test-plugin': true }); + + render(); + + expect(screen.getByText('Has violations')).toBeVisible(); + }); + + it('should display no violations when plugin is not in violations list', () => { + mockUseConsoleSelector.mockReturnValue({ 'other-plugin': true }); + + render(); + + expect(screen.getByText('No violations')).toBeVisible(); + }); + + it('should display no violations when violations list is empty', () => { + mockUseConsoleSelector.mockReturnValue({}); + + render(); + + expect(screen.getByText('No violations')).toBeVisible(); + }); + + it('should correctly identify plugin among multiple plugins', () => { + mockUseConsoleSelector.mockReturnValue({ + 'plugin-a': false, + 'target-plugin': true, + 'plugin-b': false, + }); + + render(); + + expect(screen.getByText('Has violations')).toBeVisible(); + }); +}); diff --git a/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginDescriptionDetail.spec.tsx b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginDescriptionDetail.spec.tsx new file mode 100644 index 00000000000..fb18a34bb4a --- /dev/null +++ b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginDescriptionDetail.spec.tsx @@ -0,0 +1,86 @@ +import type { PluginInfoEntry } from '@openshift/dynamic-plugin-sdk'; +import { render, screen } from '@testing-library/react'; +import * as usePluginInfoModule from '@console/plugin-sdk/src/api/usePluginInfo'; +import ConsolePluginDescriptionDetail from '../ConsolePluginDescriptionDetail'; + +jest.mock('@console/plugin-sdk/src/api/usePluginInfo', () => ({ + usePluginInfo: jest.fn(), +})); + +const mockUsePluginInfo = usePluginInfoModule.usePluginInfo as jest.Mock; + +describe('ConsolePluginDescriptionDetail', () => { + const createMockObj = (name: string) => ({ + metadata: { name }, + }); + + const createPluginInfo = ( + name: string, + description: string | undefined, + status: PluginInfoEntry['status'] = 'loaded', + ): PluginInfoEntry => + ({ + manifest: { + name, + customProperties: description ? { console: { description } } : undefined, + }, + status, + enabled: true, + } as PluginInfoEntry); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display plugin description when plugin is loaded', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin', 'A test plugin')]); + + render(); + + expect(screen.getByText('A test plugin')).toBeVisible(); + }); + + it('should display dash when plugin is not loaded', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin', 'Description', 'pending')]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should display dash when plugin has no description', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin', undefined)]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should display dash when plugin is not found', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('other-plugin', 'Other desc')]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should display dash when plugin failed to load', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin', 'Description', 'failed')]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should find correct plugin among multiple plugins', () => { + mockUsePluginInfo.mockReturnValue([ + createPluginInfo('plugin-a', 'First plugin'), + createPluginInfo('target-plugin', 'Target description'), + createPluginInfo('plugin-b', 'Third plugin'), + ]); + + render(); + + expect(screen.getByText('Target description')).toBeVisible(); + }); +}); diff --git a/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginEnabledStatusDetail.spec.tsx b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginEnabledStatusDetail.spec.tsx new file mode 100644 index 00000000000..9ae9b58aaed --- /dev/null +++ b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginEnabledStatusDetail.spec.tsx @@ -0,0 +1,139 @@ +import type { PluginInfoEntry } from '@openshift/dynamic-plugin-sdk'; +import { render, screen } from '@testing-library/react'; +import * as usePluginInfoModule from '@console/plugin-sdk/src/api/usePluginInfo'; +import * as ConsoleOperatorConfigModule from '../ConsoleOperatorConfig'; +import ConsolePluginEnabledStatusDetail from '../ConsolePluginEnabledStatusDetail'; + +jest.mock('@console/plugin-sdk/src/api/usePluginInfo', () => ({ + usePluginInfo: jest.fn(), +})); + +jest.mock('../ConsoleOperatorConfig', () => ({ + ConsolePluginEnabledStatus: ({ + pluginName, + enabled, + }: { + pluginName: string; + enabled: boolean; + }) => ( + + {pluginName}: {enabled ? 'Enabled' : 'Disabled'} + + ), + developmentMode: false, + useConsoleOperatorConfigData: jest.fn(), +})); + +const mockUsePluginInfo = usePluginInfoModule.usePluginInfo as jest.Mock; +const mockUseConsoleOperatorConfigData = ConsoleOperatorConfigModule.useConsoleOperatorConfigData as jest.Mock; + +describe('ConsolePluginEnabledStatusDetail', () => { + const createMockObj = (name: string) => ({ + metadata: { name }, + }); + + const createPluginInfo = ( + name: string, + status: PluginInfoEntry['status'] = 'loaded', + enabled: boolean = true, + ): PluginInfoEntry => + ({ + manifest: { name }, + status, + enabled, + } as PluginInfoEntry); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display enabled status when plugin is in enabled list', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin')]); + mockUseConsoleOperatorConfigData.mockReturnValue({ + consoleOperatorConfig: { + spec: { + plugins: ['test-plugin'], + }, + }, + consoleOperatorConfigLoaded: true, + }); + + render(); + + expect(screen.getByText('test-plugin: Enabled')).toBeVisible(); + }); + + it('should display disabled status when plugin is not in enabled list', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin')]); + mockUseConsoleOperatorConfigData.mockReturnValue({ + consoleOperatorConfig: { + spec: { + plugins: ['other-plugin'], + }, + }, + consoleOperatorConfigLoaded: true, + }); + + render(); + + expect(screen.getByText('test-plugin: Disabled')).toBeVisible(); + }); + + it('should display dash when config is not loaded', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin')]); + mockUseConsoleOperatorConfigData.mockReturnValue({ + consoleOperatorConfig: null, + consoleOperatorConfigLoaded: false, + }); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should display dash when plugin name is undefined', () => { + mockUsePluginInfo.mockReturnValue([]); + mockUseConsoleOperatorConfigData.mockReturnValue({ + consoleOperatorConfig: { + spec: { + plugins: [], + }, + }, + consoleOperatorConfigLoaded: true, + }); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should handle empty plugins list', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin')]); + mockUseConsoleOperatorConfigData.mockReturnValue({ + consoleOperatorConfig: { + spec: { + plugins: [], + }, + }, + consoleOperatorConfigLoaded: true, + }); + + render(); + + expect(screen.getByText('test-plugin: Disabled')).toBeVisible(); + }); + + it('should handle undefined plugins in config', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin')]); + mockUseConsoleOperatorConfigData.mockReturnValue({ + consoleOperatorConfig: { + spec: {}, + }, + consoleOperatorConfigLoaded: true, + }); + + render(); + + expect(screen.getByText('test-plugin: Disabled')).toBeVisible(); + }); +}); diff --git a/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginStatusDetail.spec.tsx b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginStatusDetail.spec.tsx new file mode 100644 index 00000000000..34d14a67837 --- /dev/null +++ b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginStatusDetail.spec.tsx @@ -0,0 +1,93 @@ +import type { PluginInfoEntry } from '@openshift/dynamic-plugin-sdk'; +import { render, screen } from '@testing-library/react'; +import * as usePluginInfoModule from '@console/plugin-sdk/src/api/usePluginInfo'; +import ConsolePluginStatusDetail from '../ConsolePluginStatusDetail'; + +jest.mock('@console/plugin-sdk/src/api/usePluginInfo', () => ({ + usePluginInfo: jest.fn(), +})); + +jest.mock('../ConsoleOperatorConfig', () => ({ + ConsolePluginStatus: ({ status, errorMessage }: { status: string; errorMessage?: string }) => ( + + {status} + {errorMessage && ` - ${errorMessage}`} + + ), +})); + +const mockUsePluginInfo = usePluginInfoModule.usePluginInfo as jest.Mock; + +describe('ConsolePluginStatusDetail', () => { + const createMockObj = (name: string) => ({ + metadata: { name }, + }); + + const createPluginInfo = ( + name: string, + status: PluginInfoEntry['status'], + errorMessage?: string, + ): PluginInfoEntry => + (({ + manifest: { name }, + status, + enabled: true, + errorMessage, + } as unknown) as PluginInfoEntry); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display loaded status when plugin is loaded', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin', 'loaded')]); + + render(); + + expect(screen.getByText('loaded')).toBeVisible(); + }); + + it('should display pending status when plugin is pending', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin', 'pending')]); + + render(); + + expect(screen.getByText('pending')).toBeVisible(); + }); + + it('should display failed status with error message', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin', 'failed', 'Network error')]); + + render(); + + expect(screen.getByText('failed - Network error')).toBeVisible(); + }); + + it('should display dash when plugin is not found', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('other-plugin', 'loaded')]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should display dash when plugin list is empty', () => { + mockUsePluginInfo.mockReturnValue([]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should find correct plugin among multiple plugins', () => { + mockUsePluginInfo.mockReturnValue([ + createPluginInfo('plugin-a', 'loaded'), + createPluginInfo('target-plugin', 'pending'), + createPluginInfo('plugin-b', 'loaded'), + ]); + + render(); + + expect(screen.getByText('pending')).toBeVisible(); + }); +}); diff --git a/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginVersionDetail.spec.tsx b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginVersionDetail.spec.tsx new file mode 100644 index 00000000000..bc675b13b37 --- /dev/null +++ b/frontend/packages/console-app/src/components/console-operator/__tests__/ConsolePluginVersionDetail.spec.tsx @@ -0,0 +1,81 @@ +import type { PluginInfoEntry } from '@openshift/dynamic-plugin-sdk'; +import { render, screen } from '@testing-library/react'; +import * as usePluginInfoModule from '@console/plugin-sdk/src/api/usePluginInfo'; +import ConsolePluginVersionDetail from '../ConsolePluginVersionDetail'; + +jest.mock('@console/plugin-sdk/src/api/usePluginInfo', () => ({ + usePluginInfo: jest.fn(), +})); + +const mockUsePluginInfo = usePluginInfoModule.usePluginInfo as jest.Mock; + +describe('ConsolePluginVersionDetail', () => { + const createMockObj = (name: string) => ({ + metadata: { name }, + }); + + const createPluginInfo = ( + name: string, + version: string, + status: PluginInfoEntry['status'] = 'loaded', + ): PluginInfoEntry => + ({ + manifest: { name, version }, + status, + enabled: true, + } as PluginInfoEntry); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display plugin version when plugin is found', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('test-plugin', '1.2.3')]); + + render(); + + expect(screen.getByText('1.2.3')).toBeVisible(); + }); + + it('should display dash when plugin is not found', () => { + mockUsePluginInfo.mockReturnValue([createPluginInfo('other-plugin', '1.0.0')]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should display dash when plugin has no version', () => { + mockUsePluginInfo.mockReturnValue([ + { + manifest: { name: 'test-plugin' }, + status: 'loaded', + enabled: true, + } as PluginInfoEntry, + ]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should display dash when plugin list is empty', () => { + mockUsePluginInfo.mockReturnValue([]); + + render(); + + expect(screen.getByText('-')).toBeVisible(); + }); + + it('should find correct plugin among multiple plugins', () => { + mockUsePluginInfo.mockReturnValue([ + createPluginInfo('plugin-a', '1.0.0'), + createPluginInfo('target-plugin', '2.5.0'), + createPluginInfo('plugin-b', '3.0.0'), + ]); + + render(); + + expect(screen.getByText('2.5.0')).toBeVisible(); + }); +}); diff --git a/frontend/packages/console-app/src/components/dashboards-page/__tests__/ClusterUpdateActivity.spec.tsx b/frontend/packages/console-app/src/components/dashboards-page/__tests__/ClusterUpdateActivity.spec.tsx new file mode 100644 index 00000000000..5fef5d386d0 --- /dev/null +++ b/frontend/packages/console-app/src/components/dashboards-page/__tests__/ClusterUpdateActivity.spec.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import type { ClusterVersionKind } from '@console/internal/module/k8s'; +import ClusterUpdateActivity from '../ClusterUpdateActivity'; + +jest.mock('@console/shared/src/components/dashboard/activity-card/ActivityItem', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe('ClusterUpdateActivity', () => { + const createMockClusterVersion = (version: string): ClusterVersionKind => + ({ + apiVersion: 'config.openshift.io/v1', + kind: 'ClusterVersion', + metadata: { + name: 'version', + }, + status: { + history: [ + { + version, + state: 'Partial', + startedTime: '2024-01-15T10:00:00Z', + }, + ], + }, + } as ClusterVersionKind); + + it('should render update message with version number', () => { + const resource = createMockClusterVersion('4.15.0'); + render(); + + expect(screen.getByText('Updating cluster to 4.15.0')).toBeVisible(); + }); + + it('should render activity item wrapper', () => { + const resource = createMockClusterVersion('4.14.5'); + render(); + + expect(screen.getByRole('article', { name: 'Cluster update activity' })).toBeVisible(); + }); + + it('should display different version numbers correctly', () => { + const resource = createMockClusterVersion('4.16.0-rc.1'); + render(); + + expect(screen.getByText('Updating cluster to 4.16.0-rc.1')).toBeVisible(); + }); + + it('should handle empty history gracefully', () => { + const resource: ClusterVersionKind = { + apiVersion: 'config.openshift.io/v1', + kind: 'ClusterVersion', + metadata: { name: 'version' }, + status: { + history: [], + }, + } as ClusterVersionKind; + + render(); + + expect(screen.getByRole('article', { name: 'Cluster update activity' })).toBeVisible(); + }); + + it('should update the message when the resource version changes', () => { + const resource1 = createMockClusterVersion('4.15.0'); + const { rerender } = render(); + + expect(screen.getByText('Updating cluster to 4.15.0')).toBeVisible(); + + const resource2 = createMockClusterVersion('4.15.1'); + rerender(); + + expect(screen.getByText('Updating cluster to 4.15.1')).toBeVisible(); + }); +}); diff --git a/frontend/packages/console-app/src/components/dashboards-page/__tests__/ControlPlaneStatus.spec.tsx b/frontend/packages/console-app/src/components/dashboards-page/__tests__/ControlPlaneStatus.spec.tsx new file mode 100644 index 00000000000..45460d7d326 --- /dev/null +++ b/frontend/packages/console-app/src/components/dashboards-page/__tests__/ControlPlaneStatus.spec.tsx @@ -0,0 +1,145 @@ +import { render, screen } from '@testing-library/react'; +import type { PrometheusResponse } from '@console/internal/components/graphs'; +import ControlPlanePopup from '../ControlPlaneStatus'; + +jest.mock('@console/shared/src/components/dashboard/status-card/states', () => ({ + HealthState: { + OK: 'OK', + WARNING: 'WARNING', + ERROR: 'ERROR', + LOADING: 'LOADING', + UNKNOWN: 'UNKNOWN', + NOT_AVAILABLE: 'NOT_AVAILABLE', + }, + healthStateMapping: { + OK: { icon: , priority: 0 }, + WARNING: { icon: , priority: 1 }, + ERROR: { icon: , priority: 2 }, + LOADING: { icon: , priority: -1 }, + UNKNOWN: { icon: , priority: 0 }, + NOT_AVAILABLE: { icon: , priority: 0 }, + }, + healthStateMessage: jest.fn((state: string) => `${state} message`), +})); + +jest.mock('@console/shared/src/components/dashboard/status-card/StatusPopup', () => ({ + __esModule: true, + default: ({ + children, + value, + icon, + }: { + children: React.ReactNode; + value: string; + icon: React.ReactNode; + }) => ( + + {children} + + {icon} + {value} + + + ), + StatusPopupSection: ({ + children, + firstColumn, + secondColumn, + }: { + children: React.ReactNode; + firstColumn: string; + secondColumn: string; + }) => ( + + + + + + + + + {children} +
Control plane component health
{firstColumn}{secondColumn}
+ ), +})); + +jest.mock('../status', () => ({ + getControlPlaneComponentHealth: jest.fn((response, error) => { + if (error) { + return { state: 'NOT_AVAILABLE', message: 'Not available' }; + } + if (!response) { + return { state: 'LOADING' }; + } + return { state: 'OK', message: '99%' }; + }), +})); + +describe('ControlPlanePopup', () => { + const createMockResponse = (value: string): { response: PrometheusResponse; error: null } => ({ + response: { + status: 'success', + data: { + resultType: 'vector', + result: [{ metric: {}, value: [Date.now() / 1000, value] }], + }, + }, + error: null, + }); + + const createMockResponses = (count: number) => + Array(count) + .fill(null) + .map(() => createMockResponse('0.99')); + + const defaultProps = { + responses: createMockResponses(4), + hide: jest.fn(), + }; + + it('should render control plane description', () => { + render(); + + expect( + screen.getByText( + 'Components of the control plane are responsible for maintaining and reconciling the state of the cluster.', + ), + ).toBeVisible(); + }); + + it('should render components column header', () => { + render(); + + expect(screen.getByRole('columnheader', { name: 'Components' })).toBeVisible(); + }); + + it('should render response rate column header', () => { + render(); + + expect(screen.getByRole('columnheader', { name: 'Response rate' })).toBeVisible(); + }); + + it('should render API Servers status', () => { + render(); + + expect(screen.getByText('API Servers')).toBeVisible(); + }); + + it('should render Controller Managers status', () => { + render(); + + expect(screen.getByText('Controller Managers')).toBeVisible(); + }); + + it('should render Schedulers status', () => { + render(); + + expect(screen.getByText('Schedulers')).toBeVisible(); + }); + + it('should render API Request Success Rate status', () => { + render(); + + expect(screen.getByText('API Request Success Rate')).toBeVisible(); + }); +}); diff --git a/frontend/packages/console-app/src/components/dashboards-page/__tests__/OperatorStatus.spec.tsx b/frontend/packages/console-app/src/components/dashboards-page/__tests__/OperatorStatus.spec.tsx new file mode 100644 index 00000000000..c4e4f1886ed --- /dev/null +++ b/frontend/packages/console-app/src/components/dashboards-page/__tests__/OperatorStatus.spec.tsx @@ -0,0 +1,132 @@ +import { render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import type { ClusterOperator } from '@console/internal/module/k8s'; +import ClusterOperatorStatusRow from '../OperatorStatus'; + +jest.mock('@console/internal/components/utils/resource-link', () => ({ + ResourceLink: ({ name, kind }: { name: string; kind: string }) => ( + {name} + ), +})); + +jest.mock('@console/internal/models', () => ({ + ClusterOperatorModel: { + kind: 'ClusterOperator', + apiVersion: 'config.openshift.io/v1', + apiGroup: 'config.openshift.io', + plural: 'clusteroperators', + }, +})); + +jest.mock('@console/internal/module/k8s', () => ({ + referenceForModel: jest.fn(() => 'config.openshift.io~v1~ClusterOperator'), +})); + +jest.mock('@console/shared/src/components/dashboard/status-card/StatusPopup', () => ({ + __esModule: true, + default: ({ + children, + value, + icon, + }: { + children: React.ReactNode; + value: string; + icon: React.ReactNode; + }) => ( +
+ {icon} +
{value}
+
{children}
+
+ ), +})); + +describe('ClusterOperatorStatusRow', () => { + const createMockOperatorStatus = (name: string, statusTitle: string) => ({ + status: { + title: statusTitle, + icon: ( + + {statusTitle} Icon + + ), + priority: 0, + health: 'OK' as const, + }, + operators: [ + { + apiVersion: 'config.openshift.io/v1', + kind: 'ClusterOperator', + metadata: { + name, + }, + } as ClusterOperator, + ], + }); + + it('should render operator status value', () => { + const operatorStatus = createMockOperatorStatus('authentication', 'Available'); + + render( + + + , + ); + + const region = screen.getByRole('region', { name: 'Operator status' }); + expect(within(region).getByRole('status')).toHaveTextContent('Available'); + }); + + it('should render operator name as resource link', () => { + const operatorStatus = createMockOperatorStatus('console', 'Degraded'); + + render( + + + , + ); + + expect(screen.getByRole('link', { name: 'console' })).toBeVisible(); + }); + + it('should render status icon', () => { + const operatorStatus = createMockOperatorStatus('network', 'Progressing'); + + render( + + + , + ); + + expect(screen.getByRole('img', { name: 'Progressing status icon' })).toHaveTextContent( + 'Progressing Icon', + ); + }); + + it('should render status popup wrapper', () => { + const operatorStatus = createMockOperatorStatus('dns', 'Available'); + + render( + + + , + ); + + expect(screen.getByRole('region', { name: 'Operator status' })).toBeVisible(); + }); + + it('should use correct kind reference for resource link', () => { + const operatorStatus = createMockOperatorStatus('ingress', 'Available'); + + render( + + + , + ); + + expect(screen.getByRole('link', { name: 'ingress' })).toHaveAttribute( + 'href', + '#/test-resource/config.openshift.io~v1~ClusterOperator/ingress', + ); + }); +}); diff --git a/frontend/packages/console-app/src/components/dashboards-page/dynamic-plugins-health-resource/__tests__/NotLoadedDynamicPlugins.spec.tsx b/frontend/packages/console-app/src/components/dashboards-page/dynamic-plugins-health-resource/__tests__/NotLoadedDynamicPlugins.spec.tsx new file mode 100644 index 00000000000..2002908562f --- /dev/null +++ b/frontend/packages/console-app/src/components/dashboards-page/dynamic-plugins-health-resource/__tests__/NotLoadedDynamicPlugins.spec.tsx @@ -0,0 +1,117 @@ +import type { FailedPluginInfoEntry, PendingPluginInfoEntry } from '@openshift/dynamic-plugin-sdk'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import NotLoadedDynamicPlugins from '../NotLoadedDynamicPlugins'; + +jest.mock('@console/internal/components/utils/resource-link', () => ({ + ResourceLink: ({ name, kind }: { name: string; kind: string }) => ( + {name} + ), +})); + +jest.mock('@console/internal/models', () => ({ + ConsolePluginModel: { + kind: 'ConsolePlugin', + apiVersion: 'console.openshift.io/v1', + apiGroup: 'console.openshift.io', + plural: 'consoleplugins', + }, +})); + +jest.mock('@console/internal/module/k8s', () => ({ + referenceForModel: jest.fn(() => 'console.openshift.io~v1~ConsolePlugin'), +})); + +jest.mock('@console/shared/src/components/dashboard/status-card/StatusPopup', () => ({ + StatusPopupSection: ({ + children, + firstColumn, + }: { + children: React.ReactNode; + firstColumn: string; + }) => ( +
+

{firstColumn}

+ {children} +
+ ), +})); + +describe('NotLoadedDynamicPlugins', () => { + const createMockPlugin = (name: string): FailedPluginInfoEntry | PendingPluginInfoEntry => + ({ + status: 'failed', + manifest: { + name, + version: '1.0.0', + }, + } as FailedPluginInfoEntry); + + it('should render section label', () => { + const plugins = [createMockPlugin('test-plugin')]; + + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Failed plugins', level: 2 })).toBeVisible(); + }); + + it('should render single plugin as resource link', () => { + const plugins = [createMockPlugin('my-plugin')]; + + render( + + + , + ); + + expect(screen.getByRole('link', { name: 'my-plugin' })).toBeVisible(); + }); + + it('should render multiple plugins as list', () => { + const plugins = [ + createMockPlugin('plugin-one'), + createMockPlugin('plugin-two'), + createMockPlugin('plugin-three'), + ]; + + render( + + + , + ); + + expect(screen.getByRole('link', { name: 'plugin-one' })).toBeVisible(); + expect(screen.getByRole('link', { name: 'plugin-two' })).toBeVisible(); + expect(screen.getByRole('link', { name: 'plugin-three' })).toBeVisible(); + }); + + it('should render with correct kind reference', () => { + const plugins = [createMockPlugin('test-plugin')]; + + render( + + + , + ); + + expect(screen.getByRole('link', { name: 'test-plugin' })).toHaveAttribute( + 'href', + '#/test-resource/console.openshift.io~v1~ConsolePlugin/test-plugin', + ); + }); + + it('should render empty list when no plugins provided', () => { + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Failed plugins', level: 2 })).toBeVisible(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/console-app/src/components/favorite/FavoriteNavItem.tsx b/frontend/packages/console-app/src/components/favorite/FavoriteNavItem.tsx index 133683b7d75..cf952ee7ad8 100644 --- a/frontend/packages/console-app/src/components/favorite/FavoriteNavItem.tsx +++ b/frontend/packages/console-app/src/components/favorite/FavoriteNavItem.tsx @@ -13,7 +13,13 @@ export const FavoriteNavItem: FC = ({ }) => { return ( - + {children} diff --git a/frontend/packages/console-app/src/components/favorite/__tests__/FavoriteNavItem.spec.tsx b/frontend/packages/console-app/src/components/favorite/__tests__/FavoriteNavItem.spec.tsx new file mode 100644 index 00000000000..076a10521f4 --- /dev/null +++ b/frontend/packages/console-app/src/components/favorite/__tests__/FavoriteNavItem.spec.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { FavoriteNavItem } from '../FavoriteNavItem'; + +describe('FavoriteNavItem', () => { + it('should render children as link text', () => { + renderWithProviders( + + Test Link + , + ); + + expect(screen.getByRole('link', { name: 'Test Link' })).toBeVisible(); + }); + + it('should render link with correct href', () => { + renderWithProviders( + + Pods + , + ); + + const link = screen.getByRole('link', { name: 'Pods' }); + expect(link).toHaveAttribute('href', '/dashboard/pods'); + }); + + it('sets aria-current for active item when location matches the link', () => { + render( + + + Active Link + + , + ); + + expect(screen.getByRole('link', { name: 'Active Link' })).toBeVisible(); + expect(screen.getByRole('link', { name: 'Active Link' })).toHaveAttribute( + 'aria-current', + 'page', + ); + }); + + it('should not mark inactive link as current page', () => { + renderWithProviders( + + Inactive Link + , + ); + + const link = screen.getByRole('link', { name: 'Inactive Link' }); + expect(link).not.toHaveAttribute('aria-current', 'page'); + }); + + it('should render link within list item when custom className is provided', () => { + renderWithProviders( + + Custom Class Link + , + ); + + const link = screen.getByRole('link', { name: 'Custom Class Link' }); + expect(screen.getByRole('listitem')).toContainElement(link); + }); + + it('should pass data attributes to link', () => { + renderWithProviders( + + Link with Data Attr + , + ); + + const link = screen.getByRole('link', { name: 'Link with Data Attr' }); + expect(link).toHaveAttribute('data-test', 'favorite-link'); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/NavItemHref.tsx b/frontend/packages/console-app/src/components/nav/NavItemHref.tsx index c7676b575b4..b37c31d9530 100644 --- a/frontend/packages/console-app/src/components/nav/NavItemHref.tsx +++ b/frontend/packages/console-app/src/components/nav/NavItemHref.tsx @@ -39,7 +39,12 @@ export const NavItemHref: FC = ({ }, [activeNamespace, href, namespaced, prefixNamespaced]); return ( - + {children} diff --git a/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx new file mode 100644 index 00000000000..e8354ff7836 --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx @@ -0,0 +1,130 @@ +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useActivePerspective } from '@console/dynamic-plugin-sdk'; +import { usePerspectives } from '@console/shared/src/hooks/usePerspectives'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import NavHeader from '../NavHeader'; + +jest.mock('@console/dynamic-plugin-sdk', () => ({ + useActivePerspective: jest.fn(), +})); + +jest.mock('@console/shared/src/hooks/usePerspectives', () => ({ + usePerspectives: jest.fn(), +})); + +jest.mock('@console/internal/components/utils/async', () => ({ + AsyncComponent: () => null, +})); + +const mockSetActivePerspective = jest.fn(); + +const createMockPerspective = (id: string, name: string) => ({ + uid: `perspective-${id}`, + properties: { + id, + name, + icon: jest.fn(() => Promise.resolve({ default: () => null })), + }, +}); + +describe('NavHeader', () => { + const mockOnPerspectiveSelected = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useActivePerspective as jest.Mock).mockReturnValue(['admin', mockSetActivePerspective]); + }); + + describe('when multiple perspectives are available', () => { + const multiplePerspectives = [ + createMockPerspective('admin', 'Administrator'), + createMockPerspective('dev', 'Developer'), + ]; + + beforeEach(() => { + (usePerspectives as jest.Mock).mockReturnValue(multiplePerspectives); + }); + + it('should render perspective switcher dropdown with toggle button', () => { + renderWithProviders(); + + const toggle = screen.getByRole('button', { expanded: false }); + expect(toggle).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Administrator' })).toBeVisible(); + }); + + it('should open dropdown menu when toggle is clicked', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const toggle = screen.getByRole('button', { expanded: false }); + await user.click(toggle); + + expect(await screen.findByRole('button', { expanded: true })).toBeVisible(); + }); + + it('should display all perspective options in dropdown menu', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole('button')); + + expect(await screen.findByRole('listbox')).toBeVisible(); + expect(screen.getAllByRole('option')).toHaveLength(2); + }); + + it('should switch perspective when an option is selected', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole('button')); + + expect(await screen.findByRole('button', { expanded: true })).toBeVisible(); + + const devOption = screen.getByRole('heading', { name: 'Developer' }); + await user.click(devOption); + + expect(mockSetActivePerspective).toHaveBeenCalledWith('dev'); + expect(mockOnPerspectiveSelected).toHaveBeenCalled(); + }); + + it('should close dropdown after selecting a perspective', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole('button')); + + const listbox = await screen.findByRole('listbox'); + expect(listbox).toBeVisible(); + + const options = screen.getAllByRole('option'); + await user.click(options[0]); + + await waitForElementToBeRemoved(listbox); + }); + }); + + describe('when only one perspective is available', () => { + it('should render static Core platform text without dropdown', () => { + (usePerspectives as jest.Mock).mockReturnValue([ + createMockPerspective('admin', 'Administrator'), + ]); + + renderWithProviders(); + + expect(screen.getByRole('heading', { name: /Core platform/i })).toBeVisible(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + }); + + describe('when no perspectives are available', () => { + it('should render fallback when perspectives array is empty', () => { + (usePerspectives as jest.Mock).mockReturnValue([]); + + renderWithProviders(); + + expect(screen.getByRole('heading')).toBeVisible(); + }); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/NavItemHref.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/NavItemHref.spec.tsx new file mode 100644 index 00000000000..1cd5cb61741 --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/NavItemHref.spec.tsx @@ -0,0 +1,115 @@ +import { screen } from '@testing-library/react'; +import { useActiveNamespace } from '@console/shared/src/hooks/useActiveNamespace'; +import { useLocation } from '@console/shared/src/hooks/useLocation'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { NavItemHref } from '../NavItemHref'; + +jest.mock('@console/shared/src/hooks/useActiveNamespace', () => ({ + useActiveNamespace: jest.fn(), +})); + +jest.mock('@console/shared/src/hooks/useLocation', () => ({ + useLocation: jest.fn(), +})); + +describe('NavItemHref', () => { + const mockSetNamespace = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useActiveNamespace as jest.Mock).mockReturnValue(['default', mockSetNamespace]); + (useLocation as jest.Mock).mockReturnValue('/other/path'); + }); + + it('should render children as link text', () => { + renderWithProviders(Dashboard); + + expect(screen.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + }); + + it('should use href directly when not namespaced', () => { + renderWithProviders(Cluster Settings); + + const link = screen.getByRole('link', { name: 'Cluster Settings' }); + expect(link).toHaveAttribute('href', '/cluster-settings'); + }); + + it('should format namespaced route when namespaced prop is true', () => { + renderWithProviders( + + Pods + , + ); + + const link = screen.getByRole('link', { name: 'Pods' }); + // formatNamespacedRouteForHref formats as /{href}/ns/{namespace} + expect(link).toHaveAttribute('href', '/pods/ns/default'); + }); + + it('should format prefixNamespaced route correctly', () => { + renderWithProviders( + + ConfigMaps + , + ); + + const link = screen.getByRole('link', { name: 'ConfigMaps' }); + expect(link).toHaveAttribute('href', '/k8s/ns/default/configmaps'); + }); + + it('should mark nav item as active when location matches href', () => { + (useLocation as jest.Mock).mockReturnValue('/dashboard'); + + renderWithProviders(Dashboard); + + expect(screen.getByRole('link', { name: 'Dashboard', current: 'page' })).toBeVisible(); + }); + + it('should not be active when location does not match href', () => { + (useLocation as jest.Mock).mockReturnValue('/other/path'); + + renderWithProviders(Dashboard); + + expect(screen.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + expect( + screen.queryByRole('link', { name: 'Dashboard', current: 'page' }), + ).not.toBeInTheDocument(); + }); + + it('should pass data attributes to link', () => { + renderWithProviders( + + Settings + , + ); + + const link = screen.getByRole('link', { name: 'Settings' }); + expect(link).toHaveAttribute('data-test', 'settings-link'); + }); + + it('should be active when location matches startsWith pattern', () => { + (useLocation as jest.Mock).mockReturnValue('/monitoring/alerts'); + + renderWithProviders( + + Monitoring + , + ); + + expect(screen.getByRole('link', { name: 'Monitoring', current: 'page' })).toBeVisible(); + }); + + it('should handle all-namespaces scope in active namespace', () => { + (useActiveNamespace as jest.Mock).mockReturnValue(['#ALL_NS#', mockSetNamespace]); + + renderWithProviders( + + Pods + , + ); + + const link = screen.getByRole('link', { name: 'Pods' }); + // formatNamespacedRouteForHref formats as /{href}/all-namespaces for #ALL_NS# + expect(link).toHaveAttribute('href', '/pods/all-namespaces'); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/NavLink.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/NavLink.spec.tsx new file mode 100644 index 00000000000..9c91b445b9f --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/NavLink.spec.tsx @@ -0,0 +1,54 @@ +import { createRef } from 'react'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { NavLink } from '../NavLink'; + +describe('NavLink', () => { + it('should render children correctly', () => { + renderWithProviders( + + Test Link Content + , + ); + + expect(screen.getByText('Test Link Content')).toBeVisible(); + }); + + it('should render as a link with correct href', () => { + renderWithProviders(Dashboard); + + const link = screen.getByRole('link', { name: 'Dashboard' }); + expect(link).toHaveAttribute('href', '/dashboard'); + }); + + it('should have data-test attribute for testing', () => { + renderWithProviders(Test); + + const link = screen.getByRole('link', { name: 'Test' }); + expect(link).toHaveAttribute('data-test', 'nav'); + }); + + it('should pass additional link props to the underlying Link component', () => { + renderWithProviders( + + Settings + , + ); + + const link = screen.getByRole('link', { name: 'Settings page' }); + expect(link).toHaveAttribute('href', '/settings'); + }); + + it('should forward dragRef to the Link element', () => { + const dragRef = createRef(); + + renderWithProviders( + + Draggable Item + , + ); + + expect(dragRef.current).toBeInstanceOf(HTMLAnchorElement); + expect(dragRef.current).toHaveAttribute('href', '/draggable'); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/NavSection.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/NavSection.spec.tsx new file mode 100644 index 00000000000..65f3453695b --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/NavSection.spec.tsx @@ -0,0 +1,194 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { HrefNavItem, ResourceNavItem, Separator } from '@console/dynamic-plugin-sdk'; +import { + isHrefNavItem, + isResourceNavItem, + isSeparator, + isResourceNSNavItem, +} from '@console/dynamic-plugin-sdk'; +import type { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types'; +import { useK8sModels } from '@console/shared/src/hooks/useK8sModels'; +import { useLocation } from '@console/shared/src/hooks/useLocation'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { NavSection } from '../NavSection'; +import { useNavExtensionsForSection } from '../useNavExtensionsForSection'; + +jest.mock('@console/shared/src/hooks/useK8sModels', () => ({ + useK8sModels: jest.fn(), +})); + +jest.mock('@console/shared/src/hooks/useLocation', () => ({ + useLocation: jest.fn(), +})); + +jest.mock('../useNavExtensionsForSection', () => ({ + useNavExtensionsForSection: jest.fn(), +})); + +jest.mock('../NavItemHref', () => ({ + NavItemHref: ({ children }: { children: React.ReactNode }) => ( +
  • {children}
  • + ), +})); + +jest.mock('../NavItemResource', () => ({ + NavItemResource: ({ children }: { children: React.ReactNode }) => ( +
  • {children}
  • + ), +})); + +jest.mock('@console/dynamic-plugin-sdk', () => ({ + ...jest.requireActual('@console/dynamic-plugin-sdk'), + isHrefNavItem: jest.fn(), + isResourceNavItem: jest.fn(), + isSeparator: jest.fn(), + isResourceNSNavItem: jest.fn(), +})); + +const createHrefExtension = (id: string, name: string): LoadedExtension => + ({ + type: 'console.navigation/href', + uid: `uid-${id}`, + properties: { + id, + name, + href: `/${id}`, + section: 'workloads', + }, + } as LoadedExtension); + +const createResourceExtension = (id: string, name: string): LoadedExtension => + ({ + type: 'console.navigation/resource-ns', + uid: `uid-${id}`, + properties: { + id, + name, + model: { group: '', version: 'v1', kind: 'Pod' }, + section: 'workloads', + }, + } as LoadedExtension); + +const createSeparatorExtension = (id: string): LoadedExtension => + ({ + type: 'console.navigation/separator', + uid: `uid-${id}`, + properties: { + id, + section: 'workloads', + }, + } as LoadedExtension); + +describe('NavSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useK8sModels as jest.Mock).mockReturnValue([{}]); + (useLocation as jest.Mock).mockReturnValue('/other/path'); + (useNavExtensionsForSection as jest.Mock).mockReturnValue([]); + ((isHrefNavItem as unknown) as jest.Mock).mockReturnValue(false); + ((isResourceNavItem as unknown) as jest.Mock).mockReturnValue(false); + ((isSeparator as unknown) as jest.Mock).mockReturnValue(false); + ((isResourceNSNavItem as unknown) as jest.Mock).mockReturnValue(false); + }); + + it('should return null when section has no children', () => { + (useNavExtensionsForSection as jest.Mock).mockReturnValue([]); + + renderWithProviders(); + + expect(screen.queryByRole('button', { name: /Empty Section/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Navigation' })).not.toBeInTheDocument(); + }); + + it('should render expandable section with name', () => { + const hrefExtension = createHrefExtension('dashboard', 'Dashboard'); + (useNavExtensionsForSection as jest.Mock).mockReturnValue([hrefExtension]); + ((isHrefNavItem as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + expect(screen.getByRole('button', { name: /Workloads/i })).toBeVisible(); + }); + + it('should render NavGroup without title when name is empty', () => { + const hrefExtension = createHrefExtension('home', 'Home'); + (useNavExtensionsForSection as jest.Mock).mockReturnValue([hrefExtension]); + ((isHrefNavItem as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + // Should render children but not as expandable + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByText('Home')).toBeVisible(); + }); + + it('should render href nav items', async () => { + const user = userEvent.setup(); + const hrefExtension = createHrefExtension('dashboard', 'Dashboard'); + (useNavExtensionsForSection as jest.Mock).mockReturnValue([hrefExtension]); + ((isHrefNavItem as unknown) as jest.Mock).mockImplementation((ext) => ext === hrefExtension); + + renderWithProviders(); + + await user.click(screen.getByRole('button', { name: /Workloads/i })); + + expect(await screen.findByText('Dashboard')).toBeVisible(); + }); + + it('should render resource nav items', async () => { + const user = userEvent.setup(); + const resourceExtension = createResourceExtension('pods', 'Pods'); + (useNavExtensionsForSection as jest.Mock).mockReturnValue([resourceExtension]); + ((isResourceNavItem as unknown) as jest.Mock).mockImplementation( + (ext) => ext === resourceExtension, + ); + + renderWithProviders(); + + await user.click(screen.getByRole('button', { name: /Workloads/i })); + + expect(await screen.findByText('Pods')).toBeVisible(); + }); + + it('should render separators between items', () => { + const separatorExtension = createSeparatorExtension('separator-1'); + (useNavExtensionsForSection as jest.Mock).mockReturnValue([separatorExtension]); + ((isSeparator as unknown) as jest.Mock).mockImplementation((ext) => ext === separatorExtension); + + renderWithProviders(); + + expect(screen.getByRole('presentation', { hidden: true })).toBeInTheDocument(); + }); + + it('should expand section when toggle is clicked', async () => { + const user = userEvent.setup(); + const hrefExtension = createHrefExtension('dashboard', 'Dashboard'); + (useNavExtensionsForSection as jest.Mock).mockReturnValue([hrefExtension]); + ((isHrefNavItem as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + const toggle = screen.getByRole('button', { name: /Workloads/i }); + await user.click(toggle); + + expect(await screen.findByRole('button', { name: /Workloads/i, expanded: true })).toBe(toggle); + }); + + it('should pass data attributes to section', () => { + const hrefExtension = createHrefExtension('dashboard', 'Dashboard'); + (useNavExtensionsForSection as jest.Mock).mockReturnValue([hrefExtension]); + ((isHrefNavItem as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders( + , + ); + + const toggle = screen.getByRole('button', { name: /Workloads/i }); + expect(toggle).toHaveAttribute('data-test', 'workloads-section'); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/Navigation.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/Navigation.spec.tsx new file mode 100644 index 00000000000..4b35915a066 --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/Navigation.spec.tsx @@ -0,0 +1,108 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { Navigation } from '../index'; + +jest.mock('../NavHeader', () => ({ + __esModule: true, + default: ({ onPerspectiveSelected }: { onPerspectiveSelected: () => void }) => ( +
    + +
    + ), +})); + +jest.mock('../PerspectiveNav', () => ({ + __esModule: true, + default: () => ( +
    + Perspective Nav Content +
    + ), +})); + +describe('Navigation', () => { + const mockOnNavSelect = jest.fn(); + const mockOnPerspectiveSelected = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render navigation sidebar when isNavOpen is true', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('navigation')).toBeVisible(); + }); + + it('should render NavHeader component', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('region', { name: 'Nav header' })).toBeVisible(); + }); + + it('should render PerspectiveNav component', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('region', { name: 'Perspective navigation' })).toBeVisible(); + }); + + it('should pass onPerspectiveSelected callback to NavHeader', async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole('button', { name: 'Select Perspective' })); + + expect(mockOnPerspectiveSelected).toHaveBeenCalled(); + }); + + it('should have accessible aria-label on Nav', () => { + renderWithProviders( + , + ); + + expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Nav'); + }); + + it('should hide sidebar when isNavOpen is false', () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId('navigation-page-sidebar')).toHaveAttribute('aria-hidden', 'true'); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/PinnedResource.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/PinnedResource.spec.tsx new file mode 100644 index 00000000000..32b71b4f947 --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/PinnedResource.spec.tsx @@ -0,0 +1,123 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useK8sModel } from '@console/shared/src/hooks/useK8sModel'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import PinnedResource from '../PinnedResource'; +import useConfirmNavUnpinModal from '../useConfirmNavUnpinModal'; + +// Note: useConfirmNavUnpinModal is imported for the type check in assertions + +jest.mock('@console/shared/src/hooks/useK8sModel', () => ({ + useK8sModel: jest.fn(), +})); + +const mockConfirmUnpinModal = jest.fn(); +jest.mock('../useConfirmNavUnpinModal', () => ({ + __esModule: true, + default: jest.fn(() => mockConfirmUnpinModal), +})); + +jest.mock('../NavItemResource', () => ({ + NavItemResource: ({ children }: { children: React.ReactNode }) => children, +})); + +const mockK8sModel = { + apiVersion: 'v1', + apiGroup: '', + namespaced: true, + kind: 'ConfigMap', + label: 'ConfigMap', + labelPlural: 'ConfigMaps', + labelPluralKey: 'public~ConfigMaps', + plural: 'configmaps', + abbr: 'CM', +}; + +describe('PinnedResource', () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null when model is not found', () => { + (useK8sModel as jest.Mock).mockReturnValue([undefined, false]); + + renderWithProviders( + , + ); + + expect(screen.queryByTestId('draggable-pinned-resource-item')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Unpin' })).not.toBeInTheDocument(); + }); + + it('should render pinned resource with label when model exists', () => { + (useK8sModel as jest.Mock).mockReturnValue([mockK8sModel, false]); + + renderWithProviders( + , + ); + + expect(screen.getByText(/ConfigMaps/)).toBeInTheDocument(); + }); + + it('should render unpin button with accessible label', () => { + (useK8sModel as jest.Mock).mockReturnValue([mockK8sModel, false]); + + renderWithProviders( + , + ); + + expect(screen.getByRole('button', { name: 'Unpin' })).toBeVisible(); + }); + + it('should call confirm unpin modal when remove button is clicked', async () => { + const user = userEvent.setup(); + (useK8sModel as jest.Mock).mockReturnValue([mockK8sModel, false]); + + renderWithProviders( + , + ); + + const unpinButton = screen.getByRole('button', { name: 'Unpin' }); + await user.click(unpinButton); + + expect(mockConfirmUnpinModal).toHaveBeenCalledWith('core~v1~ConfigMap'); + }); + + it('should pass onChange and navResources to useConfirmNavUnpinModal', () => { + (useK8sModel as jest.Mock).mockReturnValue([mockK8sModel, false]); + const navResources = ['core~v1~ConfigMap', 'build.openshift.io~v1~BuildConfig']; + + renderWithProviders( + , + ); + + expect(useConfirmNavUnpinModal).toHaveBeenCalledWith(navResources, mockOnChange); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/PluginNavItem.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/PluginNavItem.spec.tsx new file mode 100644 index 00000000000..f26a523c197 --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/PluginNavItem.spec.tsx @@ -0,0 +1,195 @@ +import { screen } from '@testing-library/react'; +import type { + NavSection as NavSectionType, + HrefNavItem, + ResourceNavItem, + Separator, +} from '@console/dynamic-plugin-sdk'; +import { + isNavSection, + isSeparator, + isHrefNavItem, + isResourceNSNavItem, + isResourceNavItem, + useActivePerspective, +} from '@console/dynamic-plugin-sdk'; +import type { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { PluginNavItem } from '../PluginNavItem'; + +jest.mock('@console/dynamic-plugin-sdk', () => ({ + ...jest.requireActual('@console/dynamic-plugin-sdk'), + isNavSection: jest.fn(), + isSeparator: jest.fn(), + isHrefNavItem: jest.fn(), + isResourceNSNavItem: jest.fn(), + isResourceNavItem: jest.fn(), + useActivePerspective: jest.fn(), +})); + +jest.mock('../NavSection', () => ({ + NavSection: ({ id, name }: { id: string; name: string }) => ( +
    + {name} +
    + ), +})); + +jest.mock('../NavItemHref', () => ({ + NavItemHref: ({ children }: { children: React.ReactNode }) => ( +
  • {children}
  • + ), +})); + +jest.mock('../NavItemResource', () => ({ + NavItemResource: ({ children }: { children: React.ReactNode }) => ( +
  • {children}
  • + ), +})); + +jest.mock('../../favorite/FavoriteNavItems', () => ({ + FavoriteNavItems: () =>
    Favorites
    , +})); + +const createNavSectionExtension = (id: string, name: string): LoadedExtension => + ({ + type: 'console.navigation/section', + uid: `uid-${id}`, + properties: { + id, + name, + }, + } as LoadedExtension); + +const createHrefExtension = (id: string, name: string): LoadedExtension => + ({ + type: 'console.navigation/href', + uid: `uid-${id}`, + properties: { + id, + name, + href: `/${id}`, + }, + } as LoadedExtension); + +const createResourceExtension = (id: string, name: string): LoadedExtension => + ({ + type: 'console.navigation/resource-ns', + uid: `uid-${id}`, + properties: { + id, + name, + model: { group: '', version: 'v1', kind: 'Pod' }, + }, + } as LoadedExtension); + +const createSeparatorExtension = (id: string): LoadedExtension => + ({ + type: 'console.navigation/separator', + uid: `uid-${id}`, + properties: { + id, + }, + } as LoadedExtension); + +describe('PluginNavItem', () => { + const mockSetPerspective = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useActivePerspective as jest.Mock).mockReturnValue(['admin', mockSetPerspective]); + ((isNavSection as unknown) as jest.Mock).mockReturnValue(false); + ((isSeparator as unknown) as jest.Mock).mockReturnValue(false); + ((isHrefNavItem as unknown) as jest.Mock).mockReturnValue(false); + ((isResourceNavItem as unknown) as jest.Mock).mockReturnValue(false); + ((isResourceNSNavItem as unknown) as jest.Mock).mockReturnValue(false); + }); + + it('should render NavSection for section extension', () => { + const sectionExtension = createNavSectionExtension('workloads', 'Workloads'); + ((isNavSection as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + expect(screen.getByTestId('nav-section')).toBeVisible(); + expect(screen.getByText('Workloads')).toBeVisible(); + }); + + it('should render FavoriteNavItems for home section in admin perspective', () => { + const homeSection = createNavSectionExtension('home', 'Home'); + ((isNavSection as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + expect(screen.getByTestId('favorite-nav-items')).toBeVisible(); + }); + + it('should not render FavoriteNavItems for home section in non-admin perspective', () => { + (useActivePerspective as jest.Mock).mockReturnValue(['dev', mockSetPerspective]); + const homeSection = createNavSectionExtension('home', 'Home'); + ((isNavSection as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + expect(screen.queryByTestId('favorite-nav-items')).not.toBeInTheDocument(); + }); + + it('should render separator with presentation role', () => { + const separatorExtension = createSeparatorExtension('sep-1'); + ((isSeparator as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + expect(screen.getByRole('presentation')).toBeInTheDocument(); + }); + + it('should render NavItemHref for href extension', () => { + const hrefExtension = createHrefExtension('dashboard', 'Dashboard'); + ((isHrefNavItem as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + expect(screen.getByTestId('nav-item-href')).toBeVisible(); + expect(screen.getByText('Dashboard')).toBeVisible(); + }); + + it('should render NavItemResource for resource extension', () => { + const resourceExtension = createResourceExtension('pods', 'Pods'); + ((isResourceNavItem as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + expect(screen.getByTestId('nav-item-resource')).toBeVisible(); + expect(screen.getByText('Pods')).toBeVisible(); + }); + + it('should return null and warn for unrecognized extension', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const unknownExtension = ({ + type: 'console.navigation/unknown', + uid: 'uid-unknown', + properties: { id: 'unknown' }, + } as unknown) as LoadedExtension; + + renderWithProviders(); + + expect(screen.queryByTestId('nav-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('favorite-nav-items')).not.toBeInTheDocument(); + expect(screen.queryByTestId('nav-item-href')).not.toBeInTheDocument(); + expect(screen.queryByTestId('nav-item-resource')).not.toBeInTheDocument(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Invalid or unrecognized navigation extension:', + unknownExtension, + ); + consoleSpy.mockRestore(); + }); + + it('should not render FavoriteNavItems for non-home sections', () => { + const workloadsSection = createNavSectionExtension('workloads', 'Workloads'); + ((isNavSection as unknown) as jest.Mock).mockReturnValue(true); + + renderWithProviders(); + + expect(screen.queryByTestId('favorite-nav-items')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/useConfirmNavUnpinModal.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/useConfirmNavUnpinModal.spec.tsx new file mode 100644 index 00000000000..653ef626688 --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/useConfirmNavUnpinModal.spec.tsx @@ -0,0 +1,113 @@ +import { isValidElement } from 'react'; +import type { ReactElement } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { modelFor } from '@console/internal/module/k8s'; +import { useWarningModal } from '@console/shared/src/hooks/useWarningModal'; +import useConfirmNavUnpinModal from '../useConfirmNavUnpinModal'; + +jest.mock('@console/internal/module/k8s', () => ({ + modelFor: jest.fn(), +})); + +jest.mock('@console/shared/src/hooks/useWarningModal', () => ({ + useWarningModal: jest.fn(), +})); + +describe('useConfirmNavUnpinModal', () => { + const mockConfirmModalLauncher = jest.fn(); + const mockUpdatePinsFn = jest.fn(); + const pinnedResources = ['core~v1~ConfigMap', 'apps~v1~Deployment']; + + beforeEach(() => { + jest.clearAllMocks(); + (useWarningModal as jest.Mock).mockReturnValue(mockConfirmModalLauncher); + (modelFor as jest.Mock).mockReturnValue({ labelPlural: 'ConfigMaps' }); + }); + + it('should initialize useWarningModal with correct options', () => { + renderHook(() => useConfirmNavUnpinModal(pinnedResources, mockUpdatePinsFn)); + + expect(useWarningModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.any(String), + cancelButtonLabel: expect.any(String), + confirmButtonLabel: expect.any(String), + ouiaId: 'NavigationUnpinConfirmation', + }), + ); + }); + + it('should return a function to launch the confirm modal', () => { + const { result } = renderHook(() => useConfirmNavUnpinModal(pinnedResources, mockUpdatePinsFn)); + + expect(typeof result.current).toBe('function'); + }); + + it('should launch modal with correct message when called', () => { + const { result } = renderHook(() => useConfirmNavUnpinModal(pinnedResources, mockUpdatePinsFn)); + + act(() => { + result.current('core~v1~ConfigMap'); + }); + + expect(mockConfirmModalLauncher).toHaveBeenCalledTimes(1); + const { children, onConfirm } = mockConfirmModalLauncher.mock.calls[0][0]; + expect(typeof onConfirm).toBe('function'); + expect(isValidElement(children)).toBe(true); + expect((children as ReactElement).type).toBe('span'); + }); + + it('should remove resource from pinned list when confirmed', async () => { + const { result } = renderHook(() => useConfirmNavUnpinModal(pinnedResources, mockUpdatePinsFn)); + + act(() => { + result.current('core~v1~ConfigMap'); + }); + + // Get the onConfirm callback that was passed to the modal + const { onConfirm } = mockConfirmModalLauncher.mock.calls[0][0]; + + await act(async () => { + await onConfirm(); + }); + + expect(mockUpdatePinsFn).toHaveBeenCalledWith(['apps~v1~Deployment']); + }); + + it('should use model labelPlural for confirmation message', () => { + (modelFor as jest.Mock).mockReturnValue({ labelPlural: 'Deployments' }); + + const { result } = renderHook(() => useConfirmNavUnpinModal(pinnedResources, mockUpdatePinsFn)); + + act(() => { + result.current('apps~v1~Deployment'); + }); + + expect(modelFor).toHaveBeenCalledWith('apps~v1~Deployment'); + expect(mockConfirmModalLauncher).toHaveBeenCalled(); + }); + + it('should handle undefined model gracefully', () => { + (modelFor as jest.Mock).mockReturnValue(undefined); + + const { result } = renderHook(() => useConfirmNavUnpinModal(pinnedResources, mockUpdatePinsFn)); + + act(() => { + result.current('unknown~v1~Resource'); + }); + + expect(mockConfirmModalLauncher).toHaveBeenCalled(); + }); + + it('should maintain stable callback reference', () => { + const { result, rerender } = renderHook(() => + useConfirmNavUnpinModal(pinnedResources, mockUpdatePinsFn), + ); + + const firstCallback = result.current; + rerender(); + const secondCallback = result.current; + + expect(firstCallback).toBe(secondCallback); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/useNavExtensionForPerspective.spec.ts b/frontend/packages/console-app/src/components/nav/__tests__/useNavExtensionForPerspective.spec.ts new file mode 100644 index 00000000000..eb2b272d1a6 --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/useNavExtensionForPerspective.spec.ts @@ -0,0 +1,133 @@ +import { renderHook } from '@testing-library/react'; +import type { NavExtension } from '@console/dynamic-plugin-sdk/src/lib-core'; +import type { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types'; +import { useExtensions } from '@console/plugin-sdk/src/api/useExtensions'; +import { usePerspectives } from '@console/shared/src/hooks/usePerspectives'; +import { useNavExtensionsForPerspective } from '../useNavExtensionForPerspective'; + +jest.mock('@console/plugin-sdk/src/api/useExtensions', () => ({ + useExtensions: jest.fn(), +})); + +jest.mock('@console/shared/src/hooks/usePerspectives', () => ({ + usePerspectives: jest.fn(), +})); + +const createNavExtension = (id: string, perspective?: string): LoadedExtension => + ({ + type: 'console.navigation/href', + uid: `uid-${id}`, + properties: { + id, + name: `Nav ${id}`, + href: `/${id}`, + perspective, + }, + } as LoadedExtension); + +const createPerspective = (id: string, isDefault = false) => ({ + uid: `perspective-${id}`, + properties: { + id, + name: id.charAt(0).toUpperCase() + id.slice(1), + default: isDefault, + }, +}); + +describe('useNavExtensionsForPerspective', () => { + beforeEach(() => { + jest.clearAllMocks(); + (usePerspectives as jest.Mock).mockReturnValue([]); + (useExtensions as jest.Mock).mockReturnValue([]); + }); + + it('should return empty array when no extensions exist', () => { + (useExtensions as jest.Mock).mockReturnValue([]); + + const { result } = renderHook(() => useNavExtensionsForPerspective('admin')); + + expect(result.current).toEqual([]); + }); + + it('should return extensions matching the perspective', () => { + const adminExtension = createNavExtension('admin-nav', 'admin'); + const devExtension = createNavExtension('dev-nav', 'dev'); + (useExtensions as jest.Mock).mockReturnValue([adminExtension, devExtension]); + + const { result } = renderHook(() => useNavExtensionsForPerspective('admin')); + + expect(result.current).toHaveLength(1); + expect(result.current[0].properties.id).toBe('admin-nav'); + }); + + it('should include extensions without perspective for default perspective', () => { + const adminPerspective = createPerspective('admin', true); + const noPerspectiveExtension = createNavExtension('global-nav'); + const adminExtension = createNavExtension('admin-nav', 'admin'); + + (usePerspectives as jest.Mock).mockReturnValue([adminPerspective]); + (useExtensions as jest.Mock).mockReturnValue([noPerspectiveExtension, adminExtension]); + + const { result } = renderHook(() => useNavExtensionsForPerspective('admin')); + + expect(result.current).toHaveLength(2); + }); + + it('should not include extensions without perspective for non-default perspective', () => { + const adminPerspective = createPerspective('admin', true); + const devPerspective = createPerspective('dev', false); + const noPerspectiveExtension = createNavExtension('global-nav'); + const devExtension = createNavExtension('dev-nav', 'dev'); + + (usePerspectives as jest.Mock).mockReturnValue([adminPerspective, devPerspective]); + (useExtensions as jest.Mock).mockReturnValue([noPerspectiveExtension, devExtension]); + + const { result } = renderHook(() => useNavExtensionsForPerspective('dev')); + + expect(result.current).toHaveLength(1); + expect(result.current[0].properties.id).toBe('dev-nav'); + }); + + it('should handle undefined perspectives array', () => { + (usePerspectives as jest.Mock).mockReturnValue(undefined); + const adminExtension = createNavExtension('admin-nav', 'admin'); + (useExtensions as jest.Mock).mockReturnValue([adminExtension]); + + const { result } = renderHook(() => useNavExtensionsForPerspective('admin')); + + expect(result.current).toHaveLength(1); + }); + + it('should return new array reference when extensions change', () => { + const ext1 = createNavExtension('nav-1', 'admin'); + const ext2 = createNavExtension('nav-2', 'admin'); + + (useExtensions as jest.Mock).mockReturnValue([ext1]); + + const { result, rerender } = renderHook(() => useNavExtensionsForPerspective('admin')); + const firstResult = result.current; + + (useExtensions as jest.Mock).mockReturnValue([ext1, ext2]); + rerender(); + + expect(result.current).not.toBe(firstResult); + expect(result.current).toHaveLength(2); + }); + + it('should handle multiple perspectives with same extension', () => { + const adminPerspective = createPerspective('admin', false); + const devPerspective = createPerspective('dev', true); + const sharedExtension = createNavExtension('shared-nav'); + + (usePerspectives as jest.Mock).mockReturnValue([adminPerspective, devPerspective]); + (useExtensions as jest.Mock).mockReturnValue([sharedExtension]); + + // For default perspective (dev), should include extensions without perspective + const { result: devResult } = renderHook(() => useNavExtensionsForPerspective('dev')); + expect(devResult.current).toHaveLength(1); + + // For non-default perspective (admin), should not include + const { result: adminResult } = renderHook(() => useNavExtensionsForPerspective('admin')); + expect(adminResult.current).toHaveLength(0); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/useNavExtensionsForSection.spec.ts b/frontend/packages/console-app/src/components/nav/__tests__/useNavExtensionsForSection.spec.ts new file mode 100644 index 00000000000..aa4077f35dd --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/useNavExtensionsForSection.spec.ts @@ -0,0 +1,121 @@ +import { renderHook } from '@testing-library/react'; +import { useActivePerspective } from '@console/dynamic-plugin-sdk/src/lib-core'; +import type { NavExtension } from '@console/dynamic-plugin-sdk/src/lib-core'; +import type { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types'; +import { useNavExtensionsForPerspective } from '../useNavExtensionForPerspective'; +import { useNavExtensionsForSection } from '../useNavExtensionsForSection'; + +jest.mock('@console/dynamic-plugin-sdk/src/lib-core', () => ({ + useActivePerspective: jest.fn(), +})); + +jest.mock('../useNavExtensionForPerspective', () => ({ + useNavExtensionsForPerspective: jest.fn(), +})); + +const createNavExtension = ( + id: string, + section?: string, + insertAfter?: string, +): LoadedExtension => + ({ + type: 'console.navigation/href', + uid: `uid-${id}`, + properties: { + id, + name: `Nav ${id}`, + href: `/${id}`, + section, + insertAfter, + }, + } as LoadedExtension); + +describe('useNavExtensionsForSection', () => { + const mockSetPerspective = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useActivePerspective as jest.Mock).mockReturnValue(['admin', mockSetPerspective]); + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([]); + }); + + it('should return empty array when no extensions for section', () => { + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([]); + + const { result } = renderHook(() => useNavExtensionsForSection('workloads')); + + expect(result.current).toEqual([]); + }); + + it('should return extensions matching the section', () => { + const workloadsExt = createNavExtension('pods', 'workloads'); + const networkingExt = createNavExtension('services', 'networking'); + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([workloadsExt, networkingExt]); + + const { result } = renderHook(() => useNavExtensionsForSection('workloads')); + + expect(result.current).toHaveLength(1); + expect(result.current[0].properties.id).toBe('pods'); + }); + + it('should sort extensions using getSortedNavExtensions', () => { + // ext2 has insertAfter: 'deployments', so it should come after ext1 + const ext1 = createNavExtension('deployments', 'workloads'); + const ext2 = createNavExtension('pods', 'workloads', 'deployments'); + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([ext2, ext1]); + + const { result } = renderHook(() => useNavExtensionsForSection('workloads')); + + // Verify sorting happened - ext1 (deployments) should come before ext2 (pods) + expect(result.current).toHaveLength(2); + expect(result.current[0].properties.id).toBe('deployments'); + expect(result.current[1].properties.id).toBe('pods'); + }); + + it('should use active perspective from hook', () => { + (useActivePerspective as jest.Mock).mockReturnValue(['dev', mockSetPerspective]); + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([]); + + renderHook(() => useNavExtensionsForSection('workloads')); + + expect(useNavExtensionsForPerspective).toHaveBeenCalledWith('dev'); + }); + + it('should filter extensions by exact section match', () => { + const workloadsExt = createNavExtension('pods', 'workloads'); + const workloads2Ext = createNavExtension('deployments', 'workloads-extra'); + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([workloadsExt, workloads2Ext]); + + const { result } = renderHook(() => useNavExtensionsForSection('workloads')); + + expect(result.current).toHaveLength(1); + expect(result.current[0].properties.id).toBe('pods'); + }); + + it('should memoize result when inputs unchanged', () => { + const ext1 = createNavExtension('pods', 'workloads'); + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([ext1]); + + const { result, rerender } = renderHook(() => useNavExtensionsForSection('workloads')); + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); + + it('should update result when extensions change', () => { + const ext1 = createNavExtension('pods', 'workloads'); + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([ext1]); + + const { result, rerender } = renderHook(() => useNavExtensionsForSection('workloads')); + const firstResult = result.current; + + const ext2 = createNavExtension('deployments', 'workloads'); + (useNavExtensionsForPerspective as jest.Mock).mockReturnValue([ext1, ext2]); + rerender(); + + expect(result.current).not.toBe(firstResult); + expect(result.current).toHaveLength(2); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/utils.spec.ts b/frontend/packages/console-app/src/components/nav/__tests__/utils.spec.ts new file mode 100644 index 00000000000..3e4aeacde13 --- /dev/null +++ b/frontend/packages/console-app/src/components/nav/__tests__/utils.spec.ts @@ -0,0 +1,263 @@ +import type { NavExtension, K8sModel } from '@console/dynamic-plugin-sdk'; +import type { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types'; +import { + getSortedNavExtensions, + sortExtensionItems, + stripScopeFromPath, + navItemHrefIsActive, + navItemResourceIsActive, + isTopLevelNavItem, +} from '../utils'; + +const createNavExtension = ( + id: string, + overrides: Partial = {}, +): LoadedExtension => + ({ + type: 'console.navigation/href', + uid: `uid-${id}`, + properties: { + id, + name: `Nav ${id}`, + href: `/${id}`, + ...overrides, + }, + } as LoadedExtension); + +const createNavSection = ( + id: string, + overrides: Partial = {}, +): LoadedExtension => + ({ + type: 'console.navigation/section', + uid: `uid-${id}`, + properties: { + id, + name: `Section ${id}`, + ...overrides, + }, + } as LoadedExtension); + +describe('utils', () => { + describe('stripScopeFromPath', () => { + it('should strip k8s/cluster/ prefix from path', () => { + expect(stripScopeFromPath('/k8s/cluster/nodes')).toBe('nodes'); + }); + + it('should strip k8s/ns// prefix from path', () => { + expect(stripScopeFromPath('/k8s/ns/default/pods')).toBe('pods'); + }); + + it('should strip k8s/all-namespaces/ prefix from path', () => { + expect(stripScopeFromPath('/k8s/all-namespaces/deployments')).toBe('deployments'); + }); + + it('should handle paths without k8s scope', () => { + expect(stripScopeFromPath('/monitoring/alerts')).toBe('monitoring/alerts'); + }); + + it('should handle trailing slashes', () => { + expect(stripScopeFromPath('/k8s/cluster/nodes/')).toBe('nodes'); + }); + + it('should handle complex namespace names', () => { + expect(stripScopeFromPath('/k8s/ns/my-project-123/configmaps')).toBe('configmaps'); + }); + }); + + describe('navItemHrefIsActive', () => { + it('should return true when location matches href exactly', () => { + expect(navItemHrefIsActive('/k8s/cluster/nodes', '/k8s/cluster/nodes')).toBe(true); + }); + + it('should return true when location starts with href segments', () => { + expect(navItemHrefIsActive('/k8s/cluster/nodes/node-1', '/k8s/cluster/nodes')).toBe(true); + }); + + it('should return false when location does not match href', () => { + expect(navItemHrefIsActive('/k8s/cluster/pods', '/k8s/cluster/nodes')).toBe(false); + }); + + it('should return true when location matches startsWith array', () => { + expect(navItemHrefIsActive('/monitoring/alerts', '/monitoring', ['monitoring/alerts'])).toBe( + true, + ); + }); + + it('should handle namespaced paths correctly', () => { + expect(navItemHrefIsActive('/k8s/ns/default/pods', '/k8s/ns/test/pods')).toBe(true); + }); + + it('should return false when startsWith does not match', () => { + expect(navItemHrefIsActive('/other/path', '/monitoring', ['monitoring/alerts'])).toBe(false); + }); + }); + + describe('navItemResourceIsActive', () => { + const mockK8sModel: K8sModel = { + apiVersion: 'v1', + apiGroup: '', + kind: 'Pod', + label: 'Pod', + labelPlural: 'Pods', + plural: 'pods', + abbr: 'P', + namespaced: true, + }; + + it('should return true when location matches model plural', () => { + expect(navItemResourceIsActive('/k8s/ns/default/pods', mockK8sModel)).toBe(true); + }); + + it('should return false when location does not match model', () => { + expect(navItemResourceIsActive('/k8s/ns/default/deployments', mockK8sModel)).toBe(false); + }); + + it('should return true when location matches startsWith', () => { + expect(navItemResourceIsActive('/workloads/pods', mockK8sModel, ['workloads'])).toBe(true); + }); + + it('should return false when model is undefined', () => { + expect(navItemResourceIsActive('/k8s/ns/default/pods', undefined)).toBe(false); + }); + + it('should handle cluster-scoped resources', () => { + const clusterModel: K8sModel = { + apiVersion: 'v1', + apiGroup: '', + kind: 'Node', + label: 'Node', + labelPlural: 'Nodes', + plural: 'nodes', + abbr: 'N', + namespaced: false, + }; + expect(navItemResourceIsActive('/k8s/cluster/nodes', clusterModel)).toBe(true); + }); + }); + + describe('getSortedNavExtensions', () => { + it('should return items in original order when no positioning specified', () => { + const items = [createNavExtension('a'), createNavExtension('b'), createNavExtension('c')]; + + const sorted = getSortedNavExtensions(items); + + expect(sorted.map((i) => i.properties.id)).toEqual(['a', 'b', 'c']); + }); + + it('should position item before another using insertBefore', () => { + const items = [ + createNavExtension('a'), + createNavExtension('b'), + createNavExtension('c', { insertBefore: 'a' }), + ]; + + const sorted = getSortedNavExtensions(items); + + expect(sorted.map((i) => i.properties.id)).toEqual(['c', 'a', 'b']); + }); + + it('should position item after another using insertAfter', () => { + const items = [ + createNavExtension('a'), + createNavExtension('b'), + createNavExtension('c', { insertAfter: 'a' }), + ]; + + const sorted = getSortedNavExtensions(items); + + expect(sorted.map((i) => i.properties.id)).toEqual(['a', 'c', 'b']); + }); + + it('should handle insertBefore with array of targets', () => { + const items = [ + createNavExtension('a'), + createNavExtension('b'), + createNavExtension('c', { insertBefore: ['nonexistent', 'b'] }), + ]; + + const sorted = getSortedNavExtensions(items); + + expect(sorted.map((i) => i.properties.id)).toEqual(['a', 'c', 'b']); + }); + + it('should handle insertAfter with array of targets', () => { + const items = [ + createNavExtension('a'), + createNavExtension('b'), + createNavExtension('c', { insertAfter: ['nonexistent', 'a'] }), + ]; + + const sorted = getSortedNavExtensions(items); + + expect(sorted.map((i) => i.properties.id)).toEqual(['a', 'c', 'b']); + }); + + it('should handle circular dependencies gracefully', () => { + const items = [ + createNavExtension('a', { insertAfter: 'b' }), + createNavExtension('b', { insertAfter: 'a' }), + ]; + + const sorted = getSortedNavExtensions(items); + + expect(sorted).toHaveLength(2); + expect(sorted.map((i) => i.properties.id)).toContain('a'); + expect(sorted.map((i) => i.properties.id)).toContain('b'); + }); + }); + + describe('sortExtensionItems', () => { + it('should return empty array for empty input', () => { + expect(sortExtensionItems([])).toEqual([]); + }); + + it('should return single item unchanged', () => { + const items = [createNavExtension('a')]; + const sorted = sortExtensionItems(items); + expect(sorted.map((i) => i.properties.id)).toEqual(['a']); + }); + + it('should sort items based on dependencies', () => { + const items = [ + createNavExtension('c', { insertAfter: 'b' }), + createNavExtension('a'), + createNavExtension('b', { insertAfter: 'a' }), + ]; + + const sorted = sortExtensionItems(items); + + const aIndex = sorted.findIndex((i) => i.properties.id === 'a'); + const bIndex = sorted.findIndex((i) => i.properties.id === 'b'); + const cIndex = sorted.findIndex((i) => i.properties.id === 'c'); + + expect(aIndex).toBeLessThan(bIndex); + expect(bIndex).toBeLessThan(cIndex); + }); + + it('should handle items with no dependencies first', () => { + const items = [createNavExtension('b', { insertAfter: 'a' }), createNavExtension('a')]; + + const sorted = sortExtensionItems(items); + + expect(sorted[0].properties.id).toBe('a'); + }); + }); + + describe('isTopLevelNavItem', () => { + it('should return true for nav sections', () => { + const section = createNavSection('home'); + expect(isTopLevelNavItem(section)).toBe(true); + }); + + it('should return true for items without section property', () => { + const item = createNavExtension('dashboard'); + expect(isTopLevelNavItem(item)).toBe(true); + }); + + it('should return false for items with section property', () => { + const item = createNavExtension('pods', { section: 'workloads' }); + expect(isTopLevelNavItem(item)).toBe(false); + }); + }); +}); diff --git a/frontend/packages/console-app/src/components/nav/index.tsx b/frontend/packages/console-app/src/components/nav/index.tsx index 062b29defff..393920a96b8 100644 --- a/frontend/packages/console-app/src/components/nav/index.tsx +++ b/frontend/packages/console-app/src/components/nav/index.tsx @@ -15,7 +15,7 @@ export const Navigation = memo( ({ isNavOpen, onNavSelect, onPerspectiveSelected }) => { const { t } = useTranslation(); return ( - +