mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 23:20:34 +01:00
Compare commits
2 Commits
main
...
test/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4510889dd9 | ||
|
|
a75233ee25 |
@@ -1,28 +0,0 @@
|
||||
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateTo {
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type SafeNavigateToType = string | SafeNavigateTo;
|
||||
|
||||
interface UseSafeNavigateReturn {
|
||||
safeNavigate: jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
>;
|
||||
}
|
||||
|
||||
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
|
||||
safeNavigate: jest.fn(
|
||||
(_to: SafeNavigateToType, _options?: SafeNavigateOptions) => {},
|
||||
) as jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
>,
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH =
|
||||
'<rootDir>/src/__tests__/safeNavigateMock.ts';
|
||||
const LOG_EVENT_MOCK_PATH = '<rootDir>/src/__tests__/logEventMock.ts';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
silent: true,
|
||||
@@ -22,6 +24,8 @@ const config: Config.InitialOptions = {
|
||||
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^api/common/logEvent$': LOG_EVENT_MOCK_PATH,
|
||||
'^src/api/common/logEvent$': LOG_EVENT_MOCK_PATH,
|
||||
'^constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
|
||||
|
||||
11
frontend/src/__tests__/logEventMock.ts
Normal file
11
frontend/src/__tests__/logEventMock.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Shared mock for `api/common/logEvent`.
|
||||
// Wired into jest.config.ts moduleNameMapper, so any import of
|
||||
// `api/common/logEvent` in test code resolves to this file.
|
||||
// Tests can import `logEventMock` to assert analytics calls — Jest's
|
||||
// `clearMocks: true` resets call history between tests.
|
||||
|
||||
export const logEventMock: jest.MockedFunction<
|
||||
(eventName: string, attributes?: Record<string, unknown>) => void
|
||||
> = jest.fn();
|
||||
|
||||
export default logEventMock;
|
||||
29
frontend/src/__tests__/safeNavigateMock.ts
Normal file
29
frontend/src/__tests__/safeNavigateMock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Shared mock for `hooks/useSafeNavigate`.
|
||||
// Wired into jest.config.ts moduleNameMapper, so any import of
|
||||
// `hooks/useSafeNavigate` in test code resolves to this file.
|
||||
// Tests can import `safeNavigateMock` to assert navigation calls — Jest's
|
||||
// `clearMocks: true` resets call history between tests.
|
||||
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateTo {
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type SafeNavigateToType = string | SafeNavigateTo;
|
||||
|
||||
export const safeNavigateMock: jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
> = jest.fn();
|
||||
|
||||
export const useSafeNavigate = (): {
|
||||
safeNavigate: typeof safeNavigateMock;
|
||||
} => ({
|
||||
safeNavigate: safeNavigateMock,
|
||||
});
|
||||
@@ -3,17 +3,12 @@ import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -35,7 +30,6 @@ jest.mock('container/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
const mockHandleContactSupport = handleContactSupport as jest.Mock;
|
||||
@@ -50,6 +44,7 @@ const mockLocation = {
|
||||
describe('FeedbackModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
logEventMock.mockReturnValue(Promise.resolve() as never);
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
@@ -116,7 +111,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'feedback',
|
||||
page: mockLocation.pathname,
|
||||
@@ -149,7 +144,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'reportBug',
|
||||
page: mockLocation.pathname,
|
||||
@@ -182,7 +177,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'featureRequest',
|
||||
page: mockLocation.pathname,
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -50,7 +45,6 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
@@ -120,7 +114,7 @@ describe('HeaderRightSection', () => {
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
@@ -133,7 +127,7 @@ describe('HeaderRightSection', () => {
|
||||
const shareButton = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
|
||||
@@ -150,7 +144,7 @@ describe('HeaderRightSection', () => {
|
||||
|
||||
await user.click(announcementsButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,18 +5,13 @@ import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import ShareURLModal from '../ShareURLModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -53,7 +48,6 @@ Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
const mockUseSelector = useSelector as jest.Mock;
|
||||
@@ -125,7 +119,7 @@ describe('ShareURLModal', () => {
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
page: TEST_PATH,
|
||||
URL: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -139,7 +139,6 @@ jest.mock('react-query', (): unknown => {
|
||||
});
|
||||
|
||||
// mock other side-effecty modules
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
jest.mock('api/browser/localstorage/set', () => jest.fn());
|
||||
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import {
|
||||
@@ -24,8 +24,6 @@ jest.mock('providers/cmdKProvider', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
|
||||
// Mock the AppContext
|
||||
const mockUpdateUserPreferenceInContext = jest.fn();
|
||||
|
||||
@@ -139,7 +137,7 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
it('should log the toggle event with correct parameters', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleShortcut = jest.fn(() => {
|
||||
logEvent('Global Shortcut: Sidebar Toggle', {
|
||||
logEventMock('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
@@ -155,10 +153,13 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
|
||||
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Global Shortcut: Sidebar Toggle',
|
||||
{
|
||||
previousState: false,
|
||||
newState: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should update user preference in context', async () => {
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TooltipFooter from '../TooltipFooter';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
describe('TooltipFooter', () => {
|
||||
const defaultProps = {
|
||||
id: 'panel-123',
|
||||
@@ -84,7 +78,7 @@ describe('TooltipFooter', () => {
|
||||
|
||||
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
id: 'panel-123',
|
||||
});
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -89,7 +89,7 @@ export function AlertsEmptyState({
|
||||
onClick={onClickNewAlertHandler}
|
||||
disabled={!addNewAlert}
|
||||
loading={loading}
|
||||
data-testid="add-alert"
|
||||
testId="add-alert"
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<Plus size="md" />
|
||||
@@ -97,7 +97,12 @@ export function AlertsEmptyState({
|
||||
</span>
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
prefix={<RefreshCw />}
|
||||
color="secondary"
|
||||
testId="list-alerts-empty-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { findAlertRow, renderListAlertRules } from './_helpers';
|
||||
|
||||
async function openActionsMenu(row: HTMLElement): Promise<void> {
|
||||
const trigger = row.querySelector(
|
||||
'[data-testid="alert-actions"]',
|
||||
) as HTMLElement | null;
|
||||
expect(trigger).not.toBeNull();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.click(trigger as HTMLElement);
|
||||
// Radix renders the menu items in a portal once the trigger is activated.
|
||||
await screen.findByRole('menu');
|
||||
}
|
||||
|
||||
async function clickMenuItem(label: string): Promise<void> {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const item = await screen.findByRole('menuitem', { name: label });
|
||||
await user.click(item);
|
||||
}
|
||||
|
||||
describe('ListAlertRules — actions menu', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders Enable/Disable/Edit/Edit in New Tab/Clone/Delete items after opening the menu', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
|
||||
await openActionsMenu(row);
|
||||
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
const labels = items.map((it) => it.textContent);
|
||||
expect(labels).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'Edit',
|
||||
'Edit in New Tab',
|
||||
'Clone',
|
||||
'Delete',
|
||||
'Disable',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('disabled rule (rule-4) shows "Enable" instead of "Disable"', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('Disabled Alert');
|
||||
await openActionsMenu(row);
|
||||
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
const labels = items.map((it) => it.textContent);
|
||||
expect(labels).toContain('Enable');
|
||||
expect(labels).not.toContain('Disable');
|
||||
});
|
||||
|
||||
it('toggle action: clicking Disable sends PATCH with disabled:true', async () => {
|
||||
let capturedBody: unknown = null;
|
||||
let capturedPath: string | null = null;
|
||||
server.use(
|
||||
rest.patch('http://localhost/api/v2/rules/:id', async (req, res, ctx) => {
|
||||
capturedBody = await req.json();
|
||||
capturedPath = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Disable');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toStrictEqual(
|
||||
expect.objectContaining({ disabled: true }),
|
||||
);
|
||||
});
|
||||
expect(capturedPath).toBe('rule-1');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('edit action: clicking Edit navigates via safeNavigate and logs event', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Edit');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock.mock.calls[0][0]).toContain('ruleId=rule-1');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Edit', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('edit in new tab action: clicking opens with newTab:true', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Edit in New Tab');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
const [url, options] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
|
||||
});
|
||||
|
||||
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
|
||||
let capturedPostBody: unknown = null;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
|
||||
capturedPostBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: {
|
||||
...(capturedPostBody as Record<string, unknown>),
|
||||
id: 'cloned-from-server',
|
||||
},
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Clone');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPostBody).toStrictEqual(
|
||||
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
|
||||
);
|
||||
});
|
||||
|
||||
// The id from the server response round-trips into the navigate URL — this
|
||||
// protects against a regression where the code hardcodes the id.
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock.mock.calls[0][0]).toContain(
|
||||
'ruleId=cloned-from-server',
|
||||
);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Clone', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('delete action: sends DELETE for the rule id', async () => {
|
||||
let deletedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (req, res, ctx) => {
|
||||
deletedId = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Delete');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deletedId).toBe('rule-1');
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Delete', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('error path: PATCH is still attempted when server returns 500', async () => {
|
||||
let patchAttempted = false;
|
||||
server.use(
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) => {
|
||||
patchAttempted = true;
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Disable');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(patchAttempted).toBe(true);
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { fireEvent, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
const COLUMN_STORAGE_KEY = '@signoz/table-columns/alert-rules-columns';
|
||||
|
||||
describe('ListAlertRules — columns selector', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('opens columns popover and lists toggleable columns', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.click(screen.getByTestId('alert-columns-button'));
|
||||
|
||||
// Popover should reveal "Toggle Columns" heading + per-column labels.
|
||||
await screen.findByText('Toggle Columns');
|
||||
expect(screen.getByText('Created At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created By')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated By')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default-hidden columns (Created At/By, Updated At/By) are not in the table header', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const headers = document.querySelectorAll('th');
|
||||
const headerTexts = Array.from(headers).map((h) => h.textContent || '');
|
||||
expect(headerTexts.some((t) => t.includes('Created At'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Created By'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Updated At'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Updated By'))).toBe(false);
|
||||
});
|
||||
|
||||
it('toggling Created At on writes to localStorage and adds the header', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const headersBefore = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headersBefore.some((t) => t.includes('Created At'))).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByTestId('alert-columns-button'));
|
||||
await screen.findByText('Toggle Columns');
|
||||
|
||||
const checkbox = document.getElementById('col-createdAt');
|
||||
expect(checkbox).not.toBeNull();
|
||||
fireEvent.click(checkbox as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
const stored = window.localStorage.getItem(COLUMN_STORAGE_KEY);
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored as string);
|
||||
expect(parsed.hiddenColumnIds).not.toContain('createdAt');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const headersAfter = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headersAfter.some((t) => t.includes('Created At'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { fireEvent, screen } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — empty states', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders AlertsEmptyState when API returns no rules', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('No Alert rules yet.');
|
||||
expect(
|
||||
screen.getByText('Create an Alert Rule to get started'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// New Alert Rule button is visible and triggers safeNavigate to ALERTS_NEW.
|
||||
fireEvent.click(screen.getByTestId('add-alert'));
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders ErrorEmptyState when API returns 500; refresh triggers a refetch', async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesFixture, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByTestId('error-empty-state');
|
||||
|
||||
fireEvent.click(screen.getByTestId('error-refresh-button'));
|
||||
|
||||
const rule = await screen.findByText('High CPU Alert');
|
||||
expect(rule).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.change(screen.getByTestId('list-alerts-search-input'), {
|
||||
target: { value: 'totally-not-found' },
|
||||
});
|
||||
|
||||
await screen.findByTestId('no-results-empty-state');
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alert rules',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No alert rules match your search. Try adjusting your search criteria.',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
const rule = await screen.findByText('High CPU Alert');
|
||||
expect(rule).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — list rendering', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders alert rules from API', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-row-rule-1-name'),
|
||||
).resolves.toHaveTextContent('High CPU Alert');
|
||||
expect(screen.getByTestId('alert-row-rule-2-name')).toHaveTextContent(
|
||||
'Memory Pending Alert',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-name')).toHaveTextContent(
|
||||
'Healthy Alert',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-name')).toHaveTextContent(
|
||||
'Disabled Alert',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders state badges via STATE_CONFIG mapping', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveTextContent(
|
||||
'Firing',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveTextContent(
|
||||
'Pending',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveTextContent('OK');
|
||||
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveTextContent(
|
||||
'Disabled',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-5-state')).toHaveTextContent('OK');
|
||||
});
|
||||
|
||||
it('renders state badges with semantic colors', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'forest',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'vanilla',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders severity badges for rules with severity', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveTextContent(
|
||||
'critical',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveTextContent(
|
||||
'warning',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-severity')).toHaveTextContent(
|
||||
'info',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-severity')).toHaveTextContent(
|
||||
'critical',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-5-severity')).toHaveTextContent(
|
||||
'-',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders header controls (search, columns, new alert)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { fireEvent, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — new alert button', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('plain click navigates to ALERTS_NEW with newTab:false', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /new alert/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs Alert: New alert button clicked', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /new alert/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: New alert button clicked',
|
||||
expect.objectContaining({ layout: 'new' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('ctrl+click on New Alert opens in a new tab (newTab:true)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /new alert/i }), {
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { fireEvent, screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — pagination', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows first 10 rows on page 1 (default limit)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows total count when showTotalCount is enabled', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
const totalCount = await screen.findByTestId('pagination-total-count');
|
||||
expect(totalCount.textContent).toContain('Showing');
|
||||
expect(totalCount.textContent).toContain('of 15');
|
||||
});
|
||||
|
||||
it('navigates to page 2 and shows remaining rows', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
const nextBtn = screen.getByLabelText('Go to next page');
|
||||
fireEvent.click(nextBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pag Rule 14')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsQueryString()).toContain('page=2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — permissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('VIEWER role hides "New Alert" button and "Actions" column', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.VIEWER });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('list-alerts-new-alert-button'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /new alert/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(false);
|
||||
expect(screen.queryByTestId('alert-actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ADMIN role shows "New Alert" button and "Actions" column', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.ADMIN });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
|
||||
});
|
||||
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('EDITOR role behaves like ADMIN (New Alert + Actions visible)', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.EDITOR });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
|
||||
});
|
||||
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { fireEvent, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — row click navigation', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
const ruleCell = await screen.findByText('High CPU Alert');
|
||||
|
||||
const td = ruleCell.closest('td');
|
||||
expect(td).not.toBeNull();
|
||||
fireEvent.click(td as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('/alerts/overview?');
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(url).toContain('panelTypes=graph');
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('ctrl+click on a row navigates with newTab option', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
const ruleCell = await screen.findByText('High CPU Alert');
|
||||
|
||||
const td = ruleCell.closest('td');
|
||||
fireEvent.click(td as HTMLElement, { ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url, options] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { fireEvent, screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
function getSearchInput(): HTMLInputElement {
|
||||
return screen.getByTestId('list-alerts-search-input') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('ListAlertRules — search', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('filters rows by alert name with debounce', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.change(getSearchInput(), { target: { value: 'CPU' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters rows by label values (severity)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.change(getSearchInput(), { target: { value: 'warning' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('restores all rows when search is cleared', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.change(getSearchInput(), { target: { value: 'CPU' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(getSearchInput(), { target: { value: '' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Healthy Alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no-results state when no match', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
fireEvent.change(getSearchInput(), {
|
||||
target: { value: 'zzzzzz-no-match' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alert rules',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets page to 1 when search debounce fires', async () => {
|
||||
renderListAlertRules({ initialRoute: '/?page=2' });
|
||||
|
||||
// Page 2 of the 4-rule fixture has no rows; we only need the search input
|
||||
// to be mounted, which happens before data is fetched.
|
||||
const input = await screen.findByTestId('list-alerts-search-input');
|
||||
fireEvent.change(input, { target: { value: 'CPU' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertRule } from '../types';
|
||||
import {
|
||||
ALERT_ACTIONS,
|
||||
alertActionLogEvent,
|
||||
filterRulesByFilters,
|
||||
getAlertSortValue,
|
||||
sortRules,
|
||||
} from '../utils';
|
||||
|
||||
const baseRule = {
|
||||
id: 'r1',
|
||||
alert: 'Rule 1',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
state: 'inactive',
|
||||
labels: { severity: 'info' },
|
||||
condition: {},
|
||||
createdAt: '2023-10-15T10:00:00Z',
|
||||
updatedAt: '2023-10-19T10:00:00Z',
|
||||
} as unknown as AlertRule;
|
||||
|
||||
const makeRule = (overrides: Partial<AlertRule>): AlertRule => ({
|
||||
...baseRule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getAlertSortValue', () => {
|
||||
it('returns state for "state"', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: RuletypesAlertStateDTO.firing }),
|
||||
'state',
|
||||
),
|
||||
).toBe('firing');
|
||||
});
|
||||
|
||||
it('returns alert name for "name"', () => {
|
||||
expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe(
|
||||
'My Rule',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns severity label for "severity"', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ labels: { severity: 'critical' } }),
|
||||
'severity',
|
||||
),
|
||||
).toBe('critical');
|
||||
});
|
||||
|
||||
it('returns createdAt as ms', () => {
|
||||
const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' });
|
||||
const result = getAlertSortValue(rule, 'createdAt');
|
||||
expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('returns updatedAt as ms', () => {
|
||||
const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' });
|
||||
const result = getAlertSortValue(rule, 'updatedAt');
|
||||
expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('returns 0 when createdAt missing', () => {
|
||||
expect(
|
||||
getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty for unknown column', () => {
|
||||
expect(getAlertSortValue(baseRule, 'xxx')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for missing fields', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: undefined, labels: undefined }),
|
||||
'state',
|
||||
),
|
||||
).toBe('');
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: undefined, labels: undefined }),
|
||||
'severity',
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortRules', () => {
|
||||
const r1 = makeRule({ id: '1', alert: 'A' });
|
||||
const r2 = makeRule({ id: '2', alert: 'B' });
|
||||
const r3 = makeRule({ id: '3', alert: 'C' });
|
||||
|
||||
it('sorts ascending by name', () => {
|
||||
const order: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortRules([r3, r1, r2], order);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('sorts descending by name', () => {
|
||||
const order: SortState = { columnName: 'name', order: 'desc' };
|
||||
const result = sortRules([r1, r2, r3], order);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']);
|
||||
});
|
||||
|
||||
it('returns unsorted when orderBy is null', () => {
|
||||
const result = sortRules([r3, r1, r2], null);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRulesByFilters', () => {
|
||||
const r1 = makeRule({
|
||||
id: '1',
|
||||
alert: 'R1',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const r2 = makeRule({
|
||||
id: '2',
|
||||
alert: 'R2',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const r3 = makeRule({
|
||||
id: '3',
|
||||
alert: 'R3',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const rules = [r1, r2, r3];
|
||||
|
||||
it('returns input when filters empty', () => {
|
||||
expect(filterRulesByFilters(rules, [])).toStrictEqual(rules);
|
||||
});
|
||||
|
||||
it('filters by state', () => {
|
||||
const result = filterRulesByFilters(rules, ['state:firing']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('filters by severity', () => {
|
||||
const result = filterRulesByFilters(rules, ['severity:warning']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['2', '3']);
|
||||
});
|
||||
|
||||
it('combines state AND severity', () => {
|
||||
const result = filterRulesByFilters(rules, [
|
||||
'state:firing',
|
||||
'severity:warning',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['3']);
|
||||
});
|
||||
|
||||
it('OR within same key (state)', () => {
|
||||
const result = filterRulesByFilters(rules, [
|
||||
'state:firing',
|
||||
'state:inactive',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('matches values case-insensitively', () => {
|
||||
const result = filterRulesByFilters(rules, ['state:FIRING']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('ignores prefixes with wrong case (state: is required lowercase)', () => {
|
||||
const result = filterRulesByFilters(rules, ['STATE:FIRING']);
|
||||
expect(result).toStrictEqual(rules);
|
||||
});
|
||||
|
||||
it('returns empty when no rule matches', () => {
|
||||
expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores unknown prefix', () => {
|
||||
expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertActionLogEvent', () => {
|
||||
it('logs with mapped action label', () => {
|
||||
const rule = makeRule({
|
||||
id: 'rule-1',
|
||||
alert: 'My Rule',
|
||||
alertType: 'METRIC_BASED_ALERT' as AlertRule['alertType'],
|
||||
});
|
||||
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
|
||||
expect(logEventMock).toHaveBeenCalledWith('Alert: Action', {
|
||||
ruleId: 'rule-1',
|
||||
dataSource: expect.any(String),
|
||||
name: 'My Rule',
|
||||
action: 'Edit',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to raw action when unmapped', () => {
|
||||
alertActionLogEvent('custom', baseRule);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'custom' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps TOGGLE action', () => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.TOGGLE, baseRule);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps DELETE and CLONE', () => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.DELETE, baseRule);
|
||||
alertActionLogEvent(ALERT_ACTIONS.CLONE, baseRule);
|
||||
expect(logEventMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Delete' }),
|
||||
);
|
||||
expect(logEventMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Clone' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
frontend/src/container/ListAlertRules/__tests__/_helpers.tsx
Normal file
65
frontend/src/container/ListAlertRules/__tests__/_helpers.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import ListAlertRules from 'container/ListAlertRules';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
|
||||
interface RenderOptions {
|
||||
role?: string;
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
export function renderListAlertRules(
|
||||
options: RenderOptions = {},
|
||||
): RenderResult {
|
||||
const { role = 'ADMIN', initialRoute = '/' } = options;
|
||||
|
||||
const initialSearch = initialRoute.includes('?')
|
||||
? initialRoute.slice(initialRoute.indexOf('?'))
|
||||
: '';
|
||||
resetNuqsState(initialSearch);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={initialSearch}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={getAppContextMock(role)}>
|
||||
<TimezoneProvider>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 46 }}
|
||||
>
|
||||
<ListAlertRules />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
|
||||
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
|
||||
const row = cell.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`Row not found for alert "${alertName}"`);
|
||||
}
|
||||
return row as HTMLElement;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ function ColumnSelector<TData>({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
data-testid="alert-columns-button"
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
|
||||
@@ -136,6 +136,7 @@ function ListAlertRules(): JSX.Element {
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleNewAlert}
|
||||
color="primary"
|
||||
testId="list-alerts-new-alert-button"
|
||||
>
|
||||
New Alert
|
||||
</Button>
|
||||
@@ -157,6 +158,7 @@ function ListAlertRules(): JSX.Element {
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="list-alerts-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,14 +26,18 @@ export function getAlertRuleColumns(
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
cell: ({ row, value }): JSX.Element => {
|
||||
const state = String(value ?? '').toLowerCase();
|
||||
const config = STATE_CONFIG[state] ?? {
|
||||
color: 'secondary' as BadgeColor,
|
||||
label: 'Unknown',
|
||||
};
|
||||
return (
|
||||
<Badge color={config.color} variant="outline">
|
||||
<Badge
|
||||
color={config.color}
|
||||
variant="outline"
|
||||
testId={`alert-row-${row.id ?? ''}-state`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -47,8 +51,11 @@ export function getAlertRuleColumns(
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={value}>
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<TanStackTable.Text
|
||||
title={value}
|
||||
data-testid={`alert-row-${row.id ?? ''}-name`}
|
||||
>
|
||||
{String(value ?? '-')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
@@ -60,15 +67,20 @@ export function getAlertRuleColumns(
|
||||
width: { fixed: '120px' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
cell: ({ row, value }): JSX.Element => {
|
||||
const severity = String(value ?? '').toLowerCase();
|
||||
if (!severity) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
return (
|
||||
<TanStackTable.Text data-testid={`alert-row-${row.id ?? ''}-severity`}>
|
||||
-
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
|
||||
variant="outline"
|
||||
testId={`alert-row-${row.id ?? ''}-severity`}
|
||||
>
|
||||
{severity}
|
||||
</Badge>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import MCPServerSettings from './MCPServerSettings';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockHistoryPush = jest.fn();
|
||||
const mockUseGetGlobalConfig = jest.fn();
|
||||
@@ -11,11 +11,6 @@ const mockUseGetTenantLicense = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
const mockToastWarning = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/global', () => ({
|
||||
useGetGlobalConfig: (...args: unknown[]): unknown =>
|
||||
mockUseGetGlobalConfig(...args),
|
||||
@@ -148,7 +143,7 @@ describe('MCPServerSettings', () => {
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('MCP Settings: Page viewed', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('MCP Settings: Page viewed', {
|
||||
role: 'ADMIN',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MySettingsContainer from 'container/MySettings';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
@@ -12,7 +13,6 @@ import APIError from 'types/api/error';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
const toggleThemeFunction = jest.fn();
|
||||
const logEventFunction = jest.fn();
|
||||
const copyToClipboardFn = jest.fn();
|
||||
const editUserFn = jest.fn();
|
||||
const updateMyPasswordFn = jest.fn();
|
||||
@@ -62,11 +62,6 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((eventName, data) => logEventFunction(eventName, data)),
|
||||
}));
|
||||
|
||||
const errorNotification = jest.fn();
|
||||
const successNotification = jest.fn();
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
@@ -135,7 +130,7 @@ describe('MySettings Flows', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggleThemeFunction).toHaveBeenCalled();
|
||||
expect(logEventFunction).toHaveBeenCalledWith(
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Account Settings: Theme Changed',
|
||||
{
|
||||
theme: 'light',
|
||||
|
||||
@@ -9,11 +9,6 @@ import {
|
||||
|
||||
import InviteTeamMembers from '../InviteTeamMembers';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
|
||||
@@ -4,11 +4,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import OnboardingQuestionaire from '../index';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
||||
@@ -4,15 +4,13 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import i18n from 'ReactI18';
|
||||
import store from 'store';
|
||||
|
||||
import CreatePipelineButton from '../Layouts/Pipeline/CreatePipelineButton';
|
||||
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render CreatePipelineButton section', async () => {
|
||||
const { asFragment } = render(
|
||||
@@ -53,9 +51,12 @@ describe('PipelinePage container test', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Logs: Pipelines: Entered Edit Mode', {
|
||||
source: 'signoz-ui',
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Logs: Pipelines: Entered Edit Mode',
|
||||
{
|
||||
source: 'signoz-ui',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('CreatePipelineButton - add new mode & tracking', async () => {
|
||||
@@ -78,7 +79,7 @@ describe('PipelinePage container test', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Logs: Pipelines: Clicked Add New Pipeline',
|
||||
{
|
||||
source: 'signoz-ui',
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { triggeredAlertsFixture } from 'mocks-server/__mockdata__/triggered_alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — empty / error states', () => {
|
||||
it('shows the "No alerts firing" empty state when the API returns []', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('No alerts firing');
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-empty-create-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-empty-refresh-button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to ROUTES.ALERTS_NEW when "Create Alert Rule" is clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('No alerts firing');
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-empty-create-button'));
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows ErrorEmptyState when the API returns 500', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(500)),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByTestId('error-empty-state');
|
||||
expect(screen.getByTestId('error-refresh-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refetches on refresh button click after an initial error', async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(500));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: triggeredAlertsFixture, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByTestId('error-refresh-button');
|
||||
|
||||
await user.click(screen.getByTestId('error-refresh-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows NoResultsEmptyState when filters yield zero matches', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'this-matches-nothing-xyz');
|
||||
|
||||
await screen.findByTestId('no-results-empty-state');
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alerts',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No alerts match your current filters. Try adjusting your search criteria.',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
(screen.getByTestId('triggered-alerts-search-input') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — severity filter', () => {
|
||||
it('filters to only critical-severity rows when "Critical" is selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
|
||||
const criticalOption = await screen.findByText(
|
||||
'Critical (severity:critical)',
|
||||
);
|
||||
await user.click(criticalOption);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows union when multiple severities are selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const critical = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(critical);
|
||||
|
||||
const warning = await screen.findByText('Warning (severity:warning)');
|
||||
await user.click(warning);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clearing the filter shows all rows again', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const critical = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(critical);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Reopen the filter combobox and deselect Critical (clicking again toggles).
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const criticalAgain = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(criticalAgain);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network Hiccup')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — group by', () => {
|
||||
it('renders a flat table when no group-by is selected', async () => {
|
||||
renderTriggeredAlerts();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
// No "Group" column header in flat mode.
|
||||
expect(screen.queryByText('Group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups by service when "service" is selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-groupby-combobox'));
|
||||
|
||||
const serviceOption = await screen.findByText('service');
|
||||
await user.click(serviceOption);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Group')).toBeInTheDocument());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service:frontend')).toBeInTheDocument();
|
||||
expect(screen.getByText('service:backend')).toBeInTheDocument();
|
||||
expect(screen.getByText('service:misc')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('expands and collapses a group row to reveal nested alerts', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-groupby-combobox'));
|
||||
const serviceOption = await screen.findByText('service');
|
||||
await user.click(serviceOption);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('service:frontend')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Nested rows aren't shown yet.
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
|
||||
// The "frontend" group sits first in the table; its expand toggle is the
|
||||
// first `group-expand-toggle` in DOM order. Targeting by testid is safe
|
||||
// against design changes that add other buttons to the row.
|
||||
const expandToggles = screen.getAllByTestId('group-expand-toggle');
|
||||
expect(expandToggles.length).toBeGreaterThan(0);
|
||||
const frontendGroupBadge = screen.getByText('service:frontend');
|
||||
const frontendRow = frontendGroupBadge.closest('tr');
|
||||
expect(frontendRow).not.toBeNull();
|
||||
const frontendToggle = (frontendRow as HTMLElement).querySelector(
|
||||
'[data-testid="group-expand-toggle"]',
|
||||
) as HTMLElement | null;
|
||||
expect(frontendToggle).not.toBeNull();
|
||||
|
||||
await user.click(frontendToggle as HTMLElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
|
||||
await user.click(frontendToggle as HTMLElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — list rendering', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders alerts from the API', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-row-fp-critical-1-name'),
|
||||
).resolves.toHaveTextContent('High CPU Usage');
|
||||
expect(screen.getByTestId('alert-row-fp-warning-1-name')).toHaveTextContent(
|
||||
'Memory Warning',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-name')).toHaveTextContent(
|
||||
'Disk Slow',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders severity badges for alerts that have severity', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-severity'),
|
||||
).toHaveTextContent('critical');
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-warning-1-severity'),
|
||||
).toHaveTextContent('warning');
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-severity')).toHaveTextContent(
|
||||
'info',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-noseverity-severity'),
|
||||
).toHaveTextContent('-');
|
||||
});
|
||||
|
||||
it('renders status tags reflecting the alert state', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-status'),
|
||||
).toHaveTextContent('Firing');
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveTextContent(
|
||||
'Unprocessed',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-suppressed-1-status'),
|
||||
).toHaveTextContent('Suppressed');
|
||||
});
|
||||
|
||||
it('renders status badges with semantic colors', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-status'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-fp-critical-1-status')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveAttribute(
|
||||
'data-color',
|
||||
'forest',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-severity'),
|
||||
).toHaveAttribute('data-color', 'cherry');
|
||||
expect(screen.getByTestId('alert-row-fp-warning-1-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the search input and filter comboboxes', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-search-input'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search alerts by name'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-filter-combobox'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-groupby-combobox'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { triggeredAlertsPaginationFixture } from 'mocks-server/__mockdata__/triggered_alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
function usePaginationHandler(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: triggeredAlertsPaginationFixture,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('TriggeredAlerts — pagination', () => {
|
||||
// Default sort is duration ascending = newest startsAt first. Fixture indices
|
||||
// 0..14 use startsAt 2023-10-01..15, so index 14 (newest) appears first and
|
||||
// index 0 (oldest) appears last. Page 1 (limit 10) = items 14..5. Page 2 = 4..0.
|
||||
|
||||
it('shows the first 10 rows on page 1 by default', async () => {
|
||||
usePaginationHandler();
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
expect(screen.getByText('Pag Alert 5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 4')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a "page 2" pagination button reflecting total=15 with pageSize=10', async () => {
|
||||
usePaginationHandler();
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2 = Array.from(nav.querySelectorAll('button')).find(
|
||||
(b) => b.textContent?.trim() === '2',
|
||||
);
|
||||
expect(page2).toBeDefined();
|
||||
});
|
||||
|
||||
it('navigates to page 2 and shows the next batch of alerts', async () => {
|
||||
usePaginationHandler();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2Button = Array.from(nav.querySelectorAll('button')).find(
|
||||
(b) => b.textContent?.trim() === '2',
|
||||
);
|
||||
if (!page2Button) {
|
||||
throw new Error('Page 2 button not found');
|
||||
}
|
||||
await user.click(page2Button);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Pag Alert 0')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Pag Alert 4')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 14')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(getCurrentNuqsQueryString()).toContain('page=2'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { fireEvent, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { getTriggeredAlertRowTestId, renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — row click', () => {
|
||||
it('navigates to the alert overview with the rule id from labels on row click', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-1',
|
||||
);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Triggered alert clicked',
|
||||
expect.objectContaining({
|
||||
ruleId: 'rule-1',
|
||||
alertName: 'High CPU Usage',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens in a new tab when ctrl+clicked', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')),
|
||||
{ ctrlKey: true },
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-2',
|
||||
{ newTab: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates correctly when ruleId is parsed from generatorURL', async () => {
|
||||
// Override fixture so the alert has no labels.ruleId but a valid
|
||||
// generatorURL → getRuleId() falls back to parsing the URL.
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: [
|
||||
{
|
||||
fingerprint: 'fp-no-rule-label',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
updatedAt: '2023-10-20T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-from-url',
|
||||
labels: { alertname: 'URL Rule Alert' },
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-from-url',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not navigate when the row has no ruleId anywhere', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: [
|
||||
{
|
||||
fingerprint: 'fp-no-rule',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
updatedAt: '2023-10-20T00:00:00Z',
|
||||
labels: { alertname: 'No Rule Alert' },
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — search', () => {
|
||||
it('filters rows by alertname when typing in the search input', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all rows again when search is cleared', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.clear(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('matches rows by label value (case-insensitive)', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
// "backend" matches `service: backend` labels on Memory Warning and Network Hiccup.
|
||||
await user.type(input, 'backend');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network Hiccup')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('matches rows by label key', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'staging');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the no-results empty state when the search matches nothing', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'zzzzznever-matches-anything');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets pagination to page 1 when the search changes', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts({ initialRoute: '/?page=2&limit=2' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { Alert } from '../types';
|
||||
import {
|
||||
getAlertSortValue,
|
||||
getRuleId,
|
||||
normalizeAlerts,
|
||||
sortAlerts,
|
||||
} from '../utils';
|
||||
|
||||
const alertWithFingerprint: Alert = {
|
||||
fingerprint: 'fp-existing',
|
||||
labels: { alertname: 'Test', severity: 'critical' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
const alertWithoutFingerprint: Alert = {
|
||||
...alertWithFingerprint,
|
||||
fingerprint: undefined,
|
||||
};
|
||||
|
||||
describe('normalizeAlerts', () => {
|
||||
it('returns empty array when given undefined', () => {
|
||||
expect(normalizeAlerts(undefined)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing fingerprints', () => {
|
||||
const result = normalizeAlerts([alertWithFingerprint]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].fingerprint).toBe('fp-existing');
|
||||
});
|
||||
|
||||
it('mints fingerprint when missing', () => {
|
||||
const result = normalizeAlerts([alertWithoutFingerprint]);
|
||||
expect(result[0].fingerprint).toBeDefined();
|
||||
expect(typeof result[0].fingerprint).toBe('string');
|
||||
expect(result[0].fingerprint?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [alertWithFingerprint];
|
||||
const copy = JSON.parse(JSON.stringify(input));
|
||||
normalizeAlerts(input);
|
||||
expect(input).toStrictEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertSortValue', () => {
|
||||
const alert: Alert = {
|
||||
fingerprint: 'fp',
|
||||
labels: { alertname: 'CPU', severity: 'critical' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
it('returns status.state for "status"', () => {
|
||||
expect(getAlertSortValue(alert, 'status')).toBe('active');
|
||||
});
|
||||
|
||||
it('returns alertname for "alertName"', () => {
|
||||
expect(getAlertSortValue(alert, 'alertName')).toBe('CPU');
|
||||
});
|
||||
|
||||
it('returns severity for "severity"', () => {
|
||||
expect(getAlertSortValue(alert, 'severity')).toBe('critical');
|
||||
});
|
||||
|
||||
it('returns elapsed ms for "firingSince"', () => {
|
||||
const result = getAlertSortValue(alert, 'firingSince');
|
||||
expect(typeof result).toBe('number');
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns elapsed ms for "duration"', () => {
|
||||
const result = getAlertSortValue(alert, 'duration');
|
||||
expect(typeof result).toBe('number');
|
||||
});
|
||||
|
||||
it('returns empty string for unknown columns', () => {
|
||||
expect(getAlertSortValue(alert, 'unknown')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for missing fields', () => {
|
||||
const empty = { ...alert, status: undefined, labels: undefined };
|
||||
expect(getAlertSortValue(empty, 'status')).toBe('');
|
||||
expect(getAlertSortValue(empty, 'alertName')).toBe('');
|
||||
expect(getAlertSortValue(empty, 'severity')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for firingSince with no startsAt', () => {
|
||||
const empty = { ...alert, startsAt: undefined };
|
||||
expect(getAlertSortValue(empty, 'firingSince')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortAlerts', () => {
|
||||
const a: Alert = {
|
||||
fingerprint: 'a',
|
||||
labels: { alertname: 'A' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
const b: Alert = { ...a, fingerprint: 'b', labels: { alertname: 'B' } };
|
||||
const c: Alert = { ...a, fingerprint: 'c', labels: { alertname: 'C' } };
|
||||
|
||||
it('sorts ascending when given orderBy', () => {
|
||||
const order: SortState = { columnName: 'alertName', order: 'asc' };
|
||||
const result = sortAlerts([c, a, b], order);
|
||||
expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('sorts descending', () => {
|
||||
const order: SortState = { columnName: 'alertName', order: 'desc' };
|
||||
const result = sortAlerts([a, b, c], order);
|
||||
expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['C', 'B', 'A']);
|
||||
});
|
||||
|
||||
it('falls back to default duration asc when orderBy is null', () => {
|
||||
const result = sortAlerts([a, b, c], null);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuleId', () => {
|
||||
const base: Alert = {
|
||||
labels: {},
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
fingerprint: 'fp',
|
||||
};
|
||||
|
||||
it('returns labels.ruleId when present', () => {
|
||||
expect(getRuleId({ ...base, labels: { ruleId: 'rule-1' } })).toBe('rule-1');
|
||||
});
|
||||
|
||||
it('falls back to generatorURL when ruleId label missing', () => {
|
||||
expect(
|
||||
getRuleId({
|
||||
...base,
|
||||
generatorURL: 'http://localhost/foo?ruleId=rule-42',
|
||||
}),
|
||||
).toBe('rule-42');
|
||||
});
|
||||
|
||||
it('prefers labels.ruleId over generatorURL', () => {
|
||||
expect(
|
||||
getRuleId({
|
||||
...base,
|
||||
labels: { ruleId: 'from-label' },
|
||||
generatorURL: 'http://localhost/foo?ruleId=from-url',
|
||||
}),
|
||||
).toBe('from-label');
|
||||
});
|
||||
|
||||
it('returns null when generatorURL has no ruleId param', () => {
|
||||
expect(
|
||||
getRuleId({ ...base, generatorURL: 'http://localhost/foo' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when generatorURL is invalid', () => {
|
||||
expect(getRuleId({ ...base, generatorURL: 'not-a-url' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no source available', () => {
|
||||
expect(getRuleId(base)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import TriggeredAlerts from 'container/TriggeredAlerts';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
|
||||
interface RenderOptions {
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
export function renderTriggeredAlerts(
|
||||
options: RenderOptions = {},
|
||||
): RenderResult {
|
||||
const { initialRoute = '/' } = options;
|
||||
|
||||
const initialSearch = initialRoute.includes('?')
|
||||
? initialRoute.slice(initialRoute.indexOf('?'))
|
||||
: '';
|
||||
resetNuqsState(initialSearch);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={initialSearch}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={getAppContextMock('ADMIN')}>
|
||||
<TimezoneProvider>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 46 }}
|
||||
>
|
||||
<TriggeredAlerts />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
|
||||
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
|
||||
const row = cell.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`Row not found for alert "${alertName}"`);
|
||||
}
|
||||
return row as HTMLElement;
|
||||
}
|
||||
|
||||
export function getTriggeredAlertRowTestId(
|
||||
fingerprint: string,
|
||||
column: 'name' | 'severity' | 'status',
|
||||
): string {
|
||||
return `alert-row-${fingerprint}-${column}`;
|
||||
}
|
||||
@@ -2,31 +2,32 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface AlertStatusTagProps {
|
||||
state: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertStatusTag({ state }: AlertStatusTagProps): JSX.Element {
|
||||
function AlertStatusTag({ state, testId }: AlertStatusTagProps): JSX.Element {
|
||||
switch (state) {
|
||||
case 'unprocessed':
|
||||
return (
|
||||
<Badge color="success" variant="outline">
|
||||
<Badge color="success" variant="outline" testId={testId}>
|
||||
Unprocessed
|
||||
</Badge>
|
||||
);
|
||||
case 'active':
|
||||
return (
|
||||
<Badge color="error" variant="outline">
|
||||
<Badge color="error" variant="outline" testId={testId}>
|
||||
Firing
|
||||
</Badge>
|
||||
);
|
||||
case 'suppressed':
|
||||
return (
|
||||
<Badge color="error" variant="outline">
|
||||
<Badge color="error" variant="outline" testId={testId}>
|
||||
Suppressed
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge color="secondary" variant="outline">
|
||||
<Badge color="secondary" variant="outline" testId={testId}>
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CircleCheck, Plus, RefreshCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import styles from './EmptyStates.module.scss';
|
||||
|
||||
@@ -13,9 +14,12 @@ interface EmptyStateProps {
|
||||
export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleCreateAlert = useCallback((): void => {
|
||||
safeNavigate(ROUTES.ALERTS_NEW);
|
||||
}, [safeNavigate]);
|
||||
const handleCreateAlert = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
safeNavigate(ROUTES.ALERTS_NEW, { newTab: isModifierKeyPressed(e) });
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
@@ -30,6 +34,7 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleCreateAlert}
|
||||
testId="triggered-alerts-empty-create-button"
|
||||
>
|
||||
Create Alert Rule
|
||||
</Button>
|
||||
@@ -39,6 +44,7 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
testId="triggered-alerts-empty-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function GroupTagsCell({
|
||||
prefix={
|
||||
localIsExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
testId="group-expand-toggle"
|
||||
/>
|
||||
<div className={styles.tagsContainer}>
|
||||
{tags.map((tag) => (
|
||||
|
||||
@@ -175,6 +175,7 @@ function TriggeredAlerts(): JSX.Element {
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="triggered-alerts-search-input"
|
||||
/>
|
||||
<ComboboxSimple
|
||||
className={styles.filterSelect}
|
||||
@@ -186,6 +187,7 @@ function TriggeredAlerts(): JSX.Element {
|
||||
allowCreate
|
||||
items={severyFilters}
|
||||
maxDisplayedPills={2}
|
||||
testId="triggered-alerts-filter-combobox"
|
||||
/>
|
||||
<ComboboxSimple
|
||||
className={styles.filterSelect}
|
||||
@@ -196,6 +198,7 @@ function TriggeredAlerts(): JSX.Element {
|
||||
items={labelOptions}
|
||||
multiple
|
||||
maxDisplayedPills={2}
|
||||
testId="triggered-alerts-groupby-combobox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,8 +21,11 @@ export function getAlertColumns(
|
||||
width: { fixed: '100px' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<AlertStatusTag state={String(value ?? '')} />
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<AlertStatusTag
|
||||
state={String(value ?? '')}
|
||||
testId={`alert-row-${row.fingerprint ?? ''}-status`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -32,8 +35,11 @@ export function getAlertColumns(
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={value}>
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<TanStackTable.Text
|
||||
title={value}
|
||||
data-testid={`alert-row-${row.fingerprint ?? ''}-name`}
|
||||
>
|
||||
{String(value ?? '-')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
@@ -45,15 +51,17 @@ export function getAlertColumns(
|
||||
width: { fixed: '120px' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
cell: ({ row, value }): JSX.Element => {
|
||||
const severity = String(value ?? '').toLowerCase();
|
||||
const testId = `alert-row-${row.fingerprint ?? ''}-severity`;
|
||||
if (!severity) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
return <TanStackTable.Text data-testid={testId}>-</TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
|
||||
variant="outline"
|
||||
testId={testId}
|
||||
>
|
||||
{severity}
|
||||
</Badge>
|
||||
|
||||
@@ -17,11 +17,6 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/dashboard/substitute_vars', () => ({
|
||||
getSubstituteVars: jest.fn(),
|
||||
}));
|
||||
|
||||
85
frontend/src/mocks-server/__mockdata__/alert_rules.ts
Normal file
85
frontend/src/mocks-server/__mockdata__/alert_rules.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
RuletypesRuleConditionDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const baseCondition: RuletypesRuleConditionDTO = {
|
||||
compositeQuery: {
|
||||
queryType: RuletypesQueryTypeDTO.builder,
|
||||
panelType: RuletypesPanelTypeDTO.graph,
|
||||
queries: null,
|
||||
},
|
||||
} as unknown as RuletypesRuleConditionDTO;
|
||||
|
||||
const make = (
|
||||
id: string,
|
||||
overrides: Partial<RuletypesRuleDTO>,
|
||||
): RuletypesRuleDTO => ({
|
||||
id,
|
||||
alert: `Alert ${id}`,
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
condition: baseCondition,
|
||||
createdAt: '2023-10-15T10:00:00Z',
|
||||
updatedAt: '2023-10-19T10:00:00Z',
|
||||
createdBy: 'alice@signoz.io',
|
||||
updatedBy: 'alice@signoz.io',
|
||||
disabled: false,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info' },
|
||||
annotations: {},
|
||||
source: '',
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m0s',
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const alertRulesFixture: RuletypesRuleDTO[] = [
|
||||
make('rule-1', {
|
||||
alert: 'High CPU Alert',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'critical', team: 'infra' },
|
||||
}),
|
||||
make('rule-2', {
|
||||
alert: 'Memory Pending Alert',
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
labels: { severity: 'warning', team: 'backend' },
|
||||
}),
|
||||
make('rule-3', {
|
||||
alert: 'Healthy Alert',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info', team: 'infra' },
|
||||
}),
|
||||
make('rule-4', {
|
||||
alert: 'Disabled Alert',
|
||||
state: RuletypesAlertStateDTO.disabled,
|
||||
disabled: true,
|
||||
labels: { severity: 'critical', team: 'frontend' },
|
||||
}),
|
||||
make('rule-5', {
|
||||
alert: 'No Labels Alert',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: {},
|
||||
}),
|
||||
];
|
||||
|
||||
export const alertRulesPaginationFixture: RuletypesRuleDTO[] = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) =>
|
||||
make(`rule-pag-${i}`, {
|
||||
alert: `Pag Rule ${i}`,
|
||||
state:
|
||||
i % 2 === 0
|
||||
? RuletypesAlertStateDTO.firing
|
||||
: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: i % 2 === 0 ? 'critical' : 'warning' },
|
||||
createdAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||
}),
|
||||
);
|
||||
102
frontend/src/mocks-server/__mockdata__/triggered_alerts.ts
Normal file
102
frontend/src/mocks-server/__mockdata__/triggered_alerts.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { AlertmanagertypesDeprecatedGettableAlertDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const triggeredAlertsFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] =
|
||||
[
|
||||
{
|
||||
fingerprint: 'fp-critical-1',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-1&panelTypes=graph',
|
||||
labels: {
|
||||
alertname: 'High CPU Usage',
|
||||
severity: 'critical',
|
||||
ruleId: 'rule-1',
|
||||
service: 'frontend',
|
||||
env: 'prod',
|
||||
},
|
||||
annotations: {
|
||||
summary: 'CPU above 90%',
|
||||
description: 'Frontend CPU usage critical',
|
||||
},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['slack'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-warning-1',
|
||||
startsAt: '2023-10-19T09:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-2',
|
||||
labels: {
|
||||
alertname: 'Memory Warning',
|
||||
severity: 'warning',
|
||||
ruleId: 'rule-2',
|
||||
service: 'backend',
|
||||
env: 'prod',
|
||||
},
|
||||
annotations: { summary: 'Memory high' },
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['slack'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-info-1',
|
||||
startsAt: '2023-10-19T08:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-3',
|
||||
labels: {
|
||||
alertname: 'Disk Slow',
|
||||
severity: 'info',
|
||||
ruleId: 'rule-3',
|
||||
service: 'frontend',
|
||||
env: 'staging',
|
||||
},
|
||||
annotations: { summary: 'Disk slow' },
|
||||
status: { state: 'unprocessed', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['email'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-suppressed-1',
|
||||
startsAt: '2023-10-19T07:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-4',
|
||||
labels: {
|
||||
alertname: 'Network Hiccup',
|
||||
severity: 'error',
|
||||
ruleId: 'rule-4',
|
||||
service: 'backend',
|
||||
env: 'dev',
|
||||
},
|
||||
annotations: { summary: 'Network errors' },
|
||||
status: { state: 'suppressed', silencedBy: ['s-1'], inhibitedBy: [] },
|
||||
receivers: ['pagerduty'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-noseverity',
|
||||
startsAt: '2023-10-19T06:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
labels: {
|
||||
alertname: 'Unknown Alert',
|
||||
service: 'misc',
|
||||
},
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
];
|
||||
|
||||
// Bigger fixture for pagination tests (15 entries → 2 pages at limit=10).
|
||||
export const triggeredAlertsPaginationFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] =
|
||||
Array.from({ length: 15 }, (_, i) => ({
|
||||
fingerprint: `fp-pag-${i}`,
|
||||
startsAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: `http://localhost/alerts/edit?ruleId=rule-pag-${i}`,
|
||||
labels: {
|
||||
alertname: `Pag Alert ${i}`,
|
||||
severity: i % 2 === 0 ? 'critical' : 'warning',
|
||||
ruleId: `rule-pag-${i}`,
|
||||
service: 'frontend',
|
||||
},
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
}));
|
||||
@@ -3,6 +3,8 @@ import { rest } from 'msw';
|
||||
import commonEnTranslation from '../../public/locales/en/common.json';
|
||||
import enTranslation from '../../public/locales/en/translation.json';
|
||||
import { allAlertChannels } from './__mockdata__/alerts';
|
||||
import { alertRulesFixture } from './__mockdata__/alert_rules';
|
||||
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
|
||||
import { billingSuccessResponse } from './__mockdata__/billing';
|
||||
import {
|
||||
dashboardSuccessResponse,
|
||||
@@ -236,6 +238,38 @@ export const handlers = [
|
||||
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: triggeredAlertsFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as { alert?: string };
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: {
|
||||
...alertRulesFixture[0],
|
||||
id: 'new-rule-id',
|
||||
alert: body?.alert ?? 'New Rule',
|
||||
},
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.delete('http://localhost/api/v1/channels/:id', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
|
||||
@@ -10,11 +10,6 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
children,
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
push: jest.fn(),
|
||||
listen: jest.fn(() => jest.fn()),
|
||||
|
||||
@@ -14,11 +14,6 @@ jest.mock('AppRoutes/utils', () => ({
|
||||
|
||||
const mockAfterLogin = jest.mocked(afterLogin);
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
||||
41
frontend/src/tests/nuqs-helpers.ts
Normal file
41
frontend/src/tests/nuqs-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Helpers for tests that drive components built with `nuqs`.
|
||||
//
|
||||
// We replace `NuqsAdapter` (which throttles URL writes to `window.history`)
|
||||
// with `NuqsTestingAdapter`. The testing adapter applies URL updates
|
||||
// synchronously (`rateLimitFactor: 0`) and stores state in memory
|
||||
// (`hasMemory: true`) so subsequent reads see the latest value without any
|
||||
// `flushNuqsUrl` sleep. Each test gets a fresh queue because
|
||||
// `resetUrlUpdateQueueOnMount` defaults to `true`.
|
||||
//
|
||||
// Reads on `window.location.search` are no longer authoritative since the
|
||||
// adapter does not push to the browser history. Use
|
||||
// `getCurrentNuqsQueryString()` (or assert on `lastNuqsUrlUpdate`) instead.
|
||||
|
||||
import type { OnUrlUpdateFunction } from 'nuqs/adapters/testing';
|
||||
|
||||
let lastUrlUpdate: { searchParams: URLSearchParams; queryString: string } = {
|
||||
searchParams: new URLSearchParams(),
|
||||
queryString: '',
|
||||
};
|
||||
|
||||
export function resetNuqsState(initialQuery = ''): void {
|
||||
lastUrlUpdate = {
|
||||
searchParams: new URLSearchParams(initialQuery),
|
||||
queryString: initialQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export const onNuqsUrlUpdate: OnUrlUpdateFunction = (event) => {
|
||||
lastUrlUpdate = {
|
||||
searchParams: event.searchParams,
|
||||
queryString: event.queryString,
|
||||
};
|
||||
};
|
||||
|
||||
export function getCurrentNuqsQueryString(): string {
|
||||
return lastUrlUpdate.queryString;
|
||||
}
|
||||
|
||||
export function getCurrentNuqsSearchParams(): URLSearchParams {
|
||||
return lastUrlUpdate.searchParams;
|
||||
}
|
||||
Reference in New Issue
Block a user