diff --git a/__mocks__/recharts.tsx b/__mocks__/recharts.tsx new file mode 100644 index 0000000000..e25ca5a1df --- /dev/null +++ b/__mocks__/recharts.tsx @@ -0,0 +1,9 @@ +import { cloneElement, ReactElement } from 'react'; + +export * from 'recharts'; + +// jsdom reports zero layout dimensions, so recharts renders nothing without +// an explicit size. Pass one through instead of measuring the container. +export function ResponsiveContainer({ children }: { children: ReactElement }) { + return cloneElement(children, { width: 800, height: 300 }); +} diff --git a/app/portainer/authorization-guard.test.ts b/app/portainer/authorization-guard.test.ts index a89199ce0f..3e9313373b 100644 --- a/app/portainer/authorization-guard.test.ts +++ b/app/portainer/authorization-guard.test.ts @@ -5,10 +5,19 @@ import { } from '@uirouter/angularjs'; import { get, keyBuilder } from '@/react/hooks/useLocalStorage'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { checkAuthorizations } from './authorization-guard'; import { IAuthenticationService } from './services/types'; +let restoreConsole: () => void; +beforeEach(() => { + restoreConsole = suppressConsoleLogs(); +}); +afterEach(() => { + restoreConsole(); +}); + describe('checkAuthorizations', () => { let authService = { init: vi.fn(), diff --git a/app/portainer/services/notifications.test.ts b/app/portainer/services/notifications.test.ts index 01d85aecc0..afe73f3b6c 100644 --- a/app/portainer/services/notifications.test.ts +++ b/app/portainer/services/notifications.test.ts @@ -1,10 +1,15 @@ import toastr from 'toastr'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; + import { notifyError, notifySuccess, notifyWarning } from './notifications'; -vi.spyOn(console, 'error').mockImplementation(() => vi.fn()); - +let restoreConsole: () => void; +beforeEach(() => { + restoreConsole = suppressConsoleLogs(); +}); afterEach(() => { + restoreConsole(); vi.resetAllMocks(); }); @@ -18,9 +23,6 @@ it('calling success should show success message', () => { }); it('calling error with Error should show error message', () => { - const consoleErrorFn = vi - .spyOn(console, 'error') - .mockImplementation(() => vi.fn()); const title = 'title'; const errorMessage = 'message'; const fallback = 'fallback'; @@ -32,14 +34,9 @@ it('calling error with Error should show error message', () => { title, expect.anything() ); - - consoleErrorFn.mockRestore(); }); it('calling error without Error should show fallback message', () => { - const consoleErrorFn = vi - .spyOn(console, 'error') - .mockImplementation(() => vi.fn()); const title = 'title'; const fallback = 'fallback'; @@ -47,7 +44,6 @@ it('calling error without Error should show fallback message', () => { notifyError(title, undefined, fallback); expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything()); - consoleErrorFn.mockRestore(); }); it('calling warning should show warning message', () => { diff --git a/app/react/azure/DashboardView/DashboardView.test.tsx b/app/react/azure/DashboardView/DashboardView.test.tsx index 58bc892079..377d620b23 100644 --- a/app/react/azure/DashboardView/DashboardView.test.tsx +++ b/app/react/azure/DashboardView/DashboardView.test.tsx @@ -11,6 +11,7 @@ import { import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { DashboardView } from './DashboardView'; @@ -76,7 +77,7 @@ test('should correctly show total number of resource groups across multiple subs }); test("when only subscriptions fail to load, don't show the dashboard", async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); + const restoreConsole = suppressConsoleLogs(); const { queryByLabelText } = await renderComponent( 1, @@ -86,10 +87,11 @@ test("when only subscriptions fail to load, don't show the dashboard", async () ); expect(queryByLabelText('Subscription')).not.toBeInTheDocument(); expect(queryByLabelText('Resource group')).not.toBeInTheDocument(); + restoreConsole(); }); test('when only resource groups fail to load, still show the subscriptions', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); + const restoreConsole = suppressConsoleLogs(); const { queryByLabelText, findByLabelText } = await renderComponent( 1, @@ -99,6 +101,7 @@ test('when only resource groups fail to load, still show the subscriptions', asy ); await expect(findByLabelText('Subscription')).resolves.toBeInTheDocument(); expect(queryByLabelText('Resource group')).not.toBeInTheDocument(); + restoreConsole(); }); async function renderComponent( diff --git a/app/react/components/Charts/StatsLineChart.test.tsx b/app/react/components/Charts/StatsLineChart.test.tsx index 94d71303fb..ea6ae2e2b0 100644 --- a/app/react/components/Charts/StatsLineChart.test.tsx +++ b/app/react/components/Charts/StatsLineChart.test.tsx @@ -1,17 +1,9 @@ -import React from 'react'; import { render, screen } from '@testing-library/react'; import { StatsLineChart } from './StatsLineChart'; import type { SeriesConfig } from './StatsLineChart'; -vi.mock('recharts', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - ResponsiveContainer: ({ children }: { children: React.ReactElement }) => - React.cloneElement(children, { width: 800, height: 300 }), - }; -}); +vi.mock('recharts'); function yAxisFormatter(value: number): string { return `${value}%`; diff --git a/app/react/components/StatsItem.tsx b/app/react/components/StatsItem.tsx index 3555de1e62..8c35ffd8ab 100644 --- a/app/react/components/StatsItem.tsx +++ b/app/react/components/StatsItem.tsx @@ -84,19 +84,23 @@ export function ContainerStats({ running, stopped, }: ContainerStatsProps) { - const actualTotal = total || running + stopped; + const safeRunning = running || 0; + const safeStopped = stopped || 0; + const actualTotal = total || safeRunning + safeStopped; return (
- {running} + + {safeRunning} + / {actualTotal}
diff --git a/app/react/components/Widget/WidgetTabs.test.tsx b/app/react/components/Widget/WidgetTabs.test.tsx index 4c4af17cbc..f0b55fbe57 100644 --- a/app/react/components/Widget/WidgetTabs.test.tsx +++ b/app/react/components/Widget/WidgetTabs.test.tsx @@ -3,6 +3,8 @@ import { Layers } from 'lucide-react'; import { ReactNode } from 'react'; import { vi } from 'vitest'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; + import { findSelectedTabIndex, Tab, WidgetTabs } from './WidgetTabs'; // Mock Link component to avoid ui-router relative state resolution in tests @@ -122,6 +124,8 @@ describe('WidgetTabs', () => { describe('error handling', () => { it('throws an error when any tab has an invalid URL-encodable param value', () => { + const restoreConsole = suppressConsoleLogs(); + // Tabs with characters that change when URL-encoded const invalidTabs: Tab[] = [ { @@ -135,6 +139,8 @@ describe('WidgetTabs', () => { expect(() => renderWidgetTabs({ tabs: invalidTabs, currentTabIndex: 1 }) ).toThrow('Invalid query param value for tab'); + + restoreConsole(); }); }); diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx index e14aabb457..e6bf5168c3 100644 --- a/app/react/components/form-components/PortainerSelect.tsx +++ b/app/react/components/form-components/PortainerSelect.tsx @@ -252,7 +252,7 @@ export function MultiSelect({ noOptionsMessage={noOptionsMessage} loadingMessage={loadingMessage} formatCreateLabel={formatCreateLabel} - onCreateOption={onCreateOption} + onCreateOption={handleCreateOption} inputValue={inputValue} onInputChange={(textInput) => setInputValue(textInput)} onBlur={handleBlur} @@ -262,6 +262,15 @@ export function MultiSelect({ /> ); + // Selecting the "create" menu option calls onCreateOption directly, + // bypassing the onChange handler above, so inputValue is cleared here + // instead — otherwise the stale text would trigger a second, duplicate + // creation from handleBlur below once the select loses focus. + function handleCreateOption(input: string) { + setInputValue(''); + onCreateOption?.(input); + } + function handleBlur(e: React.FocusEvent) { onBlur?.(e); const trimmed = inputValue.trim(); @@ -270,11 +279,10 @@ export function MultiSelect({ return; } if (onCreateOption && isCreatable) { - onCreateOption(trimmed); + handleCreateOption(trimmed); } else { onChange([...value, trimmed as TValue]); } - setInputValue(''); } } diff --git a/app/react/docker/configs/ListView/ListView.test.tsx b/app/react/docker/configs/ListView/ListView.test.tsx index 812007accd..bc9c45f138 100644 --- a/app/react/docker/configs/ListView/ListView.test.tsx +++ b/app/react/docker/configs/ListView/ListView.test.tsx @@ -6,7 +6,10 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { server } from '@/setup-tests/server'; import { Role } from '@/portainer/users/types'; -import { createMockUsers } from '@/react-tools/test-mocks'; +import { + createMockEnvironment, + createMockUsers, +} from '@/react-tools/test-mocks'; import { ListView } from './ListView'; @@ -44,11 +47,9 @@ describe('ListView', () => { beforeEach(() => { server.use( http.get('/api/endpoints/1', () => - HttpResponse.json({ - Id: 1, - Name: 'test-environment', - Type: 1, - }) + HttpResponse.json( + createMockEnvironment({ Id: 1, Name: 'test-environment', Type: 1 }) + ) ), http.get('/api/endpoints/:environmentId/docker/configs', () => HttpResponse.json([]) diff --git a/app/react/docker/containers/ItemView/ContainerStatusSection/NameRow.test.tsx b/app/react/docker/containers/ItemView/ContainerStatusSection/NameRow.test.tsx index e1f6af2668..c2288ab164 100644 --- a/app/react/docker/containers/ItemView/ContainerStatusSection/NameRow.test.tsx +++ b/app/react/docker/containers/ItemView/ContainerStatusSection/NameRow.test.tsx @@ -8,6 +8,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { createMockUser } from '@/react-tools/test-mocks'; import { server } from '@/setup-tests/server'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { User } from '@/portainer/users/types'; import { NameRow } from './NameRow'; @@ -262,10 +263,8 @@ describe('NameRow', () => { }); it('handles rename API error gracefully', async () => { - // Mock console.error to suppress expected error logs - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + // Suppress expected error logs + const restoreConsole = suppressConsoleLogs(); server.use( http.post( @@ -304,7 +303,7 @@ describe('NameRow', () => { expect(screen.getByTestId('containerNameInput')).toBeVisible(); expect(nameInput).toHaveValue('new-name'); - consoleErrorSpy.mockRestore(); + restoreConsole(); }); it('validates that container name is required', async () => { diff --git a/app/react/docker/containers/ItemView/CreateImageSection/CreateImageSection.test.tsx b/app/react/docker/containers/ItemView/CreateImageSection/CreateImageSection.test.tsx index 0d87075bde..60d5b04069 100644 --- a/app/react/docker/containers/ItemView/CreateImageSection/CreateImageSection.test.tsx +++ b/app/react/docker/containers/ItemView/CreateImageSection/CreateImageSection.test.tsx @@ -8,6 +8,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { server } from '@/setup-tests/server'; import { createMockUser } from '@/react-tools/test-mocks'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { CreateImageSection } from './CreateImageSection'; @@ -174,9 +175,7 @@ describe('CreateImageSection', () => { it('should handle API error', async () => { const onMutationError = vi.fn(); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + const restoreConsole = suppressConsoleLogs(); server.use( http.post('/api/endpoints/:endpointId/docker/commit', () => @@ -209,7 +208,7 @@ describe('CreateImageSection', () => { { timeout: 3000 } ); - consoleErrorSpy.mockRestore(); + restoreConsole(); }); it('should show loading state during creation', async () => { diff --git a/app/react/docker/containers/StatsView/StatsView.test.tsx b/app/react/docker/containers/StatsView/StatsView.test.tsx index 12fd52f1a3..31a2f1d172 100644 --- a/app/react/docker/containers/StatsView/StatsView.test.tsx +++ b/app/react/docker/containers/StatsView/StatsView.test.tsx @@ -16,6 +16,8 @@ vi.mock('@uirouter/react', async (importOriginal) => ({ })), })); +vi.mock('recharts'); + const minimalStats = { read: '2024-01-01T00:00:01Z', preread: '2024-01-01T00:00:00Z', @@ -59,6 +61,9 @@ function addBaseHandlers() { ), http.get('/api/endpoints/1/docker/containers/container1/top', () => HttpResponse.json({ Processes: [], Titles: [] }) + ), + http.get('/api/endpoints/1/docker/containers/container1/stats', () => + HttpResponse.json(minimalStats) ) ); } diff --git a/app/react/docker/images/ListView/ImagesDatatable/PruneButton.test.tsx b/app/react/docker/images/ListView/ImagesDatatable/PruneButton.test.tsx index 4d0f69b789..fe05910cea 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/PruneButton.test.tsx +++ b/app/react/docker/images/ListView/ImagesDatatable/PruneButton.test.tsx @@ -5,6 +5,7 @@ import { HttpResponse, http } from 'msw'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { server } from '@/setup-tests/server'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { PruneButton } from './PruneButton'; @@ -423,6 +424,14 @@ describe('PruneButton', () => { }); describe('Error Handling', () => { + let restoreConsole: () => void; + beforeEach(() => { + restoreConsole = suppressConsoleLogs(); + }); + afterEach(() => { + restoreConsole(); + }); + it('should show error notification on API failure', async () => { mockConfirmPruneImages.mockResolvedValue({ pruneAll: false, diff --git a/app/react/docker/networks/ItemView/NetworkContainersTable.test.tsx b/app/react/docker/networks/ItemView/NetworkContainersTable.test.tsx index d45ebd5aad..6cb57c35f7 100644 --- a/app/react/docker/networks/ItemView/NetworkContainersTable.test.tsx +++ b/app/react/docker/networks/ItemView/NetworkContainersTable.test.tsx @@ -6,6 +6,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { server } from '@/setup-tests/server'; +import { createMockEnvironment } from '@/react-tools/test-mocks'; import { NetworkContainer } from '../types'; @@ -31,7 +32,11 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ })); test('Network container values should be visible and the link should be valid', async () => { - server.use(http.get('/api/endpoints/1', () => HttpResponse.json({}))); + server.use( + http.get('/api/endpoints/1', () => + HttpResponse.json(createMockEnvironment()) + ) + ); const user = new UserViewModel({ Username: 'test', Role: 1 }); diff --git a/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx b/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx index 8e1b9964d8..968fa5e671 100644 --- a/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx +++ b/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx @@ -5,6 +5,7 @@ import { UserViewModel } from '@/portainer/models/user'; import { server } from '@/setup-tests/server'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { createMockEnvironment } from '@/react-tools/test-mocks'; import { DockerNetwork } from '../types'; @@ -52,7 +53,11 @@ test('Non system networks should have a delete button', async () => { }); async function renderComponent(isAdmin: boolean, network: DockerNetwork) { - server.use(http.get('/api/endpoints/1', () => HttpResponse.json({}))); + server.use( + http.get('/api/endpoints/1', () => + HttpResponse.json(createMockEnvironment()) + ) + ); const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 }); diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx index 5f7fc097a2..dc9720e0b9 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.test.tsx @@ -9,6 +9,7 @@ import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { server } from '@/setup-tests/server'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { CreateStackForm } from './CreateStackForm'; @@ -334,10 +335,7 @@ describe('CreateStackForm', () => { }); it('should handle API error gracefully', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const restoreConsole = suppressConsoleLogs(); const mutationError = vi.fn(); const errorMessage = 'test - failed to create stack'; @@ -380,7 +378,6 @@ describe('CreateStackForm', () => { ); }); - consoleErrorSpy.mockRestore(); - consoleLogSpy.mockRestore(); + restoreConsole(); }); }); diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/useVersionedStackFile.test.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/useVersionedStackFile.test.tsx index 782b9390b3..35c2b7a49d 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/useVersionedStackFile.test.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/useVersionedStackFile.test.tsx @@ -306,8 +306,13 @@ describe('useVersionedStackFile', () => { }); describe('error handling', () => { - const restoreConsole = suppressConsoleLogs(); - afterAll(restoreConsole); + let restoreConsole: () => void; + beforeEach(() => { + restoreConsole = suppressConsoleLogs(); + }); + afterEach(() => { + restoreConsole(); + }); it('should handle API errors gracefully', async () => { server.use( diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.test.tsx index 1a146564d0..a4e5337a25 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.test.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.test.tsx @@ -6,6 +6,7 @@ import { server } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { Stack } from '@/react/common/stacks/types'; import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { useAssociateStackToEnvironmentMutation } from './useAssociateStackToEnvironmentMutation'; @@ -192,15 +193,14 @@ describe('useAssociateStackToEnvironmentMutation', () => { }); describe('error handling', () => { - let consoleError: ReturnType; + let restoreConsole: () => void; beforeEach(() => { - // Suppress console.error for error tests to reduce noise - consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + restoreConsole = suppressConsoleLogs(); }); afterEach(() => { - consoleError.mockRestore(); + restoreConsole(); }); it('should handle API error when association fails', async () => { diff --git a/app/react/kubernetes/applications/StatsView/ApplicationStatsView.test.tsx b/app/react/kubernetes/applications/StatsView/ApplicationStatsView.test.tsx index bee289ffda..0784e7e5e8 100644 --- a/app/react/kubernetes/applications/StatsView/ApplicationStatsView.test.tsx +++ b/app/react/kubernetes/applications/StatsView/ApplicationStatsView.test.tsx @@ -21,6 +21,8 @@ vi.mock('@uirouter/react', async (importOriginal) => ({ })), })); +vi.mock('recharts'); + const podMetricsSuccess = { timestamp: '2024-01-01T00:00:00Z', containers: [ @@ -39,6 +41,9 @@ function addBaseHandlers() { ), http.get('/api/endpoints/1/kubernetes/api/v1/nodes/node1', () => HttpResponse.json({ status: { allocatable: { cpu: '4' } } }) + ), + http.get('/api/kubernetes/1/metrics/pods/namespace/default/my-pod', () => + HttpResponse.json(podMetricsSuccess) ) ); } @@ -60,7 +65,7 @@ function renderComponent() { } describe('ApplicationStatsView', () => { - it('renders the page header "Application stats"', () => { + it('renders the page header "Application stats"', async () => { server.use( http.get('/api/kubernetes/1/metrics/pods/namespace/default/my-pod', () => HttpResponse.json(podMetricsSuccess) @@ -70,6 +75,12 @@ describe('ApplicationStatsView', () => { renderComponent(); expect(screen.getByText('Application stats')).toBeInTheDocument(); + + await waitFor(() => { + expect( + screen.getByRole('combobox', { name: /refresh rate/i }) + ).toBeInTheDocument(); + }); }); it('shows "Unable to retrieve container metrics" panel when pod metrics fetch returns 500', async () => { diff --git a/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx b/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx index 454d467273..97a9f2f463 100644 --- a/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx +++ b/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx @@ -7,6 +7,7 @@ import { createMockEnvironment, createMockQueryResult, } from '@/react-tools/test-mocks'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { ClusterResourceReservation } from './ClusterResourceReservation'; @@ -169,8 +170,7 @@ describe('ClusterResourceReservation', () => { http.get('/api/kubernetes/3/metrics/nodes', () => HttpResponse.error()) ); - // Mock console.error so test logs are not polluted - vi.spyOn(console, 'error').mockImplementation(() => {}); + const restoreConsole = suppressConsoleLogs(); renderComponent(); @@ -205,7 +205,6 @@ describe('ClusterResourceReservation', () => { ) ).toBeVisible(); - // Restore console.error - vi.spyOn(console, 'error').mockRestore(); + restoreConsole(); }); }); diff --git a/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx index f55b507034..d848be3063 100644 --- a/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx +++ b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx @@ -44,7 +44,11 @@ function triggerStateChange(state: ShellState) { beforeEach(() => { vi.clearAllMocks(); Object.defineProperty(window, 'location', { - value: { protocol: 'https:', host: 'localhost:3000' }, + value: { + protocol: 'https:', + host: 'localhost:3000', + href: 'https://localhost:3000/', + }, writable: true, }); }); @@ -60,7 +64,11 @@ describe('KubectlShellView', () => { it('builds ws:// URL when location is http', () => { Object.defineProperty(window, 'location', { - value: { protocol: 'http:', host: 'localhost:3000' }, + value: { + protocol: 'http:', + host: 'localhost:3000', + href: 'http://localhost:3000/', + }, writable: true, }); renderComponent(); diff --git a/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.test.tsx b/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.test.tsx index 5ce669685c..ce3291704a 100644 --- a/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.test.tsx +++ b/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.test.tsx @@ -15,6 +15,8 @@ vi.mock('@uirouter/react', async (importOriginal) => ({ })), })); +vi.mock('recharts'); + const nodeMetricsSuccess = { metadata: { creationTimestamp: '2024-01-01T00:00:00Z' }, usage: { cpu: '250m', memory: '512Mi' }, diff --git a/app/react/kubernetes/configs/secrets/ItemView/LinkedServiceAccountsRow.test.tsx b/app/react/kubernetes/configs/secrets/ItemView/LinkedServiceAccountsRow.test.tsx index 71d88894be..4c963135e8 100644 --- a/app/react/kubernetes/configs/secrets/ItemView/LinkedServiceAccountsRow.test.tsx +++ b/app/react/kubernetes/configs/secrets/ItemView/LinkedServiceAccountsRow.test.tsx @@ -68,12 +68,16 @@ type RowProps = React.ComponentProps; function renderRow(props: Partial = {}) { return render( - + + + + +
); } diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx index 6cefd0f28a..c79750c4ae 100644 --- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx +++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx @@ -8,6 +8,7 @@ import { UserViewModel } from '@/portainer/models/user'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { mockCodeMirror } from '@/setup-tests/mock-codemirror'; import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { HelmApplicationView } from './HelmApplicationView'; @@ -266,8 +267,7 @@ describe('HelmApplicationView', () => { ) ); - // Mock console.error to prevent test output pollution - vi.spyOn(console, 'error').mockImplementation(() => {}); + const restoreConsole = suppressConsoleLogs(); renderComponent(); @@ -280,8 +280,7 @@ describe('HelmApplicationView', () => { ) ).toBeInTheDocument(); - // Restore console.error - vi.spyOn(console, 'error').mockRestore(); + restoreConsole(); }); it('should display additional details when available in helm release', async () => { diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ItemView/ImagePullSecretsRow.test.tsx b/app/react/kubernetes/more-resources/ServiceAccountsView/ItemView/ImagePullSecretsRow.test.tsx index 14855f720f..f6be88e363 100644 --- a/app/react/kubernetes/more-resources/ServiceAccountsView/ItemView/ImagePullSecretsRow.test.tsx +++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ItemView/ImagePullSecretsRow.test.tsx @@ -68,13 +68,17 @@ type RowProps = React.ComponentProps; function renderRow(props: Partial = {}) { return render( - + + + + +
); } diff --git a/app/react/kubernetes/namespaces/ItemView/UpdateNamespaceForm.test.tsx b/app/react/kubernetes/namespaces/ItemView/UpdateNamespaceForm.test.tsx index 65868ddb5d..9d3f5b77ce 100644 --- a/app/react/kubernetes/namespaces/ItemView/UpdateNamespaceForm.test.tsx +++ b/app/react/kubernetes/namespaces/ItemView/UpdateNamespaceForm.test.tsx @@ -8,6 +8,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { UserViewModel } from '@/portainer/models/user'; import { server } from '@/setup-tests/server'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { UpdateNamespaceForm } from './UpdateNamespaceForm'; @@ -18,6 +19,11 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ useCurrentStateAndParams: vi.fn(() => ({ params: { id: NAMESPACE_NAME }, })), + useRouter: vi.fn(() => ({ + stateService: { + reload: vi.fn(), + }, + })), })); vi.mock('@/react/hooks/useEnvironmentId', () => ({ @@ -197,6 +203,14 @@ describe('UpdateNamespaceForm', () => { }); describe('error states', () => { + let restoreConsole: () => void; + beforeEach(() => { + restoreConsole = suppressConsoleLogs(); + }); + afterEach(() => { + restoreConsole(); + }); + it('should show error alert when namespace query fails', async () => { setupDefaultHandlers({ namespaceError: true }); renderComponent(); diff --git a/app/react/kubernetes/volumes/ListView/PersistentVolumeClaimsDatatable.tsx b/app/react/kubernetes/volumes/ListView/PersistentVolumeClaimsDatatable.tsx index 73f11204b0..0649a4ece8 100644 --- a/app/react/kubernetes/volumes/ListView/PersistentVolumeClaimsDatatable.tsx +++ b/app/react/kubernetes/volumes/ListView/PersistentVolumeClaimsDatatable.tsx @@ -96,7 +96,11 @@ export function PersistentVolumeClaimsDatatable() { /> {editResizeClaim && ( - setEditResizeClaim(null)} size="md"> + setEditResizeClaim(null)} + size="md" + aria-label="Resize Persistent Volume Claim" + > setEditResizeClaim(null)} diff --git a/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.test.tsx b/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.test.tsx index 4c5760bf66..64ec5fc51c 100644 --- a/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.test.tsx +++ b/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.test.tsx @@ -8,6 +8,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { server } from '@/setup-tests/server'; import { createMockEnvironment } from '@/react-tools/test-mocks'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { CreateGroupView } from './CreateGroupView'; @@ -225,9 +226,7 @@ describe('CreateGroupView', () => { describe('Error handling', () => { it('should handle API error gracefully', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + const restoreConsole = suppressConsoleLogs(); const mutationError = vi.fn(); const errorMessage = 'Failed to create group'; @@ -255,7 +254,7 @@ describe('CreateGroupView', () => { expect(mutationError).toHaveBeenCalled(); }); - consoleErrorSpy.mockRestore(); + restoreConsole(); }); }); @@ -365,7 +364,8 @@ describe('CreateGroupView', () => { // Environment now appears in the associated list — select its row checkbox and remove it await screen.findByText('removable-env'); - const assocCheckboxes = screen.getAllByRole('checkbox'); + const assocTable = screen.getByTestId('group-associatedEndpoints'); + const assocCheckboxes = within(assocTable).getAllByRole('checkbox'); await user.click(assocCheckboxes[assocCheckboxes.length - 1]); const removeBtn = await screen.findByTestId('remove-environments-button'); await waitFor(() => expect(removeBtn).toBeEnabled()); diff --git a/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.tsx b/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.tsx index 1a833b7b0c..86358e45a7 100644 --- a/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.tsx +++ b/app/react/portainer/environments/environment-groups/CreateView/CreateGroupView.tsx @@ -45,24 +45,27 @@ export function CreateGroupView() { ); - async function handleSubmit( + function handleSubmit( values: GroupFormValues, { resetForm }: FormikHelpers - ) { - await createMutation.mutateAsync( - { - name: values.name, - description: values.description, - tagIds: values.tagIds, - associatedEnvironments: values.associatedEnvironments, - }, - { - onSuccess: () => { - resetForm(); - notifySuccess('Success', 'Group successfully created'); - router.stateService.go('portainer.groups'); + ): Promise { + return new Promise((resolve) => { + createMutation.mutate( + { + name: values.name, + description: values.description, + tagIds: values.tagIds, + associatedEnvironments: values.associatedEnvironments, }, - } - ); + { + onSuccess: () => { + resetForm(); + notifySuccess('Success', 'Group successfully created'); + router.stateService.go('portainer.groups'); + }, + onSettled: () => resolve(), + } + ); + }); } } diff --git a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx index e9a9d4e5ff..44b549c0ba 100644 --- a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx +++ b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx @@ -262,9 +262,13 @@ describe('EditGroupView', () => { }); describe('Error state', () => { - // Suppress console logs for error state tests - const restoreConsole = suppressConsoleLogs(); - afterAll(restoreConsole); + let restoreConsole: () => void; + beforeEach(() => { + restoreConsole = suppressConsoleLogs(); + }); + afterEach(() => { + restoreConsole(); + }); it('should show error Alert when group fetch fails', async () => { renderEditGroupView({ groupData: null }); @@ -557,6 +561,9 @@ describe('EditGroupView', () => { }); it('should hide the delete button when group data is missing', async () => { + // when groupData is null - handler returns 404 and logs an error + const restoreLog = suppressConsoleLogs(); + renderEditGroupView({ groupData: null }); // Wait for the header error state to appear @@ -567,6 +574,8 @@ describe('EditGroupView', () => { expect( screen.queryByRole('button', { name: /Delete/i }) ).not.toBeInTheDocument(); + + restoreLog(); }); it('should have correct data-cy attribute', async () => { diff --git a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AddEnvironmentsDrawer.tsx b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AddEnvironmentsDrawer.tsx index cdb31b1349..ea8fd4125d 100644 --- a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AddEnvironmentsDrawer.tsx +++ b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AddEnvironmentsDrawer.tsx @@ -14,7 +14,13 @@ import { Datatable } from '@@/datatables'; import { useTableStateWithoutStorage } from '@@/datatables/useTableState'; import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected'; import { TableRow } from '@@/datatables/TableRow'; -import { Sheet, SheetContent, SheetClose, SheetHeader } from '@@/Sheet'; +import { + Sheet, + SheetContent, + SheetClose, + SheetHeader, + SheetDescription, +} from '@@/Sheet'; import { Button, LoadingButton } from '@@/buttons'; import { EnvironmentTableData } from './types'; @@ -119,6 +125,9 @@ export function AddEnvironmentsDrawer({
+ + Select environments to add to this group. + title="Available environments" columns={columns} diff --git a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector.test.tsx b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector.test.tsx index 0310abe335..fba57c9175 100644 --- a/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector.test.tsx +++ b/app/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { vi } from 'vitest'; @@ -192,11 +192,11 @@ describe('AssociatedEnvironmentsSelector', () => { await screen.findByText('Add environments'); await screen.findByText('new-env'); - // Select the available env — find the drawer's checkboxes - // The drawer table has its own checkboxes after the main table ones - const allCheckboxes = screen.getAllByRole('checkbox'); - // Last checkbox belongs to the drawer table row - await user.click(allCheckboxes[allCheckboxes.length - 1]); + // Select the available env — scope to the drawer table to avoid duplicates + const drawerTable = screen.getByTestId('add-environments-drawer-table'); + const drawerCheckboxes = within(drawerTable).getAllByRole('checkbox'); + // [0] = select-all header, [1] = first row + await user.click(drawerCheckboxes[1]); // Click the Add button in the drawer footer const confirmAddBtn = screen.getByTestId( diff --git a/app/react/portainer/environments/environment-groups/components/GroupForm.tsx b/app/react/portainer/environments/environment-groups/components/GroupForm.tsx index 1935af196c..f5d7ff1554 100644 --- a/app/react/portainer/environments/environment-groups/components/GroupForm.tsx +++ b/app/react/portainer/environments/environment-groups/components/GroupForm.tsx @@ -35,7 +35,6 @@ export interface GroupFormValues { interface Props { initialValues: GroupFormValues; - /** Should return a Promise that resolves when navigation happens (to keep isSubmitting true) */ onSubmit: ( values: GroupFormValues, helpers: FormikHelpers diff --git a/app/setup-tests/setup-fail-on-console.ts b/app/setup-tests/setup-fail-on-console.ts new file mode 100644 index 0000000000..4c760e173e --- /dev/null +++ b/app/setup-tests/setup-fail-on-console.ts @@ -0,0 +1,12 @@ +import failOnConsole from 'vitest-fail-on-console'; + +failOnConsole({ + shouldFailOnWarn: true, + shouldFailOnError: true, + shouldFailOnLog: true, + shouldFailOnInfo: true, + allowMessage: (message) => + /Can't perform a React state update on an unmounted component/.test( + message + ), +}); diff --git a/app/setup-tests/suppress-console.ts b/app/setup-tests/suppress-console.ts index 0acefb8853..09ff446be5 100644 --- a/app/setup-tests/suppress-console.ts +++ b/app/setup-tests/suppress-console.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import { vi } from 'vitest'; /** @@ -40,25 +39,17 @@ import { vi } from 'vitest'; * @returns A cleanup function to restore the original console methods */ export function suppressConsoleLogs() { - const originalError = console.error; - const originalWarn = console.warn; - const originalInfo = console.info; - const originalLog = console.log; + // Use vi.spyOn so the mocks integrate with vitest's mock system + // (compatible with vitest-fail-on-console and vi.restoreAllMocks) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - // Suppress all console output - // Tests expect errors so no need to show them in the output - console.error = vi.fn(); - console.warn = vi.fn(); - console.info = vi.fn(); - console.log = vi.fn(); - - // Return cleanup function to restore original console methods return () => { - console.error = originalError; - console.warn = originalWarn; - console.info = originalInfo; - console.log = originalLog; + errorSpy.mockRestore(); + warnSpy.mockRestore(); + infoSpy.mockRestore(); + logSpy.mockRestore(); }; } - -/* eslint-enable no-console */ diff --git a/package.json b/package.json index 00f7ad88ab..6c3568875f 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,7 @@ "vite-plugin-svgr": "^4.5.0", "vite-tsconfig-paths": "^4.3.1", "vitest": "^4.1.8", + "vitest-fail-on-console": "^0.9.0", "webpack": "^5.107.2", "webpack-build-notifier": "^3.1.0", "webpack-bundle-analyzer": "^5.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2beb21a68..7dd726e998 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,6 +627,9 @@ importers: vitest: specifier: ^4.1.8 version: 4.1.8(@types/node@25.0.3)(@vitest/coverage-v8@4.1.8)(jsdom@24.1.3)(msw@2.14.6(@types/node@25.0.3)(typescript@6.0.2))(vite@8.0.16(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(less@4.4.2)(terser@5.44.1)(yaml@1.10.2)) + vitest-fail-on-console: + specifier: ^0.9.0 + version: 0.9.0(vite@8.0.16(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(less@4.4.2)(terser@5.44.1)(yaml@1.10.2))(vitest@4.1.8) webpack: specifier: ^5.107.2 version: 5.107.2(@swc/core@1.15.11)(cssnano@8.0.1(postcss@8.5.15))(esbuild@0.27.3)(postcss@8.5.15)(webpack-cli@6.0.1) @@ -4306,6 +4309,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -8635,6 +8642,12 @@ packages: yaml: optional: true + vitest-fail-on-console@0.9.0: + resolution: {integrity: sha512-GgoS9gSy3ctwGVgFhY6NaAaRE5ve9EmgN9iDpqtEG1Z7Z9Y8x/4GzDrhky5+050Hy0FnndoM/mpmqsmGWywFZw==} + peerDependencies: + vite: '>=4.5.2' + vitest: '>=0.26.2' + vitest@4.1.8: resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -12930,6 +12943,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -17696,6 +17711,12 @@ snapshots: terser: 5.44.1 yaml: 1.10.2 + vitest-fail-on-console@0.9.0(vite@8.0.16(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(less@4.4.2)(terser@5.44.1)(yaml@1.10.2))(vitest@4.1.8): + dependencies: + chalk: 5.6.2 + vite: 8.0.16(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(less@4.4.2)(terser@5.44.1)(yaml@1.10.2) + vitest: 4.1.8(@types/node@25.0.3)(@vitest/coverage-v8@4.1.8)(jsdom@24.1.3)(msw@2.14.6(@types/node@25.0.3)(typescript@6.0.2))(vite@8.0.16(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(less@4.4.2)(terser@5.44.1)(yaml@1.10.2)) + vitest@4.1.8(@types/node@25.0.3)(@vitest/coverage-v8@4.1.8)(jsdom@24.1.3)(msw@2.14.6(@types/node@25.0.3)(typescript@6.0.2))(vite@8.0.16(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(less@4.4.2)(terser@5.44.1)(yaml@1.10.2)): dependencies: '@vitest/expect': 4.1.8 diff --git a/vitest.config.mts b/vitest.config.mts index d7e6d09a9f..71299bd9c4 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -18,6 +18,7 @@ export default defineConfig({ './app/setup-tests/stub-modules.ts', './app/setup-tests/setup.ts', './app/setup-tests/setup-codemirror.ts', + './app/setup-tests/setup-fail-on-console.ts', ], coverage: { provider: 'v8', @@ -38,9 +39,5 @@ export default defineConfig({ return !/Can't perform a React state update on an unmounted component/.test(log); }, }, - plugins: [ - svgr({ include: /\?c$/ }), - tsconfigPaths(), - tsconfigPaths({ projects: ['./tsconfig.generated.json'] }), - ], + plugins: [svgr({ include: /\?c$/ }), tsconfigPaths(), tsconfigPaths({ projects: ['./tsconfig.generated.json'] })], });