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';
|
} from '@uirouter/angularjs';
|
||||||
|
|
||||||
import { get, keyBuilder } from '@/react/hooks/useLocalStorage';
|
import { get, keyBuilder } from '@/react/hooks/useLocalStorage';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { checkAuthorizations } from './authorization-guard';
|
import { checkAuthorizations } from './authorization-guard';
|
||||||
import { IAuthenticationService } from './services/types';
|
import { IAuthenticationService } from './services/types';
|
||||||
|
|
||||||
|
let restoreConsole: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreConsole = suppressConsoleLogs();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
restoreConsole();
|
||||||
|
});
|
||||||
|
|
||||||
describe('checkAuthorizations', () => {
|
describe('checkAuthorizations', () => {
|
||||||
let authService = {
|
let authService = {
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import toastr from 'toastr';
|
import toastr from 'toastr';
|
||||||
|
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { notifyError, notifySuccess, notifyWarning } from './notifications';
|
import { notifyError, notifySuccess, notifyWarning } from './notifications';
|
||||||
|
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => vi.fn());
|
let restoreConsole: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreConsole = suppressConsoleLogs();
|
||||||
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
restoreConsole();
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,9 +23,6 @@ it('calling success should show success message', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calling error with Error should show error message', () => {
|
it('calling error with Error should show error message', () => {
|
||||||
const consoleErrorFn = vi
|
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => vi.fn());
|
|
||||||
const title = 'title';
|
const title = 'title';
|
||||||
const errorMessage = 'message';
|
const errorMessage = 'message';
|
||||||
const fallback = 'fallback';
|
const fallback = 'fallback';
|
||||||
@@ -32,14 +34,9 @@ it('calling error with Error should show error message', () => {
|
|||||||
title,
|
title,
|
||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
|
|
||||||
consoleErrorFn.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calling error without Error should show fallback message', () => {
|
it('calling error without Error should show fallback message', () => {
|
||||||
const consoleErrorFn = vi
|
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => vi.fn());
|
|
||||||
const title = 'title';
|
const title = 'title';
|
||||||
|
|
||||||
const fallback = 'fallback';
|
const fallback = 'fallback';
|
||||||
@@ -47,7 +44,6 @@ it('calling error without Error should show fallback message', () => {
|
|||||||
notifyError(title, undefined, fallback);
|
notifyError(title, undefined, fallback);
|
||||||
|
|
||||||
expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything());
|
expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything());
|
||||||
consoleErrorFn.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calling warning should show warning message', () => {
|
it('calling warning should show warning message', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { DashboardView } from './DashboardView';
|
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 () => {
|
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(
|
const { queryByLabelText } = await renderComponent(
|
||||||
1,
|
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('Subscription')).not.toBeInTheDocument();
|
||||||
expect(queryByLabelText('Resource group')).not.toBeInTheDocument();
|
expect(queryByLabelText('Resource group')).not.toBeInTheDocument();
|
||||||
|
restoreConsole();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when only resource groups fail to load, still show the subscriptions', async () => {
|
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(
|
const { queryByLabelText, findByLabelText } = await renderComponent(
|
||||||
1,
|
1,
|
||||||
@@ -99,6 +101,7 @@ test('when only resource groups fail to load, still show the subscriptions', asy
|
|||||||
);
|
);
|
||||||
await expect(findByLabelText('Subscription')).resolves.toBeInTheDocument();
|
await expect(findByLabelText('Subscription')).resolves.toBeInTheDocument();
|
||||||
expect(queryByLabelText('Resource group')).not.toBeInTheDocument();
|
expect(queryByLabelText('Resource group')).not.toBeInTheDocument();
|
||||||
|
restoreConsole();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function renderComponent(
|
async function renderComponent(
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
import { StatsLineChart } from './StatsLineChart';
|
import { StatsLineChart } from './StatsLineChart';
|
||||||
import type { SeriesConfig } from './StatsLineChart';
|
import type { SeriesConfig } from './StatsLineChart';
|
||||||
|
|
||||||
vi.mock('recharts', async (importOriginal) => {
|
vi.mock('recharts');
|
||||||
const original = await importOriginal<typeof import('recharts')>();
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
ResponsiveContainer: ({ children }: { children: React.ReactElement }) =>
|
|
||||||
React.cloneElement(children, { width: 800, height: 300 }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function yAxisFormatter(value: number): string {
|
function yAxisFormatter(value: number): string {
|
||||||
return `${value}%`;
|
return `${value}%`;
|
||||||
|
|||||||
@@ -84,19 +84,23 @@ export function ContainerStats({
|
|||||||
running,
|
running,
|
||||||
stopped,
|
stopped,
|
||||||
}: ContainerStatsProps) {
|
}: ContainerStatsProps) {
|
||||||
const actualTotal = total || running + stopped;
|
const safeRunning = running || 0;
|
||||||
|
const safeStopped = stopped || 0;
|
||||||
|
const actualTotal = total || safeRunning + safeStopped;
|
||||||
return (
|
return (
|
||||||
<StatsItem title="CONTAINERS" icon={Hexagon}>
|
<StatsItem title="CONTAINERS" icon={Hexagon}>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-base font-bold leading-none">{running}</span>
|
<span className="text-base font-bold leading-none">
|
||||||
|
{safeRunning}
|
||||||
|
</span>
|
||||||
<span> / {actualTotal}</span>
|
<span> / {actualTotal}</span>
|
||||||
</div>
|
</div>
|
||||||
<progress
|
<progress
|
||||||
className="h-[4px] w-auto rounded bg-gray-4 th-dark:bg-white/10"
|
className="h-[4px] w-auto rounded bg-gray-4 th-dark:bg-white/10"
|
||||||
value={running}
|
value={safeRunning}
|
||||||
max={Math.max(actualTotal, 1)}
|
max={Math.max(actualTotal, 1)}
|
||||||
aria-label={`${running} of ${actualTotal} containers running`}
|
aria-label={`${safeRunning} of ${actualTotal} containers running`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</StatsItem>
|
</StatsItem>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Layers } from 'lucide-react';
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { findSelectedTabIndex, Tab, WidgetTabs } from './WidgetTabs';
|
import { findSelectedTabIndex, Tab, WidgetTabs } from './WidgetTabs';
|
||||||
|
|
||||||
// Mock Link component to avoid ui-router relative state resolution in tests
|
// Mock Link component to avoid ui-router relative state resolution in tests
|
||||||
@@ -122,6 +124,8 @@ describe('WidgetTabs', () => {
|
|||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
it('throws an error when any tab has an invalid URL-encodable param value', () => {
|
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
|
// Tabs with characters that change when URL-encoded
|
||||||
const invalidTabs: Tab[] = [
|
const invalidTabs: Tab[] = [
|
||||||
{
|
{
|
||||||
@@ -135,6 +139,8 @@ describe('WidgetTabs', () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
renderWidgetTabs({ tabs: invalidTabs, currentTabIndex: 1 })
|
renderWidgetTabs({ tabs: invalidTabs, currentTabIndex: 1 })
|
||||||
).toThrow('Invalid query param value for tab');
|
).toThrow('Invalid query param value for tab');
|
||||||
|
|
||||||
|
restoreConsole();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export function MultiSelect<TValue = string>({
|
|||||||
noOptionsMessage={noOptionsMessage}
|
noOptionsMessage={noOptionsMessage}
|
||||||
loadingMessage={loadingMessage}
|
loadingMessage={loadingMessage}
|
||||||
formatCreateLabel={formatCreateLabel}
|
formatCreateLabel={formatCreateLabel}
|
||||||
onCreateOption={onCreateOption}
|
onCreateOption={handleCreateOption}
|
||||||
inputValue={inputValue}
|
inputValue={inputValue}
|
||||||
onInputChange={(textInput) => setInputValue(textInput)}
|
onInputChange={(textInput) => setInputValue(textInput)}
|
||||||
onBlur={handleBlur}
|
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>) {
|
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||||
onBlur?.(e);
|
onBlur?.(e);
|
||||||
const trimmed = inputValue.trim();
|
const trimmed = inputValue.trim();
|
||||||
@@ -270,11 +279,10 @@ export function MultiSelect<TValue = string>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (onCreateOption && isCreatable) {
|
if (onCreateOption && isCreatable) {
|
||||||
onCreateOption(trimmed);
|
handleCreateOption(trimmed);
|
||||||
} else {
|
} else {
|
||||||
onChange([...value, trimmed as TValue]);
|
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 { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
import { Role } from '@/portainer/users/types';
|
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';
|
import { ListView } from './ListView';
|
||||||
|
|
||||||
@@ -44,11 +47,9 @@ describe('ListView', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/endpoints/1', () =>
|
http.get('/api/endpoints/1', () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
Id: 1,
|
createMockEnvironment({ Id: 1, Name: 'test-environment', Type: 1 })
|
||||||
Name: 'test-environment',
|
)
|
||||||
Type: 1,
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
http.get('/api/endpoints/:environmentId/docker/configs', () =>
|
http.get('/api/endpoints/:environmentId/docker/configs', () =>
|
||||||
HttpResponse.json([])
|
HttpResponse.json([])
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
|
|||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
import { createMockUser } from '@/react-tools/test-mocks';
|
import { createMockUser } from '@/react-tools/test-mocks';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
import { User } from '@/portainer/users/types';
|
import { User } from '@/portainer/users/types';
|
||||||
|
|
||||||
import { NameRow } from './NameRow';
|
import { NameRow } from './NameRow';
|
||||||
@@ -262,10 +263,8 @@ describe('NameRow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles rename API error gracefully', async () => {
|
it('handles rename API error gracefully', async () => {
|
||||||
// Mock console.error to suppress expected error logs
|
// Suppress expected error logs
|
||||||
const consoleErrorSpy = vi
|
const restoreConsole = suppressConsoleLogs();
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post(
|
http.post(
|
||||||
@@ -304,7 +303,7 @@ describe('NameRow', () => {
|
|||||||
expect(screen.getByTestId('containerNameInput')).toBeVisible();
|
expect(screen.getByTestId('containerNameInput')).toBeVisible();
|
||||||
expect(nameInput).toHaveValue('new-name');
|
expect(nameInput).toHaveValue('new-name');
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
restoreConsole();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validates that container name is required', async () => {
|
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 { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
import { createMockUser } from '@/react-tools/test-mocks';
|
import { createMockUser } from '@/react-tools/test-mocks';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { CreateImageSection } from './CreateImageSection';
|
import { CreateImageSection } from './CreateImageSection';
|
||||||
|
|
||||||
@@ -174,9 +175,7 @@ describe('CreateImageSection', () => {
|
|||||||
|
|
||||||
it('should handle API error', async () => {
|
it('should handle API error', async () => {
|
||||||
const onMutationError = vi.fn();
|
const onMutationError = vi.fn();
|
||||||
const consoleErrorSpy = vi
|
const restoreConsole = suppressConsoleLogs();
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/endpoints/:endpointId/docker/commit', () =>
|
http.post('/api/endpoints/:endpointId/docker/commit', () =>
|
||||||
@@ -209,7 +208,7 @@ describe('CreateImageSection', () => {
|
|||||||
{ timeout: 3000 }
|
{ timeout: 3000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
restoreConsole();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show loading state during creation', async () => {
|
it('should show loading state during creation', async () => {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ vi.mock('@uirouter/react', async (importOriginal) => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('recharts');
|
||||||
|
|
||||||
const minimalStats = {
|
const minimalStats = {
|
||||||
read: '2024-01-01T00:00:01Z',
|
read: '2024-01-01T00:00:01Z',
|
||||||
preread: '2024-01-01T00:00:00Z',
|
preread: '2024-01-01T00:00:00Z',
|
||||||
@@ -59,6 +61,9 @@ function addBaseHandlers() {
|
|||||||
),
|
),
|
||||||
http.get('/api/endpoints/1/docker/containers/container1/top', () =>
|
http.get('/api/endpoints/1/docker/containers/container1/top', () =>
|
||||||
HttpResponse.json({ Processes: [], Titles: [] })
|
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 { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { PruneButton } from './PruneButton';
|
import { PruneButton } from './PruneButton';
|
||||||
|
|
||||||
@@ -423,6 +424,14 @@ describe('PruneButton', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
|
let restoreConsole: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreConsole = suppressConsoleLogs();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
restoreConsole();
|
||||||
|
});
|
||||||
|
|
||||||
it('should show error notification on API failure', async () => {
|
it('should show error notification on API failure', async () => {
|
||||||
mockConfirmPruneImages.mockResolvedValue({
|
mockConfirmPruneImages.mockResolvedValue({
|
||||||
pruneAll: false,
|
pruneAll: false,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
|||||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
|
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||||
|
|
||||||
import { NetworkContainer } from '../types';
|
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 () => {
|
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 });
|
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 { server } from '@/setup-tests/server';
|
||||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||||
|
|
||||||
import { DockerNetwork } from '../types';
|
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) {
|
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 });
|
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 { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { CreateStackForm } from './CreateStackForm';
|
import { CreateStackForm } from './CreateStackForm';
|
||||||
|
|
||||||
@@ -334,10 +335,7 @@ describe('CreateStackForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle API error gracefully', async () => {
|
it('should handle API error gracefully', async () => {
|
||||||
const consoleErrorSpy = vi
|
const restoreConsole = suppressConsoleLogs();
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const mutationError = vi.fn();
|
const mutationError = vi.fn();
|
||||||
const errorMessage = 'test - failed to create stack';
|
const errorMessage = 'test - failed to create stack';
|
||||||
@@ -380,7 +378,6 @@ describe('CreateStackForm', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
restoreConsole();
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -306,8 +306,13 @@ describe('useVersionedStackFile', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
const restoreConsole = suppressConsoleLogs();
|
let restoreConsole: () => void;
|
||||||
afterAll(restoreConsole);
|
beforeEach(() => {
|
||||||
|
restoreConsole = suppressConsoleLogs();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
restoreConsole();
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle API errors gracefully', async () => {
|
it('should handle API errors gracefully', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
+4
-4
@@ -6,6 +6,7 @@ import { server } from '@/setup-tests/server';
|
|||||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
import { Stack } from '@/react/common/stacks/types';
|
import { Stack } from '@/react/common/stacks/types';
|
||||||
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
|
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { useAssociateStackToEnvironmentMutation } from './useAssociateStackToEnvironmentMutation';
|
import { useAssociateStackToEnvironmentMutation } from './useAssociateStackToEnvironmentMutation';
|
||||||
|
|
||||||
@@ -192,15 +193,14 @@ describe('useAssociateStackToEnvironmentMutation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
let restoreConsole: () => void;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Suppress console.error for error tests to reduce noise
|
restoreConsole = suppressConsoleLogs();
|
||||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
consoleError.mockRestore();
|
restoreConsole();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle API error when association fails', async () => {
|
it('should handle API error when association fails', async () => {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ vi.mock('@uirouter/react', async (importOriginal) => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('recharts');
|
||||||
|
|
||||||
const podMetricsSuccess = {
|
const podMetricsSuccess = {
|
||||||
timestamp: '2024-01-01T00:00:00Z',
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
containers: [
|
containers: [
|
||||||
@@ -39,6 +41,9 @@ function addBaseHandlers() {
|
|||||||
),
|
),
|
||||||
http.get('/api/endpoints/1/kubernetes/api/v1/nodes/node1', () =>
|
http.get('/api/endpoints/1/kubernetes/api/v1/nodes/node1', () =>
|
||||||
HttpResponse.json({ status: { allocatable: { cpu: '4' } } })
|
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', () => {
|
describe('ApplicationStatsView', () => {
|
||||||
it('renders the page header "Application stats"', () => {
|
it('renders the page header "Application stats"', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/kubernetes/1/metrics/pods/namespace/default/my-pod', () =>
|
http.get('/api/kubernetes/1/metrics/pods/namespace/default/my-pod', () =>
|
||||||
HttpResponse.json(podMetricsSuccess)
|
HttpResponse.json(podMetricsSuccess)
|
||||||
@@ -70,6 +75,12 @@ describe('ApplicationStatsView', () => {
|
|||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
expect(screen.getByText('Application stats')).toBeInTheDocument();
|
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 () => {
|
it('shows "Unable to retrieve container metrics" panel when pod metrics fetch returns 500', async () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createMockEnvironment,
|
createMockEnvironment,
|
||||||
createMockQueryResult,
|
createMockQueryResult,
|
||||||
} from '@/react-tools/test-mocks';
|
} from '@/react-tools/test-mocks';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
||||||
|
|
||||||
@@ -169,8 +170,7 @@ describe('ClusterResourceReservation', () => {
|
|||||||
http.get('/api/kubernetes/3/metrics/nodes', () => HttpResponse.error())
|
http.get('/api/kubernetes/3/metrics/nodes', () => HttpResponse.error())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mock console.error so test logs are not polluted
|
const restoreConsole = suppressConsoleLogs();
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
@@ -205,7 +205,6 @@ describe('ClusterResourceReservation', () => {
|
|||||||
)
|
)
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Restore console.error
|
restoreConsole();
|
||||||
vi.spyOn(console, 'error').mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ function triggerStateChange(state: ShellState) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
value: { protocol: 'https:', host: 'localhost:3000' },
|
value: {
|
||||||
|
protocol: 'https:',
|
||||||
|
host: 'localhost:3000',
|
||||||
|
href: 'https://localhost:3000/',
|
||||||
|
},
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -60,7 +64,11 @@ describe('KubectlShellView', () => {
|
|||||||
|
|
||||||
it('builds ws:// URL when location is http', () => {
|
it('builds ws:// URL when location is http', () => {
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
value: { protocol: 'http:', host: 'localhost:3000' },
|
value: {
|
||||||
|
protocol: 'http:',
|
||||||
|
host: 'localhost:3000',
|
||||||
|
href: 'http://localhost:3000/',
|
||||||
|
},
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ vi.mock('@uirouter/react', async (importOriginal) => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('recharts');
|
||||||
|
|
||||||
const nodeMetricsSuccess = {
|
const nodeMetricsSuccess = {
|
||||||
metadata: { creationTimestamp: '2024-01-01T00:00:00Z' },
|
metadata: { creationTimestamp: '2024-01-01T00:00:00Z' },
|
||||||
usage: { cpu: '250m', memory: '512Mi' },
|
usage: { cpu: '250m', memory: '512Mi' },
|
||||||
|
|||||||
@@ -68,12 +68,16 @@ type RowProps = React.ComponentProps<typeof LinkedServiceAccountsRow>;
|
|||||||
|
|
||||||
function renderRow(props: Partial<RowProps> = {}) {
|
function renderRow(props: Partial<RowProps> = {}) {
|
||||||
return render(
|
return render(
|
||||||
<LinkedServiceAccountsRowWithQuery
|
<table>
|
||||||
secretName="my-secret"
|
<tbody>
|
||||||
namespace="default"
|
<LinkedServiceAccountsRowWithQuery
|
||||||
isSystem={false}
|
secretName="my-secret"
|
||||||
{...props}
|
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 { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
|
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
|
||||||
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
|
import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { HelmApplicationView } from './HelmApplicationView';
|
import { HelmApplicationView } from './HelmApplicationView';
|
||||||
|
|
||||||
@@ -266,8 +267,7 @@ describe('HelmApplicationView', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mock console.error to prevent test output pollution
|
const restoreConsole = suppressConsoleLogs();
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
@@ -280,8 +280,7 @@ describe('HelmApplicationView', () => {
|
|||||||
)
|
)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Restore console.error
|
restoreConsole();
|
||||||
vi.spyOn(console, 'error').mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display additional details when available in helm release', async () => {
|
it('should display additional details when available in helm release', async () => {
|
||||||
|
|||||||
+11
-7
@@ -68,13 +68,17 @@ type RowProps = React.ComponentProps<typeof ImagePullSecretsRow>;
|
|||||||
|
|
||||||
function renderRow(props: Partial<RowProps> = {}) {
|
function renderRow(props: Partial<RowProps> = {}) {
|
||||||
return render(
|
return render(
|
||||||
<ImagePullSecretsRowWithQuery
|
<table>
|
||||||
namespace="default"
|
<tbody>
|
||||||
name="my-sa"
|
<ImagePullSecretsRowWithQuery
|
||||||
imagePullSecrets={[]}
|
namespace="default"
|
||||||
isSystem={false}
|
name="my-sa"
|
||||||
{...props}
|
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 { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { UpdateNamespaceForm } from './UpdateNamespaceForm';
|
import { UpdateNamespaceForm } from './UpdateNamespaceForm';
|
||||||
|
|
||||||
@@ -18,6 +19,11 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
|||||||
useCurrentStateAndParams: vi.fn(() => ({
|
useCurrentStateAndParams: vi.fn(() => ({
|
||||||
params: { id: NAMESPACE_NAME },
|
params: { id: NAMESPACE_NAME },
|
||||||
})),
|
})),
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
stateService: {
|
||||||
|
reload: vi.fn(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||||
@@ -197,6 +203,14 @@ describe('UpdateNamespaceForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('error states', () => {
|
describe('error states', () => {
|
||||||
|
let restoreConsole: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreConsole = suppressConsoleLogs();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
restoreConsole();
|
||||||
|
});
|
||||||
|
|
||||||
it('should show error alert when namespace query fails', async () => {
|
it('should show error alert when namespace query fails', async () => {
|
||||||
setupDefaultHandlers({ namespaceError: true });
|
setupDefaultHandlers({ namespaceError: true });
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|||||||
@@ -96,7 +96,11 @@ export function PersistentVolumeClaimsDatatable() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{editResizeClaim && (
|
{editResizeClaim && (
|
||||||
<Modal onDismiss={() => setEditResizeClaim(null)} size="md">
|
<Modal
|
||||||
|
onDismiss={() => setEditResizeClaim(null)}
|
||||||
|
size="md"
|
||||||
|
aria-label="Resize Persistent Volume Claim"
|
||||||
|
>
|
||||||
<ResizeClaimEditForm
|
<ResizeClaimEditForm
|
||||||
claim={editResizeClaim}
|
claim={editResizeClaim}
|
||||||
onDismiss={() => setEditResizeClaim(null)}
|
onDismiss={() => setEditResizeClaim(null)}
|
||||||
|
|||||||
+5
-5
@@ -8,6 +8,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
|
|||||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
import { server } from '@/setup-tests/server';
|
import { server } from '@/setup-tests/server';
|
||||||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||||
|
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||||
|
|
||||||
import { CreateGroupView } from './CreateGroupView';
|
import { CreateGroupView } from './CreateGroupView';
|
||||||
|
|
||||||
@@ -225,9 +226,7 @@ describe('CreateGroupView', () => {
|
|||||||
|
|
||||||
describe('Error handling', () => {
|
describe('Error handling', () => {
|
||||||
it('should handle API error gracefully', async () => {
|
it('should handle API error gracefully', async () => {
|
||||||
const consoleErrorSpy = vi
|
const restoreConsole = suppressConsoleLogs();
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
|
|
||||||
const mutationError = vi.fn();
|
const mutationError = vi.fn();
|
||||||
const errorMessage = 'Failed to create group';
|
const errorMessage = 'Failed to create group';
|
||||||
@@ -255,7 +254,7 @@ describe('CreateGroupView', () => {
|
|||||||
expect(mutationError).toHaveBeenCalled();
|
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
|
// Environment now appears in the associated list — select its row checkbox and remove it
|
||||||
await screen.findByText('removable-env');
|
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]);
|
await user.click(assocCheckboxes[assocCheckboxes.length - 1]);
|
||||||
const removeBtn = await screen.findByTestId('remove-environments-button');
|
const removeBtn = await screen.findByTestId('remove-environments-button');
|
||||||
await waitFor(() => expect(removeBtn).toBeEnabled());
|
await waitFor(() => expect(removeBtn).toBeEnabled());
|
||||||
|
|||||||
+19
-16
@@ -45,24 +45,27 @@ export function CreateGroupView() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleSubmit(
|
function handleSubmit(
|
||||||
values: GroupFormValues,
|
values: GroupFormValues,
|
||||||
{ resetForm }: FormikHelpers<GroupFormValues>
|
{ resetForm }: FormikHelpers<GroupFormValues>
|
||||||
) {
|
): Promise<void> {
|
||||||
await createMutation.mutateAsync(
|
return new Promise((resolve) => {
|
||||||
{
|
createMutation.mutate(
|
||||||
name: values.name,
|
{
|
||||||
description: values.description,
|
name: values.name,
|
||||||
tagIds: values.tagIds,
|
description: values.description,
|
||||||
associatedEnvironments: values.associatedEnvironments,
|
tagIds: values.tagIds,
|
||||||
},
|
associatedEnvironments: values.associatedEnvironments,
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
resetForm();
|
|
||||||
notifySuccess('Success', 'Group successfully created');
|
|
||||||
router.stateService.go('portainer.groups');
|
|
||||||
},
|
},
|
||||||
}
|
{
|
||||||
);
|
onSuccess: () => {
|
||||||
|
resetForm();
|
||||||
|
notifySuccess('Success', 'Group successfully created');
|
||||||
|
router.stateService.go('portainer.groups');
|
||||||
|
},
|
||||||
|
onSettled: () => resolve(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-3
@@ -262,9 +262,13 @@ describe('EditGroupView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Error state', () => {
|
describe('Error state', () => {
|
||||||
// Suppress console logs for error state tests
|
let restoreConsole: () => void;
|
||||||
const restoreConsole = suppressConsoleLogs();
|
beforeEach(() => {
|
||||||
afterAll(restoreConsole);
|
restoreConsole = suppressConsoleLogs();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
restoreConsole();
|
||||||
|
});
|
||||||
|
|
||||||
it('should show error Alert when group fetch fails', async () => {
|
it('should show error Alert when group fetch fails', async () => {
|
||||||
renderEditGroupView({ groupData: null });
|
renderEditGroupView({ groupData: null });
|
||||||
@@ -557,6 +561,9 @@ describe('EditGroupView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should hide the delete button when group data is missing', async () => {
|
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 });
|
renderEditGroupView({ groupData: null });
|
||||||
|
|
||||||
// Wait for the header error state to appear
|
// Wait for the header error state to appear
|
||||||
@@ -567,6 +574,8 @@ describe('EditGroupView', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.queryByRole('button', { name: /Delete/i })
|
screen.queryByRole('button', { name: /Delete/i })
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
restoreLog();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct data-cy attribute', async () => {
|
it('should have correct data-cy attribute', async () => {
|
||||||
|
|||||||
+10
-1
@@ -14,7 +14,13 @@ import { Datatable } from '@@/datatables';
|
|||||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||||
import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
|
import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
|
||||||
import { TableRow } from '@@/datatables/TableRow';
|
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 { Button, LoadingButton } from '@@/buttons';
|
||||||
|
|
||||||
import { EnvironmentTableData } from './types';
|
import { EnvironmentTableData } from './types';
|
||||||
@@ -119,6 +125,9 @@ export function AddEnvironmentsDrawer({
|
|||||||
<SheetContent className="flex flex-col !p-0">
|
<SheetContent className="flex flex-col !p-0">
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<SheetHeader title="Add environments" />
|
<SheetHeader title="Add environments" />
|
||||||
|
<SheetDescription className="sr-only">
|
||||||
|
Select environments to add to this group.
|
||||||
|
</SheetDescription>
|
||||||
<Datatable<EnvironmentTableData>
|
<Datatable<EnvironmentTableData>
|
||||||
title="Available environments"
|
title="Available environments"
|
||||||
columns={columns}
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
@@ -192,11 +192,11 @@ describe('AssociatedEnvironmentsSelector', () => {
|
|||||||
await screen.findByText('Add environments');
|
await screen.findByText('Add environments');
|
||||||
await screen.findByText('new-env');
|
await screen.findByText('new-env');
|
||||||
|
|
||||||
// Select the available env — find the drawer's checkboxes
|
// Select the available env — scope to the drawer table to avoid duplicates
|
||||||
// The drawer table has its own checkboxes after the main table ones
|
const drawerTable = screen.getByTestId('add-environments-drawer-table');
|
||||||
const allCheckboxes = screen.getAllByRole('checkbox');
|
const drawerCheckboxes = within(drawerTable).getAllByRole('checkbox');
|
||||||
// Last checkbox belongs to the drawer table row
|
// [0] = select-all header, [1] = first row
|
||||||
await user.click(allCheckboxes[allCheckboxes.length - 1]);
|
await user.click(drawerCheckboxes[1]);
|
||||||
|
|
||||||
// Click the Add button in the drawer footer
|
// Click the Add button in the drawer footer
|
||||||
const confirmAddBtn = screen.getByTestId(
|
const confirmAddBtn = screen.getByTestId(
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export interface GroupFormValues {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialValues: GroupFormValues;
|
initialValues: GroupFormValues;
|
||||||
/** Should return a Promise that resolves when navigation happens (to keep isSubmitting true) */
|
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
values: GroupFormValues,
|
values: GroupFormValues,
|
||||||
helpers: FormikHelpers<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';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,25 +39,17 @@ import { vi } from 'vitest';
|
|||||||
* @returns A cleanup function to restore the original console methods
|
* @returns A cleanup function to restore the original console methods
|
||||||
*/
|
*/
|
||||||
export function suppressConsoleLogs() {
|
export function suppressConsoleLogs() {
|
||||||
const originalError = console.error;
|
// Use vi.spyOn so the mocks integrate with vitest's mock system
|
||||||
const originalWarn = console.warn;
|
// (compatible with vitest-fail-on-console and vi.restoreAllMocks)
|
||||||
const originalInfo = console.info;
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const originalLog = console.log;
|
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 () => {
|
return () => {
|
||||||
console.error = originalError;
|
errorSpy.mockRestore();
|
||||||
console.warn = originalWarn;
|
warnSpy.mockRestore();
|
||||||
console.info = originalInfo;
|
infoSpy.mockRestore();
|
||||||
console.log = originalLog;
|
logSpy.mockRestore();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable no-console */
|
|
||||||
|
|||||||
@@ -237,6 +237,7 @@
|
|||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"vite-tsconfig-paths": "^4.3.1",
|
"vite-tsconfig-paths": "^4.3.1",
|
||||||
"vitest": "^4.1.8",
|
"vitest": "^4.1.8",
|
||||||
|
"vitest-fail-on-console": "^0.9.0",
|
||||||
"webpack": "^5.107.2",
|
"webpack": "^5.107.2",
|
||||||
"webpack-build-notifier": "^3.1.0",
|
"webpack-build-notifier": "^3.1.0",
|
||||||
"webpack-bundle-analyzer": "^5.2.0",
|
"webpack-bundle-analyzer": "^5.2.0",
|
||||||
|
|||||||
Generated
+21
@@ -627,6 +627,9 @@ importers:
|
|||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.8
|
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))
|
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:
|
webpack:
|
||||||
specifier: ^5.107.2
|
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)
|
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==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
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:
|
character-entities-html4@2.1.0:
|
||||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||||
|
|
||||||
@@ -8635,6 +8642,12 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
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:
|
vitest@4.1.8:
|
||||||
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
||||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
@@ -12930,6 +12943,8 @@ snapshots:
|
|||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
|
|
||||||
|
chalk@5.6.2: {}
|
||||||
|
|
||||||
character-entities-html4@2.1.0: {}
|
character-entities-html4@2.1.0: {}
|
||||||
|
|
||||||
character-entities-legacy@3.0.0: {}
|
character-entities-legacy@3.0.0: {}
|
||||||
@@ -17696,6 +17711,12 @@ snapshots:
|
|||||||
terser: 5.44.1
|
terser: 5.44.1
|
||||||
yaml: 1.10.2
|
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)):
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.8
|
'@vitest/expect': 4.1.8
|
||||||
|
|||||||
+2
-5
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
'./app/setup-tests/stub-modules.ts',
|
'./app/setup-tests/stub-modules.ts',
|
||||||
'./app/setup-tests/setup.ts',
|
'./app/setup-tests/setup.ts',
|
||||||
'./app/setup-tests/setup-codemirror.ts',
|
'./app/setup-tests/setup-codemirror.ts',
|
||||||
|
'./app/setup-tests/setup-fail-on-console.ts',
|
||||||
],
|
],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
@@ -38,9 +39,5 @@ export default defineConfig({
|
|||||||
return !/Can't perform a React state update on an unmounted component/.test(log);
|
return !/Can't perform a React state update on an unmounted component/.test(log);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [svgr({ include: /\?c$/ }), tsconfigPaths(), tsconfigPaths({ projects: ['./tsconfig.generated.json'] })],
|
||||||
svgr({ include: /\?c$/ }),
|
|
||||||
tsconfigPaths(),
|
|
||||||
tsconfigPaths({ projects: ['./tsconfig.generated.json'] }),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user