import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { HttpResponse } from 'msw'; import { vi } from 'vitest'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { UserViewModel } from '@/portainer/models/user'; import { server, http } from '@/setup-tests/server'; import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate'; import { confirm, confirmDelete } from '@@/modals/confirm'; import { ApplicationContainersDatatable } from './ApplicationContainersDatatable'; const mockUseCurrentStateAndParams = vi.fn(); const mockUseEnvironmentId = vi.fn(); mockLocalizeDate(); vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ ...(await importOriginal()), useCurrentStateAndParams: () => mockUseCurrentStateAndParams(), })); vi.mock('@/react/hooks/useEnvironmentId', () => ({ useEnvironmentId: () => mockUseEnvironmentId(), })); vi.mock('@@/Link', () => ({ Link: ({ children, to, ...rest }: { children: React.ReactNode; to: string; [key: string]: unknown; }) => ( )} > {children} ), })); vi.mock('@@/modals/confirm', () => ({ confirm: vi.fn(), confirmDelete: vi.fn(), buildConfirmButton: vi.fn((label: string) => ({ label, color: 'primary', className: '', })), })); const mockDeployment = { kind: 'Deployment', apiVersion: 'apps/v1', metadata: { name: 'test-app', namespace: 'test-namespace', }, spec: { selector: { matchLabels: { app: 'test-app' }, }, replicas: 1, }, status: { replicas: 1, readyReplicas: 1 }, }; const mockPod = { kind: 'Pod', apiVersion: 'v1', metadata: { name: 'test-pod-1', namespace: 'test-namespace' }, spec: { nodeName: 'node-1', containers: [ { name: 'nginx', image: 'nginx:latest', imagePullPolicy: 'Always', }, ], }, status: { phase: 'Running', podIP: '10.0.0.1', startTime: '2023-01-01T00:00:00Z', containerStatuses: [ { name: 'nginx', ready: true, restartCount: 0, image: 'nginx:latest', imageID: 'sha256:abc123', state: { running: { startedAt: '2023-01-01T00:01:00Z' } }, }, ], }, }; const mockPodsResponse = { apiVersion: 'v1', kind: 'PodList', items: [mockPod], }; const mockKubernetesVersion = { major: '1', minor: '35', gitVersion: 'v1.35.0', gitCommit: 'abc123', gitTreeState: 'clean', buildDate: '2025-01-01T00:00:00Z', goVersion: 'go1.22.0', compiler: 'gc', platform: 'linux/amd64', supportsPodRestart: true, }; const mockEnvironment = { Id: 1, Name: 'Test Environment', Type: 1, Kubernetes: { Configuration: { UseServerMetrics: false }, }, }; function renderComponent() { const user = new UserViewModel({ Username: 'user', Role: 1 }); const Wrapped = withTestQueryProvider( withUserProvider( withTestRouter(() => ), user ) ); return render(); } describe('ApplicationContainersDatatable', () => { beforeEach(() => { mockUseEnvironmentId.mockReturnValue(1); mockUseCurrentStateAndParams.mockReturnValue({ params: { name: 'test-app', namespace: 'test-namespace', 'resource-type': 'Deployment', }, }); vi.mocked(confirm).mockResolvedValue(true); vi.mocked(confirmDelete).mockResolvedValue(true); server.use( http.get('/api/endpoints/1', () => HttpResponse.json(mockEnvironment)), http.get( '/api/endpoints/1/kubernetes/apis/apps/v1/namespaces/test-namespace/deployments/test-app', () => HttpResponse.json(mockDeployment) ), http.get( '/api/endpoints/1/kubernetes/api/v1/namespaces/test-namespace/pods', () => HttpResponse.json(mockPodsResponse) ), http.get('/api/kubernetes/1/version', () => HttpResponse.json(mockKubernetesVersion) ) ); }); it('renders a row for each pod', async () => { const secondPod = { ...mockPod, metadata: { ...mockPod.metadata, name: 'test-pod-2' }, }; server.use( http.get( '/api/endpoints/1/kubernetes/api/v1/namespaces/test-namespace/pods', () => HttpResponse.json({ ...mockPodsResponse, items: [mockPod, secondPod], }) ) ); renderComponent(); expect(await screen.findByText('test-pod-1')).toBeVisible(); expect(await screen.findByText('test-pod-2')).toBeVisible(); }); it('shows loading state while data is fetching', async () => { server.use( http.get( '/api/endpoints/1/kubernetes/apis/apps/v1/namespaces/test-namespace/deployments/test-app', () => new Promise(() => {}) ) ); renderComponent(); expect(await screen.findByText('Loading...')).toBeVisible(); }); it('shows "No items." when the pod list is empty', async () => { server.use( http.get( '/api/endpoints/1/kubernetes/api/v1/namespaces/test-namespace/pods', () => HttpResponse.json({ ...mockPodsResponse, items: [] }) ) ); renderComponent(); await screen.findByText('Application pods'); expect(await screen.findByText('No items.')).toBeVisible(); }); it('expands a pod row to reveal its containers', async () => { const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByRole('button', { name: 'Expand' })); expect(await screen.findByText('nginx')).toBeVisible(); }); it('shows the Init badge for init containers', async () => { const podWithInit = { ...mockPod, spec: { ...mockPod.spec, initContainers: [ { name: 'init-setup', image: 'busybox:latest', imagePullPolicy: 'IfNotPresent', }, ], }, status: { ...mockPod.status, initContainerStatuses: [ { name: 'init-setup', ready: true, restartCount: 0, image: 'busybox:latest', imageID: 'sha256:def456', state: { terminated: { exitCode: 0, startedAt: '2023-01-01T00:00:30Z', finishedAt: '2023-01-01T00:00:55Z', }, }, }, ], }, }; server.use( http.get( '/api/endpoints/1/kubernetes/api/v1/namespaces/test-namespace/pods', () => HttpResponse.json({ ...mockPodsResponse, items: [podWithInit] }) ) ); const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByRole('button', { name: 'Expand' })); expect(await screen.findByText('Init')).toBeVisible(); }); it('shows the Sidecar badge for sidecar init containers', async () => { const podWithSidecar = { ...mockPod, spec: { ...mockPod.spec, initContainers: [ { name: 'sidecar-proxy', image: 'envoy:latest', imagePullPolicy: 'IfNotPresent', restartPolicy: 'Always', }, ], }, status: { ...mockPod.status, initContainerStatuses: [ { name: 'sidecar-proxy', ready: true, restartCount: 0, image: 'envoy:latest', imageID: 'sha256:ghi789', state: { running: { startedAt: '2023-01-01T00:01:00Z' } }, }, ], }, }; server.use( http.get( '/api/endpoints/1/kubernetes/api/v1/namespaces/test-namespace/pods', () => HttpResponse.json({ ...mockPodsResponse, items: [podWithSidecar] }) ) ); const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByRole('button', { name: 'Expand' })); expect(await screen.findByText('Sidecar')).toBeVisible(); }); it('shows the stats link for running containers when server metrics is enabled', async () => { server.use( http.get('/api/endpoints/1', () => HttpResponse.json({ ...mockEnvironment, Kubernetes: { Configuration: { UseServerMetrics: true } }, }) ) ); const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByRole('button', { name: 'Expand' })); expect(await screen.findByText('Stats')).toBeVisible(); }); it('hides the stats link when server metrics is disabled', async () => { const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByRole('button', { name: 'Expand' })); await screen.findByText('nginx'); expect(screen.queryByText('Stats')).not.toBeInTheDocument(); }); it('shows the logs link for a container that has started', async () => { const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByRole('button', { name: 'Expand' })); expect( await screen.findByTestId('application-container-logs-nginx') ).toBeVisible(); }); it('calls the restart API after the user confirms the restart dialog', async () => { let restartRequested = false; server.use( http.post( '/api/kubernetes/1/namespaces/test-namespace/pods/test-pod-1/restart', () => { restartRequested = true; return HttpResponse.json({}); } ) ); const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByTestId('application-pod-restart-test-pod-1')); await waitFor(() => { expect(restartRequested).toBe(true); }); }); it('does not call the restart API when the user cancels the dialog', async () => { vi.mocked(confirm).mockResolvedValue(false); let restartRequested = false; server.use( http.post( '/api/kubernetes/1/namespaces/test-namespace/pods/test-pod-1/restart', () => { restartRequested = true; return HttpResponse.json({}); } ) ); const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByTestId('application-pod-restart-test-pod-1')); await waitFor(() => { expect(confirm).toHaveBeenCalled(); }); expect(restartRequested).toBe(false); }); it('calls the delete API after the user confirms deletion', async () => { let deleteRequested = false; server.use( http.delete( '/api/kubernetes/1/namespaces/test-namespace/pods/test-pod-1', () => { deleteRequested = true; return HttpResponse.json({}); } ) ); const user = userEvent.setup(); renderComponent(); await screen.findByText('test-pod-1'); await user.click(screen.getByTestId('application-pod-delete-test-pod-1')); await waitFor(() => { expect(deleteRequested).toBe(true); }); }); it('disables the restart button when the cluster does not support pod restart', async () => { server.use( http.get('/api/kubernetes/1/version', () => HttpResponse.json({ ...mockKubernetesVersion, supportsPodRestart: false, }) ) ); renderComponent(); await screen.findByText('test-pod-1'); const restartButton = screen.getByTestId( 'application-pod-restart-test-pod-1' ); expect(restartButton).toBeDisabled(); }); });