feat(tests): enforce no unexpected console output in unit tests [BE-13230] (#3097)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chaim Lev-Ari
2026-08-05 10:50:18 +03:00
committed by GitHub
parent c58edcb81f
commit df4c700667
38 changed files with 278 additions and 139 deletions
+9
View File
@@ -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 });
}
@@ -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(),
+7 -11
View File
@@ -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', () => {
@@ -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(
@@ -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<typeof import('recharts')>();
return {
...original,
ResponsiveContainer: ({ children }: { children: React.ReactElement }) =>
React.cloneElement(children, { width: 800, height: 300 }),
};
});
vi.mock('recharts');
function yAxisFormatter(value: number): string {
return `${value}%`;
+8 -4
View File
@@ -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 (
<StatsItem title="CONTAINERS" icon={Hexagon}>
<div className="flex w-full flex-col">
<div>
<span className="text-base font-bold leading-none">{running}</span>
<span className="text-base font-bold leading-none">
{safeRunning}
</span>
<span> / {actualTotal}</span>
</div>
<progress
className="h-[4px] w-auto rounded bg-gray-4 th-dark:bg-white/10"
value={running}
value={safeRunning}
max={Math.max(actualTotal, 1)}
aria-label={`${running} of ${actualTotal} containers running`}
aria-label={`${safeRunning} of ${actualTotal} containers running`}
/>
</div>
</StatsItem>
@@ -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();
});
});
@@ -252,7 +252,7 @@ export function MultiSelect<TValue = string>({
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<TValue = string>({
/>
);
// 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<HTMLInputElement>) {
onBlur?.(e);
const trimmed = inputValue.trim();
@@ -270,11 +279,10 @@ export function MultiSelect<TValue = string>({
return;
}
if (onCreateOption && isCreatable) {
onCreateOption(trimmed);
handleCreateOption(trimmed);
} else {
onChange([...value, trimmed as TValue]);
}
setInputValue('');
}
}
@@ -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([])
@@ -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 () => {
@@ -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 () => {
@@ -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)
)
);
}
@@ -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,
@@ -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<object>) => ({
}));
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 });
@@ -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 });
@@ -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();
});
});
@@ -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(
@@ -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<typeof vi.spyOn>;
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 () => {
@@ -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 () => {
@@ -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();
});
});
@@ -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();
@@ -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' },
@@ -68,12 +68,16 @@ type RowProps = React.ComponentProps<typeof LinkedServiceAccountsRow>;
function renderRow(props: Partial<RowProps> = {}) {
return render(
<LinkedServiceAccountsRowWithQuery
secretName="my-secret"
namespace="default"
isSystem={false}
{...props}
/>
<table>
<tbody>
<LinkedServiceAccountsRowWithQuery
secretName="my-secret"
namespace="default"
isSystem={false}
{...props}
/>
</tbody>
</table>
);
}
@@ -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 () => {
@@ -68,13 +68,17 @@ type RowProps = React.ComponentProps<typeof ImagePullSecretsRow>;
function renderRow(props: Partial<RowProps> = {}) {
return render(
<ImagePullSecretsRowWithQuery
namespace="default"
name="my-sa"
imagePullSecrets={[]}
isSystem={false}
{...props}
/>
<table>
<tbody>
<ImagePullSecretsRowWithQuery
namespace="default"
name="my-sa"
imagePullSecrets={[]}
isSystem={false}
{...props}
/>
</tbody>
</table>
);
}
@@ -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<object>) => ({
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();
@@ -96,7 +96,11 @@ export function PersistentVolumeClaimsDatatable() {
/>
{editResizeClaim && (
<Modal onDismiss={() => setEditResizeClaim(null)} size="md">
<Modal
onDismiss={() => setEditResizeClaim(null)}
size="md"
aria-label="Resize Persistent Volume Claim"
>
<ResizeClaimEditForm
claim={editResizeClaim}
onDismiss={() => setEditResizeClaim(null)}
@@ -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());
@@ -45,24 +45,27 @@ export function CreateGroupView() {
</>
);
async function handleSubmit(
function handleSubmit(
values: GroupFormValues,
{ resetForm }: FormikHelpers<GroupFormValues>
) {
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<void> {
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(),
}
);
});
}
}
@@ -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 () => {
@@ -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({
<SheetContent className="flex flex-col !p-0">
<div className="flex-1 overflow-auto p-4">
<SheetHeader title="Add environments" />
<SheetDescription className="sr-only">
Select environments to add to this group.
</SheetDescription>
<Datatable<EnvironmentTableData>
title="Available environments"
columns={columns}
@@ -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(
@@ -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<GroupFormValues>
+12
View File
@@ -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
),
});
+10 -19
View File
@@ -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 */
+1
View File
@@ -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",
+21
View File
@@ -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
+2 -5
View File
@@ -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'] })],
});