mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-29 17:24:16 +00:00
Compare commits
18 Commits
fix/valida
...
chore/trac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fe5178dad | ||
|
|
9c67d36e57 | ||
|
|
197aaae9c5 | ||
|
|
58e91bc31b | ||
|
|
dba3eb732d | ||
|
|
40b9ee48ac | ||
|
|
12abfe5e43 | ||
|
|
373f26098f | ||
|
|
d70be77f40 | ||
|
|
7f230cb44f | ||
|
|
7075375537 | ||
|
|
7046349294 | ||
|
|
e685b5e3ed | ||
|
|
f993773295 | ||
|
|
25ae3c8d27 | ||
|
|
e264e3c576 | ||
|
|
4fdb74a341 | ||
|
|
329c0e7fc6 |
@@ -217,6 +217,7 @@
|
||||
"imagemin": "^8.0.1",
|
||||
"imagemin-svgo": "^10.0.1",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest-canvas-mock": "2.5.2",
|
||||
"jest-styled-components": "^7.0.8",
|
||||
"lint-staged": "^12.5.0",
|
||||
"msw": "1.3.2",
|
||||
|
||||
@@ -22,6 +22,7 @@ function ChangePercentagePill({
|
||||
'change-percentage-pill--positive': isPositive,
|
||||
'change-percentage-pill--negative': !isPositive,
|
||||
})}
|
||||
data-testid="change-percentage-pill"
|
||||
>
|
||||
<div className="change-percentage-pill__icon">
|
||||
{isPositive ? (
|
||||
|
||||
@@ -47,7 +47,7 @@ interface ITraceMetadata {
|
||||
endTime: number;
|
||||
hasMissingSpans: boolean;
|
||||
}
|
||||
interface ISuccessProps {
|
||||
export interface ISuccessProps {
|
||||
spans: Span[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
interestedSpanId: IInterestedSpan;
|
||||
@@ -164,6 +164,7 @@ function SpanOverview({
|
||||
type="text"
|
||||
size="small"
|
||||
className="add-funnel-button__button"
|
||||
data-testid="add-to-funnel-button"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -210,7 +210,11 @@ function useFunnelGraph({
|
||||
const totalSpans = successSpans + errorSpans;
|
||||
|
||||
return (
|
||||
<div key={step} className="funnel-graph__legend-column">
|
||||
<div
|
||||
key={step}
|
||||
className="funnel-graph__legend-column"
|
||||
data-testid="funnel-graph-legend-column"
|
||||
>
|
||||
<div
|
||||
className="legend-item"
|
||||
onMouseEnter={legendHoverHandlers?.onTotalHover}
|
||||
|
||||
7
frontend/src/mocks-server/__mockdata__/trace_funnels.ts
Normal file
7
frontend/src/mocks-server/__mockdata__/trace_funnels.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMockFunnel } from 'pages/TracesFunnels/__tests__/mockFunnelsData';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
export const mockSingleFunnelData: FunnelData = createMockFunnel(
|
||||
'funnel-1',
|
||||
'Checkout Process Funnel',
|
||||
);
|
||||
@@ -1,4 +1,16 @@
|
||||
import {
|
||||
FunnelOverviewPayload,
|
||||
FunnelOverviewResponse,
|
||||
} from 'api/traceFunnels';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
mockErrorTracesData,
|
||||
mockFunnelsListData,
|
||||
mockOverviewData,
|
||||
mockSlowTracesData,
|
||||
mockStepsData,
|
||||
} from 'pages/TracesFunnels/__tests__/mockFunnelsData';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import commonEnTranslation from '../../public/locales/en/common.json';
|
||||
import enTranslation from '../../public/locales/en/translation.json';
|
||||
@@ -15,8 +27,26 @@ import { membersResponse } from './__mockdata__/members';
|
||||
import { queryRangeSuccessResponse } from './__mockdata__/query_range';
|
||||
import { serviceSuccessResponse } from './__mockdata__/services';
|
||||
import { topLevelOperationSuccessResponse } from './__mockdata__/top_level_operations';
|
||||
import { mockSingleFunnelData } from './__mockdata__/trace_funnels';
|
||||
import { traceDetailResponse } from './__mockdata__/tracedetail';
|
||||
|
||||
// Define mock data specifically for step transitions
|
||||
const mockStepTransitionOverviewData: FunnelOverviewResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
data: {
|
||||
avg_duration: 55000000,
|
||||
avg_rate: 8.5,
|
||||
conversion_rate: 92.0,
|
||||
errors: 1,
|
||||
latency: 150000000,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const handlers = [
|
||||
rest.post('http://localhost/api/v3/query_range', (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(queryRangeSuccessResponse)),
|
||||
@@ -263,4 +293,54 @@ export const handlers = [
|
||||
rest.get('http://localhost/locales/en-US/common.json', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(commonEnTranslation)),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/trace-funnels/list', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', payload: mockFunnelsListData }),
|
||||
),
|
||||
),
|
||||
rest.get(
|
||||
'http://localhost/api/v1/trace-funnels/get/:funnelId',
|
||||
(req, res, ctx) => {
|
||||
const { funnelId } = req.params;
|
||||
|
||||
// Ensure the mock data always uses the requested funnelId
|
||||
const responseData: FunnelData = {
|
||||
...mockSingleFunnelData,
|
||||
funnel_id: funnelId as string,
|
||||
};
|
||||
|
||||
return res(ctx.status(200), ctx.json(responseData));
|
||||
},
|
||||
),
|
||||
|
||||
rest.post<FunnelOverviewPayload, { funnelId: string }, FunnelOverviewResponse>(
|
||||
`http://localhost/api/v1/trace-funnels/:funnelId/analytics/overview`,
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json<FunnelOverviewPayload>();
|
||||
|
||||
// Check if step_start and step_end are provided in the payload
|
||||
if (body.step_start !== undefined && body.step_end !== undefined) {
|
||||
// Return mock data for step transition
|
||||
return res(ctx.status(200), ctx.json(mockStepTransitionOverviewData));
|
||||
}
|
||||
// Otherwise, return the default overall mock data
|
||||
return res(ctx.status(200), ctx.json(mockOverviewData));
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
// Use :funnelId to match any funnel ID requested in tests
|
||||
`http://localhost/api/v1/trace-funnels/:funnelId/analytics/steps`,
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(mockStepsData)),
|
||||
),
|
||||
rest.post(
|
||||
// Use :funnelId
|
||||
`http://localhost/api/v1/trace-funnels/:funnelId/analytics/slow-traces`,
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(mockSlowTracesData)),
|
||||
),
|
||||
rest.post(
|
||||
// Use :funnelId
|
||||
`http://localhost/api/v1/trace-funnels/:funnelId/analytics/error-traces`,
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(mockErrorTracesData)),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -14,10 +14,6 @@ function TracesFunnelDetails(): JSX.Element {
|
||||
const { funnelId } = useParams<{ funnelId: string }>();
|
||||
const { data, isLoading, isError } = useFunnelDetails({ funnelId });
|
||||
|
||||
if (isLoading || !data?.payload) {
|
||||
return <Spinner size="large" tip="Loading..." />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
@@ -26,6 +22,10 @@ function TracesFunnelDetails(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data?.payload) {
|
||||
return <Spinner size="large" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FunnelProvider funnelId={funnelId}>
|
||||
<div className="traces-funnel-details">
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
|
||||
import TracesFunnelDetails from '../TracesFunnelDetails';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/TopNav/DateTimeSelectionV2/index.tsx',
|
||||
() =>
|
||||
function MockDateTimeSelection(): JSX.Element {
|
||||
return <div>MockDateTimeSelection</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
|
||||
() =>
|
||||
function MockQueryBuilderSearchV2(): JSX.Element {
|
||||
return <div>MockQueryBuilderSearchV2</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions',
|
||||
() => ({
|
||||
FilterSelect: ({
|
||||
placeholder,
|
||||
onChange,
|
||||
values,
|
||||
}: {
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
values: string;
|
||||
}): JSX.Element => (
|
||||
<input
|
||||
placeholder={placeholder}
|
||||
value={values || ''}
|
||||
onChange={(e): void => onChange?.(e.target.value)}
|
||||
data-testid={`filter-select-${placeholder}`}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const successNotification = jest.fn();
|
||||
const errorNotification = jest.fn();
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: {
|
||||
success: successNotification,
|
||||
error: errorNotification,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: (): { selectedTime: string; loading: boolean } => ({
|
||||
selectedTime: '1h',
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const REACT_ROUTER_DOM = 'react-router-dom';
|
||||
|
||||
const mockUseParams = jest.fn();
|
||||
|
||||
jest.mock(REACT_ROUTER_DOM, () => ({
|
||||
...jest.requireActual(REACT_ROUTER_DOM),
|
||||
useParams: mockUseParams,
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
pathname: '/traces/funnels/test-funnel-id',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/App/utils', () => ({
|
||||
getUserDefaults: jest.fn(() => ({
|
||||
accessJwt: 'mock-access-token',
|
||||
refreshJwt: 'mock-refresh-token',
|
||||
id: 'mock-user-id',
|
||||
email: 'editor@example.com',
|
||||
displayName: 'Test Editor',
|
||||
createdAt: Date.now(),
|
||||
organization: 'Test Organization',
|
||||
orgId: 'mock-org-id',
|
||||
role: 'EDITOR',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockFunnelId = 'test-funnel-id';
|
||||
const MOCK_FUNNEL_NAME = 'Test Funnel';
|
||||
const mockFunnelData = {
|
||||
funnel_id: mockFunnelId,
|
||||
funnel_name: MOCK_FUNNEL_NAME,
|
||||
user_email: 'test@example.com',
|
||||
created_at: Date.now() - 86400000,
|
||||
updated_at: Date.now(),
|
||||
description: 'Test funnel description',
|
||||
steps: [
|
||||
{
|
||||
id: 'step-1',
|
||||
step_order: 1,
|
||||
service_name: 'auth-service',
|
||||
span_name: 'user-login',
|
||||
filters: { items: [], op: 'AND' },
|
||||
latency_pointer: 'start',
|
||||
latency_type: 'p99',
|
||||
has_errors: false,
|
||||
name: 'Login Step',
|
||||
description: 'User login step',
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
step_order: 2,
|
||||
service_name: 'payment-service',
|
||||
span_name: 'process-payment',
|
||||
filters: { items: [], op: 'AND' },
|
||||
latency_pointer: 'start',
|
||||
latency_type: 'p99',
|
||||
has_errors: false,
|
||||
name: 'Payment Step',
|
||||
description: 'Payment processing step',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
cacheTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Test render helper
|
||||
const renderTracesFunnelDetails = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>
|
||||
<MemoryRouter initialEntries={[`/traces/funnels/${mockFunnelId}`]}>
|
||||
<Route path={ROUTES.TRACES_FUNNELS_DETAIL}>
|
||||
<TracesFunnelDetails />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Shared setup helper
|
||||
const setupTest = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
renderTracesFunnelDetails();
|
||||
});
|
||||
|
||||
// Wait for page to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
|
||||
});
|
||||
};
|
||||
|
||||
describe('TracesFunnelDetails', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock console.error to track error logs
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock useParams to return the funnel ID
|
||||
mockUseParams.mockReturnValue({
|
||||
funnelId: mockFunnelId,
|
||||
});
|
||||
|
||||
// Setup comprehensive API mocks
|
||||
server.use(
|
||||
// Mock funnel details fetch
|
||||
rest.get(
|
||||
`http://localhost/api/v1/trace-funnels/${mockFunnelId}`,
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: mockFunnelData })),
|
||||
),
|
||||
// Mock all analytics endpoints to avoid errors
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/validate',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [{ count: 150 }] })),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/overview',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/steps',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/steps/overview',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/slow-traces',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/error-traces',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
queryClient.clear();
|
||||
|
||||
// Restore console.error
|
||||
(console.error as jest.Mock).mockRestore?.();
|
||||
});
|
||||
|
||||
describe('Basic Page Loading', () => {
|
||||
it('should load and display funnel information', async () => {
|
||||
await setupTest();
|
||||
|
||||
// Check that the page loads with basic funnel info (appears in breadcrumb + title)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Check breadcrumb navigation
|
||||
expect(screen.getByText('All funnels')).toBeInTheDocument();
|
||||
|
||||
// Check funnel steps section header
|
||||
expect(screen.getByText('FUNNEL STEPS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading spinner initially', async () => {
|
||||
// Mock slow API response
|
||||
server.use(
|
||||
rest.get(
|
||||
`http://localhost/api/v1/trace-funnels/${mockFunnelId}`,
|
||||
(_, res, ctx) =>
|
||||
res(ctx.delay(100), ctx.status(200), ctx.json({ data: mockFunnelData })),
|
||||
),
|
||||
);
|
||||
|
||||
await setupTest();
|
||||
|
||||
// Should show Ant Design loading spinner
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
|
||||
// Wait for data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API errors (currently shows loading spinner due to component logic)', async () => {
|
||||
// Mock API error with proper HTTP error status
|
||||
server.use(
|
||||
rest.get(
|
||||
`http://localhost/api/v1/trace-funnels/${mockFunnelId}`,
|
||||
(_, res, ctx) =>
|
||||
res(ctx.status(404), ctx.json({ message: 'Funnel not found' })),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
renderTracesFunnelDetails();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Error loading funnel details'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify that the API error was actually triggered
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Step Configuration Display', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTest();
|
||||
});
|
||||
|
||||
it('should display funnel steps with their names', async () => {
|
||||
// Check step names are displayed
|
||||
expect(screen.getByText('Login Step')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payment Step')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show step configuration interface', async () => {
|
||||
// Check that service and span selectors are present by placeholder text
|
||||
expect(screen.getAllByPlaceholderText('Select Service')).toHaveLength(2);
|
||||
expect(screen.getAllByPlaceholderText('Select Span name')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should display add step button when under step limit', async () => {
|
||||
// Find "Add Funnel Step" button (only shown if less than 3 steps)
|
||||
const addStepButton = screen.getByRole('button', {
|
||||
name: /add funnel step/i,
|
||||
});
|
||||
expect(addStepButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show step actions via ellipsis menu', async () => {
|
||||
// Find ellipsis icons for step actions (they are SVG elements with specific class)
|
||||
const ellipsisElements = document.querySelectorAll(
|
||||
'.funnel-item__action-icon',
|
||||
);
|
||||
expect(ellipsisElements.length).toBeGreaterThan(0);
|
||||
|
||||
// Click on ellipsis to open popover
|
||||
await user.click(ellipsisElements[0] as Element);
|
||||
|
||||
// Check that step action options appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer and Save Functionality', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTest();
|
||||
});
|
||||
|
||||
it('should display step count in footer', async () => {
|
||||
// Check step count display
|
||||
expect(screen.getByText('2 steps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show save button in footer', async () => {
|
||||
// Find save button
|
||||
const saveButton = screen.getByRole('button', { name: /save funnel/i });
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display footer validation section', async () => {
|
||||
// Check that the footer exists with validation status
|
||||
const footer = screen.getByTestId('steps-footer');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Flow', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTest();
|
||||
});
|
||||
|
||||
it('should display correct breadcrumb navigation', async () => {
|
||||
// Check breadcrumb links
|
||||
const allFunnelsLink = screen.getByRole('link', { name: /all funnels/i });
|
||||
expect(allFunnelsLink).toHaveAttribute('href', '/traces/funnels');
|
||||
|
||||
// Check current funnel name in breadcrumb
|
||||
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API validation errors gracefully', async () => {
|
||||
// Mock validation API error
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/validate',
|
||||
(_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ message: 'Validation failed' })),
|
||||
),
|
||||
);
|
||||
|
||||
await setupTest();
|
||||
|
||||
// Should still load the main funnel configuration
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Configuration section should still work
|
||||
expect(screen.getByText('FUNNEL STEPS')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,17 +2,16 @@ import './StepsContent.styles.scss';
|
||||
|
||||
import { Button, Steps, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { PlusIcon, Undo2 } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import FunnelStep from './FunnelStep';
|
||||
import InterStepConfig from './InterStepConfig';
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
function StepsContent({
|
||||
isTraceDetailsPage,
|
||||
span,
|
||||
@@ -36,57 +35,60 @@ function StepsContent({
|
||||
);
|
||||
}, [span, handleAddStep, handleReplaceStep, steps.length, hasEditPermission]);
|
||||
|
||||
const stepItems = useMemo(
|
||||
() =>
|
||||
steps.map((step, index) => ({
|
||||
key: `step-${index + 1}`,
|
||||
description: (
|
||||
<div className="steps-content__description">
|
||||
<div className="funnel-step-wrapper">
|
||||
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
|
||||
{isTraceDetailsPage && span && (
|
||||
<Tooltip
|
||||
title={
|
||||
!hasEditPermission
|
||||
? 'You need editor or admin access to replace steps'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
(step.service_name === span.serviceName &&
|
||||
step.span_name === span.name) ||
|
||||
!hasEditPermission
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/* Display InterStepConfig only between steps */}
|
||||
{index < steps.length - 1 && (
|
||||
<InterStepConfig index={index} step={step} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
[steps, isTraceDetailsPage, span, hasEditPermission, handleReplaceStep],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="steps-content">
|
||||
<Steps direction="vertical">
|
||||
{steps.map((step, index) => (
|
||||
<Step
|
||||
key={`step-${index + 1}`}
|
||||
description={
|
||||
<div className="steps-content__description">
|
||||
<div className="funnel-step-wrapper">
|
||||
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
|
||||
{isTraceDetailsPage && span && (
|
||||
<Tooltip
|
||||
title={
|
||||
!hasEditPermission
|
||||
? 'You need editor or admin access to replace steps'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
(step.service_name === span.serviceName &&
|
||||
step.span_name === span.name) ||
|
||||
!hasEditPermission
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/* Display InterStepConfig only between steps */}
|
||||
{index < steps.length - 1 && (
|
||||
// the latency type should be sent with the n+1th step
|
||||
<InterStepConfig index={index + 1} step={steps[index + 1]} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{/* For now we are only supporting 3 steps */}
|
||||
{steps.length < 3 && (
|
||||
<Step
|
||||
className="steps-content__add-step"
|
||||
description={
|
||||
!isTraceDetailsPage ? (
|
||||
<OverlayScrollbar>
|
||||
<>
|
||||
<Steps direction="vertical" items={stepItems} />
|
||||
|
||||
{/* For now we are only supporting 3 steps */}
|
||||
{steps.length < 3 && (
|
||||
<div className="steps-content__add-step">
|
||||
{!isTraceDetailsPage ? (
|
||||
<Tooltip
|
||||
title={
|
||||
!hasEditPermission
|
||||
@@ -122,11 +124,11 @@ function StepsContent({
|
||||
Add for new Step
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Steps>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function StepsFooter({ stepsCount, isSaving }: StepsFooterProps): JSX.Element {
|
||||
} = useFunnelContext();
|
||||
|
||||
return (
|
||||
<div className="steps-footer">
|
||||
<div className="steps-footer" data-testid="steps-footer">
|
||||
<div className="steps-footer__left">
|
||||
<Cone className="funnel-icon" size={14} />
|
||||
<span>{stepsCount} steps</span>
|
||||
|
||||
@@ -103,10 +103,13 @@ function FunnelGraph(): JSX.Element {
|
||||
return (
|
||||
<Spin spinning={isFetching} indicator={<LoadingOutlined spin />}>
|
||||
<div className={cx('funnel-graph', `funnel-graph--${totalSteps}-columns`)}>
|
||||
<div className="funnel-graph__chart-container">
|
||||
<div
|
||||
className="funnel-graph__chart-container"
|
||||
data-testid="funnel-graph-canvas"
|
||||
>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
<div className="funnel-graph__legends">
|
||||
<div className="funnel-graph__legends" data-testid="funnel-graph-legend">
|
||||
{Array.from({ length: totalSteps }, (_, index) => {
|
||||
const prevTotalSpans =
|
||||
index > 0
|
||||
|
||||
@@ -18,6 +18,7 @@ interface FunnelMetricsTableProps {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
emptyState?: JSX.Element;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function FunnelMetricsContentRenderer({
|
||||
@@ -71,9 +72,10 @@ function FunnelMetricsTable({
|
||||
isLoading,
|
||||
isError,
|
||||
emptyState,
|
||||
testId,
|
||||
}: FunnelMetricsTableProps): JSX.Element {
|
||||
return (
|
||||
<div className="funnel-metrics">
|
||||
<div className="funnel-metrics" data-testid={testId}>
|
||||
<div className="funnel-metrics__header">
|
||||
<div className="funnel-metrics__title">{title}</div>
|
||||
{subtitle && (
|
||||
|
||||
@@ -9,6 +9,7 @@ interface FunnelTableProps {
|
||||
columns: Array<ColumnProps<any>>;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function FunnelTable({
|
||||
@@ -17,9 +18,10 @@ function FunnelTable({
|
||||
columns = [],
|
||||
title,
|
||||
tooltip,
|
||||
testId,
|
||||
}: FunnelTableProps): JSX.Element {
|
||||
return (
|
||||
<div className="funnel-table">
|
||||
<div className="funnel-table" data-testid={testId}>
|
||||
<div className="funnel-table__header">
|
||||
<div className="funnel-table__title">{title}</div>
|
||||
<div className="funnel-table__actions">
|
||||
@@ -41,6 +43,7 @@ function FunnelTable({
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
|
||||
}
|
||||
rowKey={(record): string => record.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ interface FunnelTopTracesTableProps {
|
||||
Error
|
||||
>;
|
||||
steps: FunnelStepData[];
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function FunnelTopTracesTable({
|
||||
@@ -36,6 +37,7 @@ function FunnelTopTracesTable({
|
||||
tooltip,
|
||||
steps,
|
||||
useQueryHook,
|
||||
testId,
|
||||
}: FunnelTopTracesTableProps): JSX.Element {
|
||||
const { startTime, endTime } = useFunnelContext();
|
||||
const payload = useMemo(
|
||||
@@ -65,6 +67,7 @@ function FunnelTopTracesTable({
|
||||
|
||||
return (
|
||||
<FunnelTable
|
||||
testId={testId}
|
||||
title={title}
|
||||
tooltip={tooltip}
|
||||
columns={topTracesTableColumns}
|
||||
|
||||
@@ -12,6 +12,7 @@ function OverallMetrics(): JSX.Element {
|
||||
return (
|
||||
<FunnelMetricsTable
|
||||
title="Overall Funnel Metrics"
|
||||
testId="overall-funnel-metrics"
|
||||
subtitle={{
|
||||
label: 'Conversion rate',
|
||||
value: `${conversionRate.toFixed(2)}%`,
|
||||
|
||||
@@ -35,6 +35,7 @@ function StepsTransitionMetrics({
|
||||
return (
|
||||
<FunnelMetricsTable
|
||||
title={currentTransition.label}
|
||||
testId="step-transition-metrics"
|
||||
subtitle={{
|
||||
label: 'Conversion rate',
|
||||
value: `${conversionRate.toFixed(2)}%`,
|
||||
|
||||
@@ -18,6 +18,7 @@ function TopSlowestTraces(props: TopSlowestTracesProps): JSX.Element {
|
||||
title="Slowest 5 traces"
|
||||
tooltip="A list of the slowest traces in the funnel"
|
||||
useQueryHook={useFunnelSlowTraces}
|
||||
testId="top-slowest-traces-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ function TopTracesWithErrors(props: TopTracesWithErrorsProps): JSX.Element {
|
||||
title="Traces with errors"
|
||||
tooltip="A list of the traces with errors in the funnel"
|
||||
useQueryHook={useFunnelErrorTraces}
|
||||
testId="top-traces-with-errors-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { FunnelData, FunnelStepData } from 'types/api/traceFunnels';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
interface FunnelContextType {
|
||||
export interface FunnelContextType {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
selectedTime: CustomTimeType | Time | TimeV2;
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import Success, {
|
||||
ISuccessProps,
|
||||
} from 'container/TraceWaterfall/TraceWaterfallStates/Success/Success';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { FunnelProvider } from '../FunnelContext';
|
||||
import {
|
||||
mockFunnelsListData,
|
||||
mockSpanSuccessComponentProps,
|
||||
} from './mockFunnelsData';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const firstFunnel = mockFunnelsListData[0];
|
||||
const secondFunnel = mockFunnelsListData[1];
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { search: string } => ({
|
||||
search: '',
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderTraceWaterfallSuccess = (
|
||||
props: Partial<ISuccessProps> = {},
|
||||
): RenderResult =>
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<AppProvider>
|
||||
<FunnelProvider funnelId={firstFunnel.funnel_id}>
|
||||
<MemoryRouter initialEntries={[ROUTES.TRACES_FUNNELS_DETAIL]}>
|
||||
<Success {...mockSpanSuccessComponentProps} {...props} />
|
||||
</MemoryRouter>
|
||||
</FunnelProvider>
|
||||
</AppProvider>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
window.Element.prototype.getBoundingClientRect = jest
|
||||
.fn()
|
||||
.mockReturnValue({ height: 1000, width: 1000 });
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: (): { selectedTime: string; loading: boolean } => ({
|
||||
selectedTime: '1h',
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseFunnelsList = jest.fn();
|
||||
const mockUseValidateFunnelSteps = jest.fn(() => ({
|
||||
data: { payload: { data: [] } },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
}));
|
||||
const mockUseUpdateFunnelSteps = jest.fn(() => ({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/TracesFunnels/useFunnels', () => ({
|
||||
...jest.requireActual('hooks/TracesFunnels/useFunnels'),
|
||||
useFunnelsList: (): void => mockUseFunnelsList(),
|
||||
useValidateFunnelSteps: (): {
|
||||
data: { payload: { data: unknown[] } };
|
||||
isLoading: boolean;
|
||||
} => mockUseValidateFunnelSteps(),
|
||||
useUpdateFunnelSteps: (): { mutate: jest.Mock; isLoading: boolean } =>
|
||||
mockUseUpdateFunnelSteps(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: jest.fn(() => ({
|
||||
location: {
|
||||
pathname: '',
|
||||
search: '',
|
||||
},
|
||||
})),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '',
|
||||
search: '',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
|
||||
() =>
|
||||
function MockQueryBuilderSearchV2(): JSX.Element {
|
||||
return <div>MockQueryBuilderSearchV2</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
function MockOverlayScrollbar({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactNode {
|
||||
return children;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('providers/App/utils', () => ({
|
||||
getUserDefaults: jest.fn(() => ({
|
||||
accessJwt: 'mock-access-token',
|
||||
refreshJwt: 'mock-refresh-token',
|
||||
id: 'mock-user-id',
|
||||
email: 'editor@example.com',
|
||||
displayName: 'Test Editor',
|
||||
createdAt: Date.now(),
|
||||
organization: 'Test Organization',
|
||||
orgId: 'mock-org-id',
|
||||
role: 'EDITOR',
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Add span to funnel from trace details page', () => {
|
||||
// Set NODE_ENV to development for modal to render
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
});
|
||||
|
||||
it('displays add to funnel icon for spans with valid service and span names', async () => {
|
||||
await act(() => renderTraceWaterfallSuccess());
|
||||
expect(await screen.findByTestId('add-to-funnel-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("doesn't display add to funnel icon for spans with invalid service and span names", async () => {
|
||||
await act(() =>
|
||||
renderTraceWaterfallSuccess({
|
||||
spans: [
|
||||
{
|
||||
...mockSpanSuccessComponentProps.spans[0],
|
||||
serviceName: '',
|
||||
name: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-to-funnel-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('add span to funnel modal tests', () => {
|
||||
beforeEach(async () => {
|
||||
mockUseFunnelsList.mockReturnValue({
|
||||
data: { payload: mockFunnelsListData },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`http://localhost/api/v1/trace-funnels/${firstFunnel.funnel_id}`,
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: firstFunnel })),
|
||||
),
|
||||
);
|
||||
|
||||
await act(() => renderTraceWaterfallSuccess());
|
||||
|
||||
const addFunnelButton = await screen.findByTestId('add-to-funnel-button');
|
||||
await act(() => {
|
||||
fireEvent.click(addFunnelButton);
|
||||
});
|
||||
|
||||
// Wait for modal to appear and content to load
|
||||
|
||||
// Wait for funnel list content to appear in modal
|
||||
await waitFor(async () => {
|
||||
expect(
|
||||
await screen.findByText(firstFunnel.funnel_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the add to funnel modal when the add to funnel icon is clicked', async () => {
|
||||
const addSpanToFunnelModal = await screen.findByRole('dialog');
|
||||
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText('Add span to funnel'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByPlaceholderText(
|
||||
'Search by name, description, or tags...',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText('Create new funnel'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText(firstFunnel.funnel_name),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText(secondFunnel.funnel_name),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText(firstFunnel.user_email),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText(secondFunnel.user_email),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should search / filter when the user types in the search input', async () => {
|
||||
const addSpanToFunnelModal = await screen.findByRole('dialog');
|
||||
const searchInput = within(addSpanToFunnelModal).getByPlaceholderText(
|
||||
'Search by name, description, or tags...',
|
||||
);
|
||||
await act(() =>
|
||||
fireEvent.change(searchInput, {
|
||||
target: { value: firstFunnel.funnel_name },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchInput).toHaveValue(firstFunnel.funnel_name);
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText(firstFunnel.funnel_name),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).queryByText(secondFunnel.funnel_name),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('funnel details view tests', () => {
|
||||
beforeEach(async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const addSpanToFunnelModal = await screen.findByRole('dialog');
|
||||
|
||||
const firstFunnelButton = await within(addSpanToFunnelModal).findByText(
|
||||
firstFunnel.funnel_name,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(firstFunnelButton);
|
||||
});
|
||||
|
||||
// Wait for the modal to transition to details view
|
||||
await waitFor(async () => {
|
||||
expect(
|
||||
await within(addSpanToFunnelModal).findByRole('button', {
|
||||
name: 'All funnels',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should go to funnels details view of modal when a funnel is clicked, and go back to list view on clicking all funnels button', async () => {
|
||||
const addSpanToFunnelModal = await screen.findByRole('dialog');
|
||||
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByRole('button', {
|
||||
name: 'All funnels',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).queryByRole('button', {
|
||||
name: 'Create new funnel',
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const allFunnelsButton = await within(addSpanToFunnelModal).getByText(
|
||||
/all funnels/i,
|
||||
);
|
||||
await act(() => {
|
||||
fireEvent.click(allFunnelsButton);
|
||||
});
|
||||
|
||||
await within(addSpanToFunnelModal).findByRole('button', {
|
||||
name: 'Create new funnel',
|
||||
});
|
||||
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByRole('button', {
|
||||
name: 'Create new funnel',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).queryByRole('button', {
|
||||
name: 'All funnels',
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the funnel preview card correctly', async () => {
|
||||
const addSpanToFunnelModal = await screen.findByRole('dialog');
|
||||
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText(firstFunnel.funnel_name),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText(firstFunnel.user_email),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it('should render the funnel steps correctly', async () => {
|
||||
const addSpanToFunnelModal = await screen.findByRole('dialog');
|
||||
|
||||
const expectTextWithCount = async (
|
||||
text: string,
|
||||
count: number,
|
||||
): Promise<void> => {
|
||||
expect(
|
||||
await within(addSpanToFunnelModal).findAllByText(text),
|
||||
).toHaveLength(count);
|
||||
};
|
||||
|
||||
await expectTextWithCount('Step 1', 1);
|
||||
await expectTextWithCount('Step 2', 1);
|
||||
await expectTextWithCount('ServiceA', 1);
|
||||
await expectTextWithCount('SpanA', 1);
|
||||
await expectTextWithCount('ServiceB', 1);
|
||||
await expectTextWithCount('SpanB', 1);
|
||||
await expectTextWithCount('Where', 2);
|
||||
await expectTextWithCount('Errors', 2);
|
||||
await expectTextWithCount('Latency type', 1);
|
||||
await expectTextWithCount('P99', 1);
|
||||
await expectTextWithCount('P95', 1);
|
||||
await expectTextWithCount('P90', 1);
|
||||
await expectTextWithCount('Replace', 2);
|
||||
});
|
||||
it('should replace the selected span and service names on clicking the replace button', async () => {
|
||||
const addSpanToFunnelModal = await screen.findByRole('dialog');
|
||||
|
||||
expect(within(addSpanToFunnelModal).getByText('SpanA')).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText('ServiceA'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const replaceButtons = await within(
|
||||
addSpanToFunnelModal,
|
||||
).findAllByRole('button', { name: /replace/i });
|
||||
expect(replaceButtons[0]).toBeEnabled();
|
||||
await act(() => {
|
||||
fireEvent.click(replaceButtons[0]);
|
||||
});
|
||||
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText('producer-svc-3'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(addSpanToFunnelModal).getByText('topic2 publish'),
|
||||
).toBeInTheDocument();
|
||||
expect(replaceButtons[0]).toBeDisabled();
|
||||
});
|
||||
it('should add the span as a new step on clicking the add for a new step button', async () => {
|
||||
const addSpanToFunnelModal = await screen.findByRole('dialog');
|
||||
|
||||
const addNewStepButton = await within(
|
||||
addSpanToFunnelModal,
|
||||
).findByRole('button', { name: /add for new step/i });
|
||||
await act(() => {
|
||||
fireEvent.click(addNewStepButton);
|
||||
});
|
||||
expect(
|
||||
await within(addSpanToFunnelModal).queryByText('Add for new Step'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
await within(addSpanToFunnelModal).findAllByText('Where'),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
278
frontend/src/pages/TracesFunnels/__tests__/CreateFunnel.test.tsx
Normal file
278
frontend/src/pages/TracesFunnels/__tests__/CreateFunnel.test.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import TracesFunnelDetails from 'pages/TracesFunnelDetails';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
|
||||
import TracesFunnels from '..';
|
||||
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('providers/App/utils', () => ({
|
||||
getUserDefaults: jest.fn(() => ({
|
||||
accessJwt: 'mock-access-token',
|
||||
refreshJwt: 'mock-refresh-token',
|
||||
id: 'mock-user-id',
|
||||
email: 'editor@example.com',
|
||||
displayName: 'Test Editor',
|
||||
createdAt: Date.now(),
|
||||
organization: 'Test Organization',
|
||||
orgId: 'mock-org-id',
|
||||
role: 'EDITOR',
|
||||
})),
|
||||
}));
|
||||
|
||||
const successNotification = jest.fn();
|
||||
const errorNotification = jest.fn();
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: {
|
||||
success: successNotification,
|
||||
error: errorNotification,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const createdFunnelId = `newly-created-funnel-id`;
|
||||
const newFunnelName = 'My Test Funnel';
|
||||
export const FUNNELS_LIST_URL = 'http://localhost/api/v1/trace-funnels/list';
|
||||
const CREATE_FUNNEL_URL = 'http://localhost/api/v1/trace-funnels/new';
|
||||
|
||||
// Helper function to encapsulate opening the create funnel modal
|
||||
const openCreateFunnelModal = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
await screen.findByText(/no funnels yet/i);
|
||||
await user.click(screen.getAllByText(/new funnel/i)[1]);
|
||||
await screen.findByRole('dialog', { name: /create new funnel/i });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
pathname: `${ROUTES.LOGS_EXPLORER}`,
|
||||
}),
|
||||
useParams: jest.fn(() => ({
|
||||
funnelId: createdFunnelId,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: (): { selectedTime: string; loading: boolean } => ({
|
||||
selectedTime: '1h',
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/TopNav/DateTimeSelectionV2/index.tsx',
|
||||
() =>
|
||||
function MockDateTimeSelection(): JSX.Element {
|
||||
return <div>MockDateTimeSelection</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
|
||||
() =>
|
||||
function MockQueryBuilderSearchV2(): JSX.Element {
|
||||
return <div>MockQueryBuilderSearchV2</div>;
|
||||
},
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
export const renderTraceFunnelRoutes = (
|
||||
initialEntries: string[] = [ROUTES.TRACES_FUNNELS],
|
||||
): RenderResult =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Route path={ROUTES.TRACES_FUNNELS} exact>
|
||||
<TracesFunnels />
|
||||
</Route>
|
||||
<Route path={ROUTES.TRACES_FUNNELS_DETAIL}>
|
||||
<TracesFunnelDetails />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const renderFunnelRoutesWithAct = async (
|
||||
initialEntries: string[] = [ROUTES.TRACES_FUNNELS],
|
||||
): Promise<void> => {
|
||||
await act(async () => {
|
||||
renderTraceFunnelRoutes(initialEntries);
|
||||
});
|
||||
};
|
||||
describe('Funnel Creation Flow', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(jest.requireMock('react-router-dom').useParams as jest.Mock).mockReturnValue(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
queryClient.clear(); // Clear react-query cache between tests
|
||||
});
|
||||
|
||||
describe('Navigating to Funnel Creation Modal', () => {
|
||||
// Setup: Mock empty list and render the list page
|
||||
beforeEach(async () => {
|
||||
server.use(
|
||||
rest.get(FUNNELS_LIST_URL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ payload: [] })),
|
||||
),
|
||||
);
|
||||
await renderFunnelRoutesWithAct();
|
||||
await screen.findByText(/no funnels yet/i);
|
||||
});
|
||||
|
||||
it('should render the "New Funnel" button when the funnel list is empty', async () => {
|
||||
expect(screen.getAllByText(/new funnel/i).length).toBe(2);
|
||||
});
|
||||
|
||||
it('should open the "Create New Funnel" modal when the "New Funnel" button is clicked', async () => {
|
||||
await user.click(screen.getAllByText(/new funnel/i)[1]);
|
||||
await screen.findByRole('dialog', {
|
||||
name: /create new funnel/i,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Creating a New Funnel', () => {
|
||||
// Setup: Render list page with mocked empty list
|
||||
beforeEach(async () => {
|
||||
server.use(
|
||||
rest.get(FUNNELS_LIST_URL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ payload: [] })),
|
||||
),
|
||||
);
|
||||
await renderFunnelRoutesWithAct();
|
||||
});
|
||||
|
||||
it('should create a new funnel and navigate to its details page upon successful API response', async () => {
|
||||
// Mock specific API calls for successful creation
|
||||
server.use(
|
||||
rest.post(CREATE_FUNNEL_URL, async (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { funnel_id: createdFunnelId } })),
|
||||
),
|
||||
);
|
||||
|
||||
// Mock useParams for the target details page
|
||||
(jest.requireMock('react-router-dom')
|
||||
.useParams as jest.Mock).mockReturnValue({
|
||||
funnelId: createdFunnelId,
|
||||
});
|
||||
|
||||
await openCreateFunnelModal(user);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(
|
||||
/eg\. checkout dropoff funnel/i,
|
||||
);
|
||||
await user.type(nameInput, newFunnelName);
|
||||
|
||||
const createButton = screen.getByRole('button', { name: /create funnel/i });
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(successNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Funnel created successfully' }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const expectedPath = ROUTES.TRACES_FUNNELS_DETAIL.replace(
|
||||
':funnelId',
|
||||
createdFunnelId,
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error notification if the funnel creation API call fails', async () => {
|
||||
// Temporarily suppress console.error for expected error log
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Mock specific API calls for creation failure
|
||||
server.use(
|
||||
rest.post(CREATE_FUNNEL_URL, async (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ message: 'Failed to create funnel' })),
|
||||
),
|
||||
);
|
||||
|
||||
await openCreateFunnelModal(user);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(
|
||||
/eg\. checkout dropoff funnel/i,
|
||||
);
|
||||
await user.type(nameInput, newFunnelName);
|
||||
|
||||
const createButton = screen.getByRole('button', { name: /create funnel/i });
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: { message: 'Failed to create funnel' },
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure navigation did not occur
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import 'jest-canvas-mock';
|
||||
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as FunnelsHooksModule from 'hooks/TracesFunnels/useFunnels';
|
||||
import { mockSingleFunnelData } from 'mocks-server/__mockdata__/trace_funnels';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import * as FunnelContextModule from 'pages/TracesFunnels/FunnelContext';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { renderTraceFunnelRoutes } from './CreateFunnel.test';
|
||||
import {
|
||||
defaultMockFunnelContext,
|
||||
mockErrorTracesData,
|
||||
mockOverviewData,
|
||||
mockSlowTracesData,
|
||||
mockStepsData,
|
||||
mockStepsOverviewData,
|
||||
} from './mockFunnelsData';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: (): { selectedTime: string; loading: boolean } => ({
|
||||
selectedTime: '1h',
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseParams = jest.requireMock('react-router-dom')
|
||||
.useParams as jest.Mock;
|
||||
|
||||
const renderFunnelDetailsWithAct = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
await renderTraceFunnelRoutes([
|
||||
ROUTES.TRACES_FUNNELS_DETAIL.replace(
|
||||
':funnelId',
|
||||
mockSingleFunnelData.funnel_id,
|
||||
),
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Viewing Funnel Details', () => {
|
||||
beforeEach(() => {
|
||||
mockUseParams.mockReturnValue({
|
||||
funnelId: mockSingleFunnelData.funnel_id,
|
||||
});
|
||||
});
|
||||
it('should render the Funnel Details page and display the funnel name', async () => {
|
||||
// Mock the API call to fetch funnel details
|
||||
server.use(
|
||||
rest.get(
|
||||
`http://localhost/api/v1/trace-funnels/${mockSingleFunnelData.funnel_id}`,
|
||||
(_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleFunnelData })),
|
||||
),
|
||||
// Mock validate endpoint as it might be called on load
|
||||
rest.post(
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
'http://localhost/api/v1/trace-funnels/analytics/validate',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
|
||||
// Render the component for this test
|
||||
await renderFunnelDetailsWithAct();
|
||||
|
||||
// Assertions for the general funnel details page render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/all funnels/i)).toBeInTheDocument();
|
||||
});
|
||||
await screen.findAllByText(mockSingleFunnelData.funnel_name);
|
||||
});
|
||||
|
||||
it('should display the total number of steps based on the fetched funnel data', async () => {
|
||||
// Mock API calls
|
||||
server.use(
|
||||
rest.get(
|
||||
`http://localhost/api/v1/trace-funnels/${mockSingleFunnelData.funnel_id}`,
|
||||
(_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleFunnelData })),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/validate',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
|
||||
// Render
|
||||
await renderFunnelDetailsWithAct();
|
||||
|
||||
await waitFor(() => {
|
||||
// Check if the step count is displayed correctly
|
||||
expect(
|
||||
screen.getByText(`${mockSingleFunnelData.steps?.length || 0} steps`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Nested describe for tests requiring FunnelContext mocks
|
||||
describe('when FunnelContext state is mocked', () => {
|
||||
let useFunnelsContextSpy: jest.SpyInstance;
|
||||
let useFunnelStepsGraphDataSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
useFunnelsContextSpy = jest.spyOn(FunnelContextModule, 'useFunnelContext');
|
||||
useFunnelStepsGraphDataSpy = jest.spyOn(
|
||||
FunnelsHooksModule,
|
||||
'useFunnelStepsGraphData',
|
||||
);
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/validate',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(
|
||||
`http://localhost/api/v1/trace-funnels/${mockSingleFunnelData.funnel_id}`,
|
||||
(_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockSingleFunnelData })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useFunnelsContextSpy.mockRestore();
|
||||
useFunnelStepsGraphDataSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should show empty state UI when no services or spans are selected in steps', async () => {
|
||||
// Apply specific context mock *before* rendering
|
||||
useFunnelsContextSpy.mockReturnValue({
|
||||
...defaultMockFunnelContext,
|
||||
hasAllEmptyStepFields: true,
|
||||
isValidateStepsLoading: false,
|
||||
});
|
||||
|
||||
// Render *after* setting the context mock for this specific test
|
||||
await renderFunnelDetailsWithAct();
|
||||
|
||||
// Assertions specific to the empty steps scenario
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No spans selected yet.')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('No service / span names')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show missing fields UI when steps have incomplete service/span selections', async () => {
|
||||
// Apply specific context mock
|
||||
useFunnelsContextSpy.mockReturnValue({
|
||||
...defaultMockFunnelContext,
|
||||
hasIncompleteStepFields: true,
|
||||
isValidateStepsLoading: false,
|
||||
});
|
||||
|
||||
// Render *after* setting the context mock
|
||||
await renderFunnelDetailsWithAct();
|
||||
|
||||
// Check if the missing services / spans message is shown in footer and results
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Missing service / span names').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty results state when no traces match the funnel steps', async () => {
|
||||
// Apply specific context mock
|
||||
useFunnelsContextSpy.mockReturnValue({
|
||||
...defaultMockFunnelContext,
|
||||
validTracesCount: 0,
|
||||
isValidateStepsLoading: false,
|
||||
});
|
||||
|
||||
// Render *after* setting the context mock
|
||||
await renderFunnelDetailsWithAct();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('There are no traces that match the funnel steps.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('No valid traces found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Describe block for tests when valid traces exist based on context
|
||||
describe('when valid traces exist', () => {
|
||||
beforeEach(async () => {
|
||||
// Apply the common context mock for this scenario
|
||||
useFunnelsContextSpy.mockReturnValue({
|
||||
...defaultMockFunnelContext, // Use the imported mock data
|
||||
validTracesCount: 1,
|
||||
isValidateStepsLoading: false,
|
||||
});
|
||||
|
||||
// Mock all the API endpoints that the components will call
|
||||
server.use(
|
||||
// Mock funnel overview endpoint
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/overview',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(mockOverviewData)),
|
||||
),
|
||||
// Mock funnel steps overview endpoint
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/steps/overview',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(mockStepsOverviewData)),
|
||||
),
|
||||
// Mock funnel slow traces endpoint
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/slow-traces',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(mockSlowTracesData)),
|
||||
),
|
||||
// Mock funnel error traces endpoint
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/error-traces',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(mockErrorTracesData)),
|
||||
),
|
||||
// Mock funnel steps graph data endpoint
|
||||
rest.post(
|
||||
'http://localhost/api/v1/trace-funnels/analytics/steps',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json(mockStepsData)),
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
await act(async () => {
|
||||
renderTraceFunnelRoutes([
|
||||
ROUTES.TRACES_FUNNELS_DETAIL.replace(
|
||||
':funnelId',
|
||||
mockSingleFunnelData.funnel_id,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the "Valid traces found" and steps', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Valid traces found')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 steps')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the overall funnel metrics based on context data', async () => {
|
||||
await waitFor(() => {
|
||||
const overallFunnelMetrics = screen.getByTestId('overall-funnel-metrics');
|
||||
const expectedTexts = [
|
||||
'Overall Funnel Metrics',
|
||||
'Conversion rate',
|
||||
'⎯',
|
||||
'80.00%',
|
||||
'Avg. Rate',
|
||||
'10.5 req/s',
|
||||
'Errors',
|
||||
'2',
|
||||
'Avg. Duration',
|
||||
'123 ms',
|
||||
'P99 Latency',
|
||||
'250 ms',
|
||||
];
|
||||
|
||||
expectedTexts.forEach((text) => {
|
||||
expect(within(overallFunnelMetrics).getByText(text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display step transition metrics based on context data', async () => {
|
||||
await waitFor(() => {
|
||||
const stepTransitionMetrics = screen.getByTestId(
|
||||
'step-transition-metrics',
|
||||
);
|
||||
const expectedTexts = [
|
||||
'Step 1 → Step 2',
|
||||
'Conversion rate',
|
||||
'⎯',
|
||||
'92.00%',
|
||||
'Avg. Rate',
|
||||
'8.5 req/s',
|
||||
'Errors',
|
||||
'1',
|
||||
'Avg. Duration',
|
||||
'55 µs',
|
||||
'P99 Latency',
|
||||
'150 µs',
|
||||
];
|
||||
|
||||
expectedTexts.forEach((text) => {
|
||||
expect(within(stepTransitionMetrics).getByText(text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the slowest traces table based on context data', async () => {
|
||||
await waitFor(() => {
|
||||
const slowTracesTable = screen.getByTestId('top-slowest-traces-table');
|
||||
const expectedTexts = [
|
||||
'Slowest 5 traces',
|
||||
'TRACE ID',
|
||||
'STEP TRANSITION DURATION',
|
||||
'slow-trace-1',
|
||||
'500 ms',
|
||||
];
|
||||
|
||||
expectedTexts.forEach((text) => {
|
||||
expect(within(slowTracesTable).getByText(text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the traces with errors table based on context data', async () => {
|
||||
await waitFor(() => {
|
||||
const errorTracesTable = screen.getByTestId(
|
||||
'top-traces-with-errors-table',
|
||||
);
|
||||
const expectedTexts = [
|
||||
'Traces with errors',
|
||||
'TRACE ID',
|
||||
'STEP TRANSITION DURATION',
|
||||
'error-trace-1',
|
||||
'151 ms',
|
||||
];
|
||||
|
||||
expectedTexts.forEach((text) => {
|
||||
expect(within(errorTracesTable).getByText(text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Updated test for Funnel Graph elements
|
||||
it('should display the funnel graph and legend based on mocked graph data', async () => {
|
||||
await waitFor(() => {
|
||||
// Check for the canvas element (assuming data-testid="funnel-graph-canvas" exists)
|
||||
expect(screen.getByTestId('funnel-graph-canvas')).toBeInTheDocument();
|
||||
|
||||
// Check for the legend container (assuming data-testid="funnel-graph-legend" exists)
|
||||
const legendContainer = screen.getByTestId('funnel-graph-legend');
|
||||
expect(legendContainer).toBeInTheDocument();
|
||||
|
||||
// Get the actual graph data from our mock
|
||||
const graphMetrics = mockStepsData.data[0].data;
|
||||
const successSteps: number[] = [];
|
||||
const errorSteps: number[] = [];
|
||||
let stepCount = 1;
|
||||
while (
|
||||
graphMetrics?.[`total_s${stepCount}_spans`] !== undefined &&
|
||||
graphMetrics?.[`total_s${stepCount}_errored_spans`] !== undefined
|
||||
) {
|
||||
const total = graphMetrics[`total_s${stepCount}_spans`];
|
||||
const errors = graphMetrics[`total_s${stepCount}_errored_spans`];
|
||||
successSteps.push(total - errors);
|
||||
errorSteps.push(errors);
|
||||
stepCount += 1;
|
||||
}
|
||||
const totalSteps = stepCount - 1;
|
||||
|
||||
// Assert number of legend columns based on calculated totalSteps
|
||||
const legendColumns = within(legendContainer).getAllByTestId(
|
||||
'funnel-graph-legend-column',
|
||||
);
|
||||
expect(legendColumns).toHaveLength(totalSteps); // Should be 2 based on mock data
|
||||
|
||||
// Check content of the first legend column (Step 1)
|
||||
const step1Total = successSteps[0] + errorSteps[0];
|
||||
expect(
|
||||
within(legendColumns[0]).getByText('Total spans'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(legendColumns[0]).getByText(step1Total.toString()),
|
||||
).toBeInTheDocument(); // 100
|
||||
expect(
|
||||
within(legendColumns[0]).getByText('Error spans'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(legendColumns[0]).getByText(errorSteps[0].toString()),
|
||||
).toBeInTheDocument(); // 10
|
||||
|
||||
// Check content of the second legend column (Step 2)
|
||||
const step2Total = successSteps[1] + errorSteps[1];
|
||||
expect(
|
||||
within(legendColumns[1]).getByText('Total spans'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(legendColumns[1]).getByText(step2Total.toString()),
|
||||
).toBeInTheDocument(); // 80
|
||||
expect(
|
||||
within(legendColumns[1]).getByText('Error spans'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(legendColumns[1]).getByText(errorSteps[1].toString()),
|
||||
).toBeInTheDocument(); // 8
|
||||
|
||||
// Check for the percentage change pill in the second column
|
||||
expect(
|
||||
within(legendColumns[1]).getByTestId('change-percentage-pill'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { render, RenderResult, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs from 'dayjs';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
|
||||
import TracesFunnels from '..';
|
||||
import { mockFunnelsListData, mockSingleFunnelData } from './mockFunnelsData';
|
||||
|
||||
const mockUseFunnelsList = jest.fn();
|
||||
jest.mock('hooks/TracesFunnels/useFunnels', () => ({
|
||||
...jest.requireActual('hooks/TracesFunnels/useFunnels'),
|
||||
useFunnelsList: (): void => mockUseFunnelsList(),
|
||||
useFunnelDetails: jest.fn(() => ({
|
||||
// Mock for details page test
|
||||
data: { payload: mockSingleFunnelData },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => new URLSearchParams()),
|
||||
}));
|
||||
|
||||
describe('Viewing and Navigating Funnels', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default successful fetch for list
|
||||
mockUseFunnelsList.mockReturnValue({
|
||||
data: { payload: mockFunnelsListData },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('TracesFunnels List View', () => {
|
||||
const renderListComponent = (): RenderResult =>
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<AppProvider>
|
||||
<MemoryRouter initialEntries={[ROUTES.TRACES_FUNNELS]}>
|
||||
<Route path={ROUTES.TRACES_FUNNELS}>
|
||||
<TracesFunnels />
|
||||
</Route>
|
||||
{/* Add a dummy route for detail page to test navigation link */}
|
||||
<Route path={ROUTES.TRACES_FUNNELS_DETAIL}>
|
||||
<div>Mock Details Page</div>
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
it('should display the list of funnels when data is loaded', async () => {
|
||||
renderListComponent();
|
||||
|
||||
// Wait for the list items to appear (use findBy for async)
|
||||
await screen.findByText(mockFunnelsListData[0].funnel_name);
|
||||
|
||||
// Verify both funnel names are present
|
||||
expect(
|
||||
screen.getByText(mockFunnelsListData[0].funnel_name),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(mockFunnelsListData[1].funnel_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display funnel details like creation date and user', async () => {
|
||||
renderListComponent();
|
||||
const firstFunnel = mockFunnelsListData[0];
|
||||
await screen.findByText(firstFunnel.funnel_name); // Ensure rendering is complete
|
||||
|
||||
// Check for formatted date (adjust format if needed)
|
||||
const expectedDateFormat = DATE_TIME_FORMATS.FUNNELS_LIST_DATE;
|
||||
const expectedDate = dayjs(firstFunnel.created_at).format(
|
||||
expectedDateFormat,
|
||||
);
|
||||
expect(screen.getByText(expectedDate)).toBeInTheDocument();
|
||||
|
||||
// Check for user
|
||||
expect(
|
||||
screen.getByText(firstFunnel.user_email as string),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Find the first funnel item container and check within it
|
||||
const firstFunnelItem = screen
|
||||
.getByText(firstFunnel.funnel_name)
|
||||
.closest('.funnel-item');
|
||||
|
||||
// Get the expected initial
|
||||
const expectedInitial = firstFunnel.user_email
|
||||
?.substring(0, 1)
|
||||
.toUpperCase();
|
||||
|
||||
// Look for the avatar initial specifically within the first funnel item
|
||||
const avatarElement = within(firstFunnelItem as HTMLElement).getByText(
|
||||
expectedInitial as string,
|
||||
);
|
||||
expect(avatarElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render links for each funnel item pointing to the details page', async () => {
|
||||
renderListComponent();
|
||||
await screen.findByText(mockFunnelsListData[0].funnel_name); // Ensure rendering
|
||||
|
||||
const firstFunnelLink = screen.getByRole('link', {
|
||||
name: new RegExp(mockFunnelsListData[0].funnel_name),
|
||||
}); // Find link associated with the funnel item content
|
||||
const secondFunnelLink = screen.getByRole('link', {
|
||||
name: new RegExp(mockFunnelsListData[1].funnel_name),
|
||||
});
|
||||
|
||||
const expectedPath1 = ROUTES.TRACES_FUNNELS_DETAIL.replace(
|
||||
':funnelId',
|
||||
mockFunnelsListData[0].funnel_id,
|
||||
);
|
||||
const expectedPath2 = ROUTES.TRACES_FUNNELS_DETAIL.replace(
|
||||
':funnelId',
|
||||
mockFunnelsListData[1].funnel_id,
|
||||
);
|
||||
|
||||
expect(firstFunnelLink).toHaveAttribute('href', expectedPath1);
|
||||
expect(secondFunnelLink).toHaveAttribute('href', expectedPath2);
|
||||
|
||||
// Optional: Simulate click and verify navigation (less common now, asserting link is often enough)
|
||||
await userEvent.click(firstFunnelLink);
|
||||
expect(screen.getByText('Mock Details Page')).toBeInTheDocument(); // If you setup the route element
|
||||
});
|
||||
|
||||
it('should display loading skeletons when isLoading is true', () => {
|
||||
mockUseFunnelsList.mockReturnValue({
|
||||
data: null, // No data yet
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
const { container } = renderListComponent();
|
||||
|
||||
expect(
|
||||
container.querySelectorAll('.ant-skeleton-active').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display error message when isError is true', () => {
|
||||
mockUseFunnelsList.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
renderListComponent();
|
||||
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display empty state when data is empty', () => {
|
||||
mockUseFunnelsList.mockReturnValue({
|
||||
data: { payload: [] }, // Empty array
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
renderListComponent();
|
||||
|
||||
expect(screen.getByText(/No Funnels yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
211
frontend/src/pages/TracesFunnels/__tests__/mockFunnelsData.ts
Normal file
211
frontend/src/pages/TracesFunnels/__tests__/mockFunnelsData.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
ErrorTraceData,
|
||||
FunnelOverviewResponse,
|
||||
FunnelStepsResponse,
|
||||
SlowTraceData,
|
||||
} from 'api/traceFunnels';
|
||||
import { getRandomNumber } from 'lib/getRandomColor';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import { FunnelContextType } from '../FunnelContext';
|
||||
|
||||
// Helper to create consistent mock data
|
||||
export const createMockFunnel = (id: string, name: string): FunnelData => ({
|
||||
funnel_id: id,
|
||||
funnel_name: name,
|
||||
created_at: Date.now() - getRandomNumber(10000, 50000), // Mock timestamp
|
||||
updated_at: Date.now(),
|
||||
steps: [
|
||||
{
|
||||
id: 'step-1',
|
||||
step_order: 1,
|
||||
service_name: 'ServiceA',
|
||||
span_name: 'SpanA',
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
latency_pointer: 'start',
|
||||
latency_type: 'p99',
|
||||
has_errors: false,
|
||||
name: 'Step 1',
|
||||
description: 'First step',
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
step_order: 2,
|
||||
service_name: 'ServiceB',
|
||||
span_name: 'SpanB',
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
latency_pointer: 'start',
|
||||
latency_type: 'p99',
|
||||
has_errors: false,
|
||||
name: 'Step 2',
|
||||
description: 'Second step',
|
||||
},
|
||||
],
|
||||
user_email: `user-${id}`,
|
||||
description: `Description for ${name}`,
|
||||
});
|
||||
|
||||
export const mockFunnelsListData: FunnelData[] = [
|
||||
createMockFunnel('funnel-1', 'Checkout Process Funnel'),
|
||||
createMockFunnel('funnel-2', 'User Signup Flow'),
|
||||
];
|
||||
|
||||
export const mockSingleFunnelData: FunnelData = createMockFunnel(
|
||||
'funnel-1',
|
||||
'Checkout Process Funnel',
|
||||
);
|
||||
|
||||
export const defaultMockFunnelContext: FunnelContextType = {
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
selectedTime: '1h',
|
||||
validTracesCount: 0,
|
||||
funnelId: 'default-mock-id',
|
||||
steps: mockSingleFunnelData.steps || [],
|
||||
setSteps: jest.fn(),
|
||||
initialSteps: [],
|
||||
handleAddStep: jest.fn(),
|
||||
handleStepChange: jest.fn(),
|
||||
handleStepRemoval: jest.fn(),
|
||||
handleRunFunnel: jest.fn(),
|
||||
validationResponse: undefined,
|
||||
isValidateStepsLoading: false,
|
||||
hasIncompleteStepFields: false,
|
||||
hasAllEmptyStepFields: false,
|
||||
handleReplaceStep: jest.fn(),
|
||||
handleRestoreSteps: jest.fn(),
|
||||
handleSaveFunnel: jest.fn(),
|
||||
triggerSave: false,
|
||||
hasUnsavedChanges: false,
|
||||
isUpdatingFunnel: false,
|
||||
setIsUpdatingFunnel: jest.fn(),
|
||||
lastUpdatedSteps: [],
|
||||
setLastUpdatedSteps: jest.fn(),
|
||||
};
|
||||
|
||||
export const mockOverviewData: FunnelOverviewResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
timestamp: '1678886400000',
|
||||
data: {
|
||||
avg_duration: 123, // in milliseconds for proper formatting
|
||||
avg_rate: 10.5,
|
||||
conversion_rate: 80.0,
|
||||
errors: 2,
|
||||
latency: 250, // in milliseconds for proper formatting
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockStepsData: FunnelStepsResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
timestamp: '1678886400000',
|
||||
data: {
|
||||
total_s1_spans: 100,
|
||||
total_s1_errored_spans: 10,
|
||||
total_s2_spans: 80,
|
||||
total_s2_errored_spans: 8,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockStepsOverviewData = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
timestamp: '1678886400000',
|
||||
data: {
|
||||
avg_duration: 0.055, // in milliseconds for steps overview
|
||||
avg_rate: 8.5,
|
||||
conversion_rate: 92.0,
|
||||
errors: 1,
|
||||
latency: 0.15, // in milliseconds for steps overview
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockSlowTracesData: SlowTraceData = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
timestamp: '1678886400000',
|
||||
data: {
|
||||
duration_ms: '500.12',
|
||||
span_count: 15,
|
||||
trace_id: 'slow-trace-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockErrorTracesData: ErrorTraceData = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
timestamp: '1678886400000',
|
||||
data: {
|
||||
duration_ms: '150.67',
|
||||
span_count: 10,
|
||||
trace_id: 'error-trace-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockSpanSuccessComponentProps = {
|
||||
spans: [
|
||||
{
|
||||
timestamp: 1683245912789,
|
||||
durationNano: 28934567,
|
||||
spanId: 'c84bb52145b55f85',
|
||||
rootSpanId: '',
|
||||
traceId: '29fe8bbf8515f9fc4dd2g917c97c2b16',
|
||||
hasError: false,
|
||||
kind: 2,
|
||||
|
||||
event: [],
|
||||
rootName: '',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'Unset',
|
||||
spanKind: 'Producer',
|
||||
serviceName: 'producer-svc-3',
|
||||
name: 'topic2 publish',
|
||||
children: [],
|
||||
subTreeNodeCount: 3,
|
||||
hasChildren: false,
|
||||
hasSiblings: false,
|
||||
level: 0,
|
||||
parentSpanId: '',
|
||||
references: [],
|
||||
tagMap: { 'http.method': 'POST' },
|
||||
hasSibling: false,
|
||||
},
|
||||
],
|
||||
traceMetadata: {
|
||||
traceId: '29fe8bbf8515f9fc4dd2g917c97c2b16',
|
||||
startTime: 1683245912789,
|
||||
endTime: 1683245912817,
|
||||
hasMissingSpans: false,
|
||||
},
|
||||
interestedSpanId: {
|
||||
spanId: 'c84bb52145b55f85',
|
||||
isUncollapsed: true,
|
||||
},
|
||||
uncollapsedNodes: [],
|
||||
setInterestedSpanId: jest.fn(),
|
||||
setTraceFlamegraphStatsWidth: jest.fn(),
|
||||
selectedSpan: undefined,
|
||||
setSelectedSpan: jest.fn(),
|
||||
};
|
||||
@@ -6702,7 +6702,7 @@ color-name@1.1.3:
|
||||
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
|
||||
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
|
||||
|
||||
color-name@^1.0.0, color-name@~1.1.4:
|
||||
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
@@ -7158,6 +7158,11 @@ cssfilter@0.0.10:
|
||||
resolved "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz"
|
||||
integrity sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==
|
||||
|
||||
cssfontparser@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
|
||||
integrity sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==
|
||||
|
||||
cssnano-preset-default@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-6.0.1.tgz#2a93247140d214ddb9f46bc6a3562fa9177fe301"
|
||||
@@ -10483,6 +10488,14 @@ jerrypick@^1.1.1:
|
||||
resolved "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.1.tgz"
|
||||
integrity sha512-XTtedPYEyVp4t6hJrXuRKr/jHj8SC4z+4K0b396PMkov6muL+i8IIamJIvZWe3jUspgIJak0P+BaWKawMYNBLg==
|
||||
|
||||
jest-canvas-mock@2.5.2:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz#7e21ebd75e05ab41c890497f6ba8a77f915d2ad6"
|
||||
integrity sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==
|
||||
dependencies:
|
||||
cssfontparser "^1.2.1"
|
||||
moo-color "^1.0.2"
|
||||
|
||||
jest-changed-files@^27.5.1:
|
||||
version "27.5.1"
|
||||
resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz"
|
||||
@@ -12441,6 +12454,13 @@ moment@^2.29.4:
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
|
||||
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
|
||||
|
||||
moo-color@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
|
||||
integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==
|
||||
dependencies:
|
||||
color-name "^1.1.4"
|
||||
|
||||
motion-dom@^12.4.11:
|
||||
version "12.4.11"
|
||||
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.4.11.tgz#0419c8686cda4d523f08249deeb8fa6683a9b9d3"
|
||||
|
||||
Reference in New Issue
Block a user