import {EventFixture} from 'sentry-fixture/event';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
import ProjectsStore from 'sentry/stores/projectsStore';
import {trackAnalytics} from 'sentry/utils/analytics';
import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
import {TraceTimeline} from './traceTimeline';
import type {TraceEventResponse} from './useTraceTimelineEvents';
jest.mock('sentry/utils/routeAnalytics/useRouteAnalyticsParams');
jest.mock('sentry/utils/analytics');
describe('TraceTimeline', () => {
const organization = OrganizationFixture();
// This creates the ApiException event
const event = EventFixture({
dateCreated: '2024-01-24T09:09:03+00:00',
contexts: {
trace: {
trace_id: '123',
},
},
});
const project = ProjectFixture();
const emptyBody: TraceEventResponse = {data: [], meta: {fields: {}, units: {}}};
const issuePlatformBody: TraceEventResponse = {
data: [
{
// In issuePlatform, we store the subtitle within the message
message: '/api/slow/ Slow DB Query SELECT "sentry_monitorcheckin"."monitor_id"',
timestamp: '2024-01-24T09:09:03+00:00',
'issue.id': 1000,
project: project.slug,
'project.name': project.name,
title: 'Slow DB Query',
id: 'abc',
transaction: '/api/slow/',
},
],
meta: {fields: {}, units: {}},
};
const discoverBody: TraceEventResponse = {
data: [
{
message: 'This is the subtitle of the issue',
timestamp: '2024-01-23T22:11:42+00:00',
'issue.id': 4909507143,
project: project.slug,
'project.name': project.name,
title: 'AttributeError: Something Failed',
id: event.id,
transaction: 'important.task',
'event.type': 'error',
'stack.function': ['important.task', 'task.run'],
},
],
meta: {fields: {}, units: {}},
};
beforeEach(() => {
ProjectsStore.loadInitialData([project]);
jest.clearAllMocks();
});
it('renders items and highlights the current event', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: discoverBody,
match: [MockApiClient.matchQuery({dataset: 'discover'})],
});
render(, {organization});
expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
await userEvent.hover(screen.getByTestId('trace-timeline-tooltip-1'));
expect(await screen.findByText('You are here')).toBeInTheDocument();
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
has_related_trace_issue: false,
trace_timeline_status: 'shown',
});
});
it('displays nothing if the only event is the current event', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: discoverBody,
match: [MockApiClient.matchQuery({dataset: 'discover'})],
});
const {container} = render(, {organization});
await waitFor(() =>
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
has_related_trace_issue: false,
trace_timeline_status: 'empty',
})
);
expect(container).toBeEmptyDOMElement();
});
it('displays nothing if there are no events', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'discover'})],
});
const {container} = render(, {organization});
await waitFor(() =>
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
has_related_trace_issue: false,
trace_timeline_status: 'empty',
})
);
expect(container).toBeEmptyDOMElement();
});
it('shows seconds for very short timelines', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'discover'})],
});
render(, {organization});
// Checking for the presence of seconds
expect(await screen.findAllByText(/\d{1,2}:\d{2}:\d{2} (AM|PM)/)).toHaveLength(5);
});
it('adds the current event if not in the api response', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'discover'})],
});
render(, {organization});
expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
});
it('skips the timeline and shows related issues (2 issues)', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: issuePlatformBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'discover'})],
});
// I believe the call to projects is to determine what projects a user belongs to
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/projects/`,
body: [],
});
render(, {
organization: OrganizationFixture({
features: ['related-issues-issue-details-page'],
}),
});
// Instead of a timeline, we should see the other related issue
expect(await screen.findByText('Slow DB Query')).toBeInTheDocument();
expect(
await screen.findByText('SELECT "sentry_monitorcheckin"."monitor_id"')
).toBeInTheDocument();
expect(screen.queryByLabelText('Current Event')).not.toBeInTheDocument();
// Test analytics
await userEvent.click(await screen.findByText('Slow DB Query'));
expect(trackAnalytics).toHaveBeenCalledTimes(1);
expect(trackAnalytics).toHaveBeenCalledWith(
'issue_details.related_trace_issue.trace_issue_clicked',
{
group_id: issuePlatformBody.data[0]['issue.id'],
organization: OrganizationFixture({
features: ['related-issues-issue-details-page'],
}),
}
);
});
it('skips the timeline and shows NO related issues (only 1 issue)', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
body: emptyBody,
match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/events/`,
// Only 1 issue
body: discoverBody,
match: [MockApiClient.matchQuery({dataset: 'discover'})],
});
// I believe the call to projects is to determine what projects a user belongs to
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/projects/`,
body: [],
});
render(, {
organization: OrganizationFixture({
features: ['related-issues-issue-details-page'],
}),
});
// We do not display any related issues because we only have 1 issue
expect(await screen.queryByText('Slow DB Query')).not.toBeInTheDocument();
expect(
await screen.queryByText('AttributeError: Something Failed')
).not.toBeInTheDocument();
// We do not display the timeline because we only have 1 event
expect(await screen.queryByLabelText('Current Event')).not.toBeInTheDocument();
expect(useRouteAnalyticsParams).toHaveBeenCalledWith({
has_related_trace_issue: false,
trace_timeline_status: 'empty',
});
});
});