Compare commits

...

18 Commits

Author SHA1 Message Date
ahmadshaheer
6fe5178dad chore: fix the failing tests in CI 2025-07-22 19:32:56 +04:30
ahmadshaheer
9c67d36e57 chore: fix the failing CI check 2025-07-22 11:04:15 +04:30
ahmadshaheer
197aaae9c5 chore: fix the failing tests 2025-07-22 10:44:36 +04:30
ahmadshaheer
58e91bc31b chore: assert whether add span to funnel is in the document 2025-07-20 16:24:35 +04:30
ahmadshaheer
dba3eb732d chore: refined trace funnels tests 2025-07-20 16:24:35 +04:30
ahmadshaheer
40b9ee48ac chore: fix linter issues 2025-07-20 16:24:35 +04:30
ahmadshaheer
12abfe5e43 chore: revert the unintended removal of dev env check for trace funnels 2025-07-20 16:24:35 +04:30
ahmadshaheer
373f26098f chore: improve row key 2025-07-20 16:24:35 +04:30
ahmadshaheer
d70be77f40 chore: fix the failing tests by adjusting the tests with latest changes 2025-07-20 16:24:35 +04:30
ahmadshaheer
7f230cb44f chore: fix the errors in trace funnels tests due to modified API response 2025-07-20 16:24:35 +04:30
ahmadshaheer
7075375537 chore: overall improvements to the existing trace funnels tests 2025-07-20 16:23:44 +04:30
ahmadshaheer
7046349294 chore: add span to funnel from trace details page tests 2025-07-20 16:23:44 +04:30
ahmadshaheer
e685b5e3ed chore: funnel details -> graph tests 2025-07-20 16:22:53 +04:30
ahmadshaheer
f993773295 chore: fix the warnings in trace funnels 2025-07-20 16:22:53 +04:30
ahmadshaheer
25ae3c8d27 chore: funnel details flows tests 2025-07-20 16:00:15 +04:30
ahmadshaheer
e264e3c576 chore: improve the tests for funnel creation flows 2025-07-20 16:00:15 +04:30
ahmadshaheer
4fdb74a341 chore: writing create and run funnel tests 2025-07-20 16:00:15 +04:30
ahmadshaheer
329c0e7fc6 chore: trace funnels list page tests 2025-07-20 16:00:15 +04:30
25 changed files with 2109 additions and 70 deletions

View File

@@ -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",

View File

@@ -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 ? (

View File

@@ -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();

View File

@@ -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}

View 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',
);

View File

@@ -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)),
),
];

View File

@@ -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">

View File

@@ -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();
});
});
});

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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)}%`,

View File

@@ -35,6 +35,7 @@ function StepsTransitionMetrics({
return (
<FunnelMetricsTable
title={currentTransition.label}
testId="step-transition-metrics"
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,

View File

@@ -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"
/>
);
}

View File

@@ -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"
/>
);
}

View File

@@ -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;

View File

@@ -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);
});
});
});
});

View 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();
});
});
});

View File

@@ -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();
});
});
});
});
});

View File

@@ -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();
});
});
});

View 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(),
};

View File

@@ -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"