mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 09:04:48 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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}%`;
|
||||
|
||||
@@ -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(
|
||||
|
||||
+4
-4
@@ -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(
|
||||
<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 () => {
|
||||
|
||||
+4
@@ -68,6 +68,8 @@ type RowProps = React.ComponentProps<typeof ImagePullSecretsRow>;
|
||||
|
||||
function renderRow(props: Partial<RowProps> = {}) {
|
||||
return render(
|
||||
<table>
|
||||
<tbody>
|
||||
<ImagePullSecretsRowWithQuery
|
||||
namespace="default"
|
||||
name="my-sa"
|
||||
@@ -75,6 +77,8 @@ function renderRow(props: Partial<RowProps> = {}) {
|
||||
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)}
|
||||
|
||||
+5
-5
@@ -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,11 +45,12 @@ export function CreateGroupView() {
|
||||
</>
|
||||
);
|
||||
|
||||
async function handleSubmit(
|
||||
function handleSubmit(
|
||||
values: GroupFormValues,
|
||||
{ resetForm }: FormikHelpers<GroupFormValues>
|
||||
) {
|
||||
await createMutation.mutateAsync(
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
createMutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
@@ -62,7 +63,9 @@ export function CreateGroupView() {
|
||||
notifySuccess('Success', 'Group successfully created');
|
||||
router.stateService.go('portainer.groups');
|
||||
},
|
||||
onSettled: () => resolve(),
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -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 () => {
|
||||
|
||||
+10
-1
@@ -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}
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
@@ -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
|
||||
),
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+21
@@ -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
@@ -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'] })],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user