From 2bbcae39b602375633fd0d3feac0844b4bd1768f Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 22 Feb 2026 07:42:49 +0000 Subject: [PATCH] feat: clean frontend test logs (#1894) --- .../PageHeader/HeaderTitle.test.tsx | 16 ----- .../__snapshots__/HeaderTitle.test.tsx.snap | 3 - .../queries/useExportImageMutation.test.ts | 8 +++ .../StackEditorTab/StackEditorTab.test.tsx | 19 ++++++ .../StackEditorTab.webhook.test.tsx | 7 ++ .../useVersionedStackFile.test.tsx | 7 ++ .../StackRedeployGitForm.test.tsx | 5 ++ .../queries/useAppStackFile.test.tsx | 4 ++ .../NodeView/NodeDetails/NodeDetails.test.tsx | 19 ++++-- .../ItemView/EditGroupView.test.tsx | 11 ++-- .../RegistryTestConnection.test.tsx | 5 ++ app/setup-tests/setup-handlers/docker.ts | 3 + app/setup-tests/suppress-console.ts | 64 +++++++++++++++++++ 13 files changed, 141 insertions(+), 30 deletions(-) delete mode 100644 app/react/components/PageHeader/__snapshots__/HeaderTitle.test.tsx.snap create mode 100644 app/setup-tests/suppress-console.ts diff --git a/app/react/components/PageHeader/HeaderTitle.test.tsx b/app/react/components/PageHeader/HeaderTitle.test.tsx index 490e9608c..68385f0eb 100644 --- a/app/react/components/PageHeader/HeaderTitle.test.tsx +++ b/app/react/components/PageHeader/HeaderTitle.test.tsx @@ -8,22 +8,6 @@ import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { HeaderContainer } from './HeaderContainer'; import { HeaderTitle } from './HeaderTitle'; -test('should not render without a wrapping HeaderContainer', async () => { - const consoleErrorFn = vi - .spyOn(console, 'error') - .mockImplementation(() => vi.fn()); - - const title = 'title'; - function renderComponent() { - const Wrapped = withTestQueryProvider(HeaderTitle); - return render(); - } - - expect(renderComponent).toThrowErrorMatchingSnapshot(); - - consoleErrorFn.mockRestore(); -}); - test('should display a HeaderTitle', async () => { const username = 'username'; const user = new UserViewModel({ Username: username }); diff --git a/app/react/components/PageHeader/__snapshots__/HeaderTitle.test.tsx.snap b/app/react/components/PageHeader/__snapshots__/HeaderTitle.test.tsx.snap deleted file mode 100644 index 05a885748..000000000 --- a/app/react/components/PageHeader/__snapshots__/HeaderTitle.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`should not render without a wrapping HeaderContainer 1`] = `[Error: Should be nested inside a HeaderContainer component]`; diff --git a/app/react/docker/images/queries/useExportImageMutation.test.ts b/app/react/docker/images/queries/useExportImageMutation.test.ts index 8598d1440..fd174b797 100644 --- a/app/react/docker/images/queries/useExportImageMutation.test.ts +++ b/app/react/docker/images/queries/useExportImageMutation.test.ts @@ -6,6 +6,7 @@ import { createElement, Fragment } from 'react'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { server } from '@/setup-tests/server'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { useExportMutation, @@ -227,6 +228,8 @@ describe('exportImage', () => { }); it('should throw error when export fails', async () => { + const restoreConsole = suppressConsoleLogs(); + server.use( http.get('/api/endpoints/:envId/docker/images/get', () => HttpResponse.json({ message: 'Image not found' }, { status: 404 }) @@ -240,6 +243,8 @@ describe('exportImage', () => { images: [{ id: 'sha256:abc123', tags: ['nginx:latest'] }], }) ).rejects.toThrow('Unable to export image'); + + restoreConsole(); }); it('should handle filename without content-disposition header', async () => { @@ -297,6 +302,8 @@ describe('useExportMutation', () => { }); it('should handle export error', async () => { + const restoreConsole = suppressConsoleLogs(); + server.use( http.get('/api/endpoints/:envId/docker/images/get', () => HttpResponse.json({ message: 'Internal server error' }, { status: 500 }) @@ -315,6 +322,7 @@ describe('useExportMutation', () => { }); expect(result.current.error).toBeDefined(); + restoreConsole(); }); it('should export multiple images with node name', async () => { diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.test.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.test.tsx index 4bc0ec548..7942670c1 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.test.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.test.tsx @@ -7,6 +7,7 @@ import { ComponentProps } from 'react'; import { server } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { createMockUsers, createMockStack } from '@/react-tools/test-mocks'; import { EnvironmentType } from '@/react/portainer/environments/types'; import { Role } from '@/portainer/users/types'; @@ -69,6 +70,7 @@ beforeEach(() => { describe('initial loading', () => { it('should be empty when environment data is not loaded', async () => { + const restoreConsole = suppressConsoleLogs(); setupMswHandlers({ shouldReturnEnv: false }); const { container } = renderComponent(); @@ -77,6 +79,8 @@ describe('initial loading', () => { await waitFor(() => { expect(container.innerHTML).toBe('
'); }); + + restoreConsole(); }); it('should be empty when schema data is not loaded', async () => { @@ -141,6 +145,8 @@ describe('initial loading', () => { describe('form submission', () => { it('should show confirmation dialog before submitting', async () => { + const restoreConsole = suppressConsoleLogs(); + const mockConfirm = vi.mocked(confirmStackUpdate); renderComponent(); const user = userEvent.setup(); @@ -162,9 +168,13 @@ describe('form submission', () => { false // stackType is DockerCompose ); }); + + restoreConsole(); }); it('should call mutation API with correct payload', async () => { + const restoreConsole = suppressConsoleLogs(); + let capturedRequestBody: unknown; server.use( @@ -203,6 +213,8 @@ describe('form submission', () => { }, { timeout: 3000 } ); + + restoreConsole(); }); it('should not submit if confirmation is cancelled', async () => { @@ -244,6 +256,8 @@ describe('form submission', () => { }); it('should call onSubmitSuccess callback and show success notification after mutation completes', async () => { + const restoreConsole = suppressConsoleLogs(); + const onSubmitSuccess = vi.fn(); renderComponent({ onSubmitSuccess }); const user = userEvent.setup(); @@ -266,9 +280,13 @@ describe('form submission', () => { ); expect(onSubmitSuccess).toHaveBeenCalled(); }); + + restoreConsole(); }); it('should handle API errors during submission', async () => { + const restoreConsole = suppressConsoleLogs(); + server.use( http.put('/api/stacks/:id', () => HttpResponse.json({ message: 'Stack update failed' }, { status: 500 }) @@ -295,6 +313,7 @@ describe('form submission', () => { }); expect(deployButton).toBeEnabled(); + restoreConsole(); }); }); diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.webhook.test.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.webhook.test.tsx index e677bd7c0..1447709c7 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.webhook.test.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.webhook.test.tsx @@ -12,6 +12,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { withTestRouter } from '@/react/test-utils/withRouter'; import { createMockStack, createMockUsers } from '@/react-tools/test-mocks'; import { Role } from '@/portainer/users/types'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { StackEditorTab } from './StackEditorTab'; @@ -103,6 +104,8 @@ describe('StackEditorTab - Webhook ID Handling', () => { describe('Form submission', () => { it('should send webhook ID in API request when stack has webhook', async () => { + const restoreConsole = suppressConsoleLogs(); + const user = userEvent.setup(); let capturedRequestBody: DefaultBodyType; @@ -142,9 +145,12 @@ describe('StackEditorTab - Webhook ID Handling', () => { assert(capturedRequestBody && typeof capturedRequestBody === 'object'); expect(capturedRequestBody?.webhook).toBe('existing-webhook-123'); + + restoreConsole(); }); it('should not send webhook ID in API request when stack has no webhook', async () => { + const restoreConsole = suppressConsoleLogs(); const user = userEvent.setup(); let capturedRequestBody: DefaultBodyType; @@ -183,6 +189,7 @@ describe('StackEditorTab - Webhook ID Handling', () => { ); expect(capturedRequestBody).not.toHaveProperty('webhook'); + restoreConsole(); }); }); }); diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/useVersionedStackFile.test.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/useVersionedStackFile.test.tsx index d47410519..782b9390b 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/useVersionedStackFile.test.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/useVersionedStackFile.test.tsx @@ -5,6 +5,7 @@ import { vi } from 'vitest'; import { server } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { useVersionedStackFile } from './useVersionedStackFile'; @@ -305,6 +306,9 @@ describe('useVersionedStackFile', () => { }); describe('error handling', () => { + const restoreConsole = suppressConsoleLogs(); + afterAll(restoreConsole); + it('should handle API errors gracefully', async () => { server.use( http.get('/api/stacks/:id/file', () => @@ -420,6 +424,7 @@ describe('useVersionedStackFile', () => { }); it('should clear loading state after failed fetch', async () => { + const restoreConsole = suppressConsoleLogs(); server.use( http.get('/api/stacks/:id/file', () => HttpResponse.json({ message: 'Error' }, { status: 500 }) @@ -435,6 +440,8 @@ describe('useVersionedStackFile', () => { await waitFor(() => { expect(result.current.isLoading).toBe(false); }); + + restoreConsole(); }); }); diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.test.tsx index e01803193..3d799e43a 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.test.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.test.tsx @@ -18,6 +18,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider'; import { useApiVersion } from '@/react/docker/proxy/queries/useVersion'; import { http, server } from '@/setup-tests/server'; import { DeepPartial } from '@/types'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { StackRedeployGitForm } from './StackRedeployGitForm'; @@ -553,6 +554,10 @@ describe('StackRedeployGitForm', () => { }); describe('Error handling', () => { + // Suppress console logs for error handling tests + const restoreConsole = suppressConsoleLogs(); + afterAll(restoreConsole); + it('should handle updateGitStack mutation errors gracefully', async () => { const user = userEvent.setup(); server.use( diff --git a/app/react/kubernetes/applications/queries/useAppStackFile.test.tsx b/app/react/kubernetes/applications/queries/useAppStackFile.test.tsx index c976c95ae..03d44b5fa 100644 --- a/app/react/kubernetes/applications/queries/useAppStackFile.test.tsx +++ b/app/react/kubernetes/applications/queries/useAppStackFile.test.tsx @@ -5,6 +5,7 @@ import { HttpResponse } from 'msw'; import { server, http } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { useAppStackFile } from './useAppStackFile'; @@ -46,6 +47,7 @@ describe('useAppStackFile', () => { }); it('should handle fetch error for regular stack', async () => { + const restoreConsole = suppressConsoleLogs(); server.use( http.get('/api/stacks/999/file', () => HttpResponse.json({ message: 'Stack not found' }, { status: 404 }) @@ -57,6 +59,8 @@ describe('useAppStackFile', () => { await waitFor(() => { expect(result.current.isError).toBe(true); }); + + restoreConsole(); }); it('should not fetch when query is disabled', async () => { diff --git a/app/react/kubernetes/cluster/NodeView/NodeDetails/NodeDetails.test.tsx b/app/react/kubernetes/cluster/NodeView/NodeDetails/NodeDetails.test.tsx index 186b2b4e7..a62bf46fe 100644 --- a/app/react/kubernetes/cluster/NodeView/NodeDetails/NodeDetails.test.tsx +++ b/app/react/kubernetes/cluster/NodeView/NodeDetails/NodeDetails.test.tsx @@ -183,6 +183,7 @@ describe('NodeDetails', () => { }); it('shows drain warning when selecting Drain availability', async () => { + const user = userEvent.setup(); renderComponent(); // Wait for component to load @@ -192,11 +193,11 @@ describe('NodeDetails', () => { // Find the availability select and select Drain const availabilitySelect = screen.getByLabelText('Availability'); - await select(availabilitySelect, 'Drain'); + await select(availabilitySelect, 'Drain', { user }); // Try to submit the form to trigger validation const submitButton = screen.getByRole('button', { name: /update node/i }); - await userEvent.click(submitButton); + await user.click(submitButton); // Check that the confirmation modal is called with drain warning await waitFor(() => { @@ -210,6 +211,7 @@ describe('NodeDetails', () => { }); it('prevents submission when Portainer is running on node', async () => { + const user = userEvent.setup(); setupMocks({ applications: mockPortainerApplications }); renderComponent(); @@ -219,7 +221,7 @@ describe('NodeDetails', () => { }); const availabilitySelect = screen.getByLabelText('Availability'); - await select(availabilitySelect, 'Drain'); + await select(availabilitySelect, 'Drain', { user }); await waitFor(() => { expect( @@ -232,6 +234,7 @@ describe('NodeDetails', () => { }); it('prevents drain when only one node in cluster', async () => { + const user = userEvent.setup(); setupMocks({ nodes: [mockNode] }); renderComponent(); @@ -241,7 +244,7 @@ describe('NodeDetails', () => { }); const availabilitySelect = screen.getByLabelText('Availability'); - await select(availabilitySelect, 'Drain'); + await select(availabilitySelect, 'Drain', { user }); await waitFor(() => { expect( @@ -254,6 +257,7 @@ describe('NodeDetails', () => { }); it('prevents drain when another node is already draining', async () => { + const user = userEvent.setup(); const drainingNodes = [ mockNode, { @@ -276,7 +280,7 @@ describe('NodeDetails', () => { }); const availabilitySelect = screen.getByLabelText('Availability'); - await select(availabilitySelect, 'Drain'); + await select(availabilitySelect, 'Drain', { user }); await waitFor(() => { expect( @@ -286,6 +290,7 @@ describe('NodeDetails', () => { }); it('shows cordon warning when submitting with Pause availability', async () => { + const user = userEvent.setup(); vi.mocked(confirmUpdateNode).mockResolvedValue(true); renderComponent(); @@ -295,10 +300,10 @@ describe('NodeDetails', () => { }); const availabilitySelect = screen.getByLabelText('Availability'); - await select(availabilitySelect, 'Pause'); + await select(availabilitySelect, 'Pause', { user }); const submitButton = screen.getByRole('button', { name: /update node/i }); - await userEvent.click(submitButton); + await user.click(submitButton); // Verify confirmation modal was called with cordon warning await waitFor(() => { 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 47f2626c3..faab6c1a6 100644 --- a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx +++ b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.test.tsx @@ -7,6 +7,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 { Environment, EnvironmentType, @@ -228,6 +229,10 @@ describe('EditGroupView', () => { }); describe('Error state', () => { + // Suppress console logs for error state tests + const restoreConsole = suppressConsoleLogs(); + afterAll(restoreConsole); + it('should show error Alert when group fetch fails', async () => { renderEditGroupView({ groupData: null }); @@ -392,9 +397,7 @@ describe('EditGroupView', () => { describe('Error handling on update', () => { it('should handle API error gracefully on update', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + const restoreConsole = suppressConsoleLogs(); const mutationError = vi.fn(); const errorMessage = 'Failed to update group'; @@ -429,7 +432,7 @@ describe('EditGroupView', () => { expect(mutationError).toHaveBeenCalled(); }); - consoleErrorSpy.mockRestore(); + restoreConsole(); }); }); diff --git a/app/react/portainer/registries/CreateView/TestConnection/RegistryTestConnection.test.tsx b/app/react/portainer/registries/CreateView/TestConnection/RegistryTestConnection.test.tsx index fac3d4406..572cad21e 100644 --- a/app/react/portainer/registries/CreateView/TestConnection/RegistryTestConnection.test.tsx +++ b/app/react/portainer/registries/CreateView/TestConnection/RegistryTestConnection.test.tsx @@ -5,6 +5,7 @@ import { vi } from 'vitest'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { server } from '@/setup-tests/server'; +import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { RegistryTestConnection } from './RegistryTestConnection'; @@ -102,6 +103,8 @@ test('should show error message on failed test', async () => { }); test('should show error message on network error', async () => { + const restoreConsole = suppressConsoleLogs(); + const user = userEvent.setup(); // Mock network error @@ -117,6 +120,8 @@ test('should show error message on network error', async () => { screen.getByText(/Failed to test registry connection/) ).toBeVisible(); }); + + restoreConsole(); }); test('should send correct payload to API', async () => { diff --git a/app/setup-tests/setup-handlers/docker.ts b/app/setup-tests/setup-handlers/docker.ts index 6a82ac803..e7347481a 100644 --- a/app/setup-tests/setup-handlers/docker.ts +++ b/app/setup-tests/setup-handlers/docker.ts @@ -22,4 +22,7 @@ export const dockerHandlers = [ http.get('/api/endpoints/:endpointId/docker/containers/json', () => HttpResponse.json([]) ), + http.get('/api/endpoints/:endpointId/docker/networks', () => + HttpResponse.json([]) + ), ]; diff --git a/app/setup-tests/suppress-console.ts b/app/setup-tests/suppress-console.ts new file mode 100644 index 000000000..0acefb885 --- /dev/null +++ b/app/setup-tests/suppress-console.ts @@ -0,0 +1,64 @@ +/* eslint-disable no-console */ +import { vi } from 'vitest'; + +/** + * Suppresses all console output during tests. + * Useful for tests that trigger expected errors, warnings, or info messages + * that clutter the test output without providing useful information. + * + * Can be used at file level or per-test level. + * + * @example File level usage + * ```typescript + * import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; + * + * const restoreConsole = suppressConsoleLogs(); + * afterAll(restoreConsole); + * ``` + * + * @example Per-test usage + * ```typescript + * import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; + * + * describe('some test suite', () => { + * let restoreConsole: () => void; + * + * beforeEach(() => { + * restoreConsole = suppressConsoleLogs(); + * }); + * + * afterEach(() => { + * restoreConsole(); + * }); + * + * test('test that produces noisy logs', () => { + * // console logs suppressed + * }); + * }); + * ``` + * + * @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; + + // 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; + }; +} + +/* eslint-enable no-console */