index.spec.tsx 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230
  1. import {browserHistory, InjectedRouter} from 'react-router';
  2. import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
  3. import {initializeOrg} from 'sentry-test/initializeOrg';
  4. import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
  5. import ProjectsStore from 'sentry/stores/projectsStore';
  6. import TeamStore from 'sentry/stores/teamStore';
  7. import {Project} from 'sentry/types';
  8. import {
  9. MEPSetting,
  10. MEPState,
  11. } from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  12. import TransactionSummary from 'sentry/views/performance/transactionSummary/transactionOverview';
  13. import {RouteContext} from 'sentry/views/routeContext';
  14. const teams = [
  15. TestStubs.Team({id: '1', slug: 'team1', name: 'Team 1'}),
  16. TestStubs.Team({id: '2', slug: 'team2', name: 'Team 2'}),
  17. ];
  18. function initializeData({
  19. features: additionalFeatures = [],
  20. query = {},
  21. project: prj,
  22. }: {features?: string[]; project?: Project; query?: Record<string, any>} = {}) {
  23. const features = ['discover-basic', 'performance-view', ...additionalFeatures];
  24. const project = prj ?? TestStubs.Project({teams});
  25. const organization = TestStubs.Organization({
  26. features,
  27. projects: [project],
  28. apdexThreshold: 400,
  29. });
  30. const initialData = initializeOrg({
  31. ...initializeOrg(),
  32. organization,
  33. router: {
  34. location: {
  35. query: {
  36. transaction: '/performance',
  37. project: project.id,
  38. transactionCursor: '1:0:0',
  39. ...query,
  40. },
  41. },
  42. },
  43. });
  44. ProjectsStore.loadInitialData(initialData.organization.projects);
  45. TeamStore.loadInitialData(teams, false, null);
  46. return initialData;
  47. }
  48. const TestComponent = ({
  49. router,
  50. ...props
  51. }: React.ComponentProps<typeof TransactionSummary> & {
  52. router: InjectedRouter<Record<string, string>, any>;
  53. }) => {
  54. const client = new QueryClient();
  55. return (
  56. <QueryClientProvider client={client}>
  57. <RouteContext.Provider value={{router, ...router}}>
  58. <TransactionSummary {...props} />
  59. </RouteContext.Provider>
  60. </QueryClientProvider>
  61. );
  62. };
  63. describe('Performance > TransactionSummary', function () {
  64. beforeEach(function () {
  65. // @ts-ignore no-console
  66. // eslint-disable-next-line no-console
  67. jest.spyOn(console, 'error').mockImplementation(jest.fn());
  68. MockApiClient.clearMockResponses();
  69. MockApiClient.addMockResponse({
  70. url: '/organizations/org-slug/projects/',
  71. body: [],
  72. });
  73. MockApiClient.addMockResponse({
  74. url: '/organizations/org-slug/tags/',
  75. body: [],
  76. });
  77. MockApiClient.addMockResponse({
  78. url: '/organizations/org-slug/tags/user.email/values/',
  79. body: [],
  80. });
  81. MockApiClient.addMockResponse({
  82. url: '/organizations/org-slug/events-stats/',
  83. body: {data: [[123, []]]},
  84. });
  85. MockApiClient.addMockResponse({
  86. url: '/organizations/org-slug/releases/stats/',
  87. body: [],
  88. });
  89. MockApiClient.addMockResponse({
  90. url: '/organizations/org-slug/issues/?limit=5&project=2&query=is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  91. body: [],
  92. });
  93. MockApiClient.addMockResponse({
  94. url: '/organizations/org-slug/users/',
  95. body: [],
  96. });
  97. MockApiClient.addMockResponse({
  98. url: '/organizations/org-slug/recent-searches/',
  99. body: [],
  100. });
  101. MockApiClient.addMockResponse({
  102. url: '/organizations/org-slug/recent-searches/',
  103. method: 'POST',
  104. body: [],
  105. });
  106. MockApiClient.addMockResponse({
  107. url: '/organizations/org-slug/sdk-updates/',
  108. body: [],
  109. });
  110. MockApiClient.addMockResponse({
  111. url: '/prompts-activity/',
  112. body: {},
  113. });
  114. MockApiClient.addMockResponse({
  115. url: '/organizations/org-slug/events-facets-performance/',
  116. body: {},
  117. });
  118. // Mock totals for the sidebar and other summary data
  119. MockApiClient.addMockResponse({
  120. url: '/organizations/org-slug/eventsv2/',
  121. body: {
  122. meta: {
  123. count: 'number',
  124. apdex: 'number',
  125. count_miserable_user: 'number',
  126. user_misery: 'number',
  127. count_unique_user: 'number',
  128. p95: 'number',
  129. failure_rate: 'number',
  130. tpm: 'number',
  131. project_threshold_config: 'string',
  132. },
  133. data: [
  134. {
  135. count: 2,
  136. apdex: 0.6,
  137. count_miserable_user: 122,
  138. user_misery: 0.114,
  139. count_unique_user: 1,
  140. p95: 750.123,
  141. failure_rate: 1,
  142. tpm: 1,
  143. project_threshold_config: ['duration', 300],
  144. },
  145. ],
  146. },
  147. match: [
  148. (_url, options) => {
  149. return options.query?.field?.includes('p95()');
  150. },
  151. ],
  152. });
  153. // Eventsv2 Transaction list response
  154. MockApiClient.addMockResponse({
  155. url: '/organizations/org-slug/eventsv2/',
  156. headers: {
  157. Link:
  158. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=2:0:0>; rel="next"; results="true"; cursor="2:0:0",' +
  159. '<http://localhost/api/0/organizations/org-slug/eventsv2/?cursor=1:0:0>; rel="previous"; results="false"; cursor="1:0:0"',
  160. },
  161. body: {
  162. meta: {
  163. id: 'string',
  164. 'user.display': 'string',
  165. 'transaction.duration': 'duration',
  166. 'project.id': 'integer',
  167. timestamp: 'date',
  168. },
  169. data: [
  170. {
  171. id: 'deadbeef',
  172. 'user.display': 'uhoh@example.com',
  173. 'transaction.duration': 400,
  174. 'project.id': 2,
  175. timestamp: '2020-05-21T15:31:18+00:00',
  176. },
  177. ],
  178. },
  179. match: [
  180. (_url, options) => {
  181. return options.query?.field?.includes('user.display');
  182. },
  183. ],
  184. });
  185. // Eventsv2 Mock totals for status breakdown
  186. MockApiClient.addMockResponse({
  187. url: '/organizations/org-slug/eventsv2/',
  188. body: {
  189. meta: {
  190. 'transaction.status': 'string',
  191. count: 'number',
  192. },
  193. data: [
  194. {
  195. count: 2,
  196. 'transaction.status': 'ok',
  197. },
  198. ],
  199. },
  200. match: [
  201. (_url, options) => {
  202. return options.query?.field?.includes('transaction.status');
  203. },
  204. ],
  205. });
  206. // Events Mock totals for the sidebar and other summary data
  207. MockApiClient.addMockResponse({
  208. url: '/organizations/org-slug/events/',
  209. body: {
  210. meta: {
  211. fields: {
  212. 'count()': 'number',
  213. 'apdex()': 'number',
  214. 'count_miserable_user()': 'number',
  215. 'user_misery()': 'number',
  216. 'count_unique_user()': 'number',
  217. 'p95()': 'number',
  218. 'failure_rate()': 'number',
  219. 'tpm()': 'number',
  220. project_threshold_config: 'string',
  221. },
  222. },
  223. data: [
  224. {
  225. 'count()': 2,
  226. 'apdex()': 0.6,
  227. 'count_miserable_user()': 122,
  228. 'user_misery()': 0.114,
  229. 'count_unique_user()': 1,
  230. 'p95()': 750.123,
  231. 'failure_rate()': 1,
  232. 'tpm()': 1,
  233. project_threshold_config: ['duration', 300],
  234. },
  235. ],
  236. },
  237. match: [
  238. (_url, options) => {
  239. return options.query?.field?.includes('p95()');
  240. },
  241. ],
  242. });
  243. // Events Transaction list response
  244. MockApiClient.addMockResponse({
  245. url: '/organizations/org-slug/events/',
  246. headers: {
  247. Link:
  248. '<http://localhost/api/0/organizations/org-slug/events/?cursor=2:0:0>; rel="next"; results="true"; cursor="2:0:0",' +
  249. '<http://localhost/api/0/organizations/org-slug/events/?cursor=1:0:0>; rel="previous"; results="false"; cursor="1:0:0"',
  250. },
  251. body: {
  252. meta: {
  253. fields: {
  254. id: 'string',
  255. 'user.display': 'string',
  256. 'transaction.duration': 'duration',
  257. 'project.id': 'integer',
  258. timestamp: 'date',
  259. },
  260. },
  261. data: [
  262. {
  263. id: 'deadbeef',
  264. 'user.display': 'uhoh@example.com',
  265. 'transaction.duration': 400,
  266. 'project.id': 2,
  267. timestamp: '2020-05-21T15:31:18+00:00',
  268. },
  269. ],
  270. },
  271. match: [
  272. (_url, options) => {
  273. return options.query?.field?.includes('user.display');
  274. },
  275. ],
  276. });
  277. // Events Mock totals for status breakdown
  278. MockApiClient.addMockResponse({
  279. url: '/organizations/org-slug/events/',
  280. body: {
  281. meta: {
  282. fields: {
  283. 'transaction.status': 'string',
  284. 'count()': 'number',
  285. },
  286. },
  287. data: [
  288. {
  289. 'count()': 2,
  290. 'transaction.status': 'ok',
  291. },
  292. ],
  293. },
  294. match: [
  295. (_url, options) => {
  296. return options.query?.field?.includes('transaction.status');
  297. },
  298. ],
  299. });
  300. MockApiClient.addMockResponse({
  301. url: '/organizations/org-slug/events-facets/',
  302. body: [
  303. {
  304. key: 'release',
  305. topValues: [{count: 3, value: 'abcd123', name: 'abcd123'}],
  306. },
  307. {
  308. key: 'environment',
  309. topValues: [{count: 2, value: 'dev', name: 'dev'}],
  310. },
  311. {
  312. key: 'foo',
  313. topValues: [{count: 1, value: 'bar', name: 'bar'}],
  314. },
  315. {
  316. key: 'user',
  317. topValues: [{count: 1, value: 'id:100', name: '100'}],
  318. },
  319. ],
  320. });
  321. MockApiClient.addMockResponse({
  322. url: '/organizations/org-slug/project-transaction-threshold-override/',
  323. method: 'GET',
  324. body: {
  325. threshold: '800',
  326. metric: 'lcp',
  327. },
  328. });
  329. MockApiClient.addMockResponse({
  330. url: '/organizations/org-slug/events-vitals/',
  331. body: {
  332. 'measurements.fcp': {
  333. poor: 3,
  334. meh: 100,
  335. good: 47,
  336. total: 150,
  337. p75: 1500,
  338. },
  339. 'measurements.lcp': {
  340. poor: 2,
  341. meh: 38,
  342. good: 40,
  343. total: 80,
  344. p75: 2750,
  345. },
  346. 'measurements.fid': {
  347. poor: 2,
  348. meh: 53,
  349. good: 5,
  350. total: 60,
  351. p75: 1000,
  352. },
  353. 'measurements.cls': {
  354. poor: 3,
  355. meh: 10,
  356. good: 4,
  357. total: 17,
  358. p75: 0.2,
  359. },
  360. },
  361. });
  362. MockApiClient.addMockResponse({
  363. method: 'GET',
  364. url: `/organizations/org-slug/key-transactions-list/`,
  365. body: teams.map(({id}) => ({
  366. team: id,
  367. count: 0,
  368. keyed: [],
  369. })),
  370. });
  371. MockApiClient.addMockResponse({
  372. url: '/organizations/org-slug/events-has-measurements/',
  373. body: {measurements: false},
  374. });
  375. jest.spyOn(MEPSetting, 'get').mockImplementation(() => MEPState.auto);
  376. });
  377. afterEach(function () {
  378. MockApiClient.clearMockResponses();
  379. ProjectsStore.reset();
  380. jest.clearAllMocks();
  381. // @ts-ignore no-console
  382. // eslint-disable-next-line no-console
  383. console.error.mockRestore();
  384. });
  385. describe('with eventsv2', function () {
  386. it('renders basic UI elements', async function () {
  387. const {organization, router, routerContext} = initializeData();
  388. render(<TestComponent router={router} location={router.location} />, {
  389. context: routerContext,
  390. organization,
  391. });
  392. // It shows the header
  393. await screen.findByText('Transaction Summary');
  394. expect(screen.getByRole('heading', {name: '/performance'})).toBeInTheDocument();
  395. // It shows a chart
  396. expect(
  397. screen.getByRole('button', {name: 'Display Duration Breakdown'})
  398. ).toBeInTheDocument();
  399. // It shows a searchbar
  400. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  401. // It shows a table
  402. expect(screen.getByTestId('transactions-table')).toBeInTheDocument();
  403. // Ensure open in discover button exists.
  404. expect(screen.getByTestId('transaction-events-open')).toBeInTheDocument();
  405. // Ensure open issues button exists.
  406. expect(screen.getByRole('button', {name: 'Open in Issues'})).toBeInTheDocument();
  407. // Ensure transaction filter button exists
  408. expect(
  409. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  410. ).toBeInTheDocument();
  411. // Ensure ops breakdown filter exists
  412. expect(screen.getByTestId('span-operation-breakdown-filter')).toBeInTheDocument();
  413. // Ensure create alert from discover is hidden without metric alert
  414. expect(
  415. screen.queryByRole('button', {name: 'Create Alert'})
  416. ).not.toBeInTheDocument();
  417. // Ensure status breakdown exists
  418. expect(screen.getByText('Status Breakdown')).toBeInTheDocument();
  419. });
  420. it('renders feature flagged UI elements', function () {
  421. const {organization, router, routerContext} = initializeData({
  422. features: ['incidents'],
  423. });
  424. render(<TestComponent router={router} location={router.location} />, {
  425. context: routerContext,
  426. organization,
  427. });
  428. // Ensure create alert from discover is shown with metric alerts
  429. expect(screen.getByRole('button', {name: 'Create Alert'})).toBeInTheDocument();
  430. });
  431. it('renders Web Vitals widget', async function () {
  432. const {organization, router, routerContext} = initializeData({
  433. project: TestStubs.Project({teams, platform: 'javascript'}),
  434. query: {
  435. query:
  436. 'transaction.duration:<15m transaction.op:pageload event.type:transaction transaction:/organizations/:orgId/issues/',
  437. },
  438. });
  439. render(<TestComponent router={router} location={router.location} />, {
  440. context: routerContext,
  441. organization,
  442. });
  443. // It renders the web vitals widget
  444. await screen.findByRole('heading', {name: 'Web Vitals'});
  445. const vitalStatues = screen.getAllByTestId('vital-status');
  446. expect(vitalStatues).toHaveLength(3);
  447. expect(vitalStatues[0]).toHaveTextContent('31%');
  448. expect(vitalStatues[1]).toHaveTextContent('65%');
  449. expect(vitalStatues[2]).toHaveTextContent('3%');
  450. });
  451. it('renders sidebar widgets', async function () {
  452. const {organization, router, routerContext} = initializeData();
  453. render(<TestComponent router={router} location={router.location} />, {
  454. context: routerContext,
  455. organization,
  456. });
  457. // Renders Apdex widget
  458. await screen.findByRole('heading', {name: 'Apdex'});
  459. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  460. // Renders Failure Rate widget
  461. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  462. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  463. // Renders TPM widget
  464. expect(screen.getByRole('heading', {name: 'TPM'})).toBeInTheDocument();
  465. expect(screen.getByTestId('tpm-summary-value')).toHaveTextContent('1 tpm');
  466. });
  467. it('fetches transaction threshold', function () {
  468. const {organization, router, routerContext} = initializeData();
  469. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  470. url: '/organizations/org-slug/project-transaction-threshold-override/',
  471. method: 'GET',
  472. body: {
  473. threshold: '800',
  474. metric: 'lcp',
  475. },
  476. });
  477. const getProjectThresholdMock = MockApiClient.addMockResponse({
  478. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  479. method: 'GET',
  480. body: {
  481. threshold: '200',
  482. metric: 'duration',
  483. },
  484. });
  485. render(<TestComponent router={router} location={router.location} />, {
  486. context: routerContext,
  487. organization,
  488. });
  489. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  490. expect(getProjectThresholdMock).not.toHaveBeenCalled();
  491. });
  492. it('fetches project transaction threshdold', async function () {
  493. const {organization, router, routerContext} = initializeData();
  494. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  495. url: '/organizations/org-slug/project-transaction-threshold-override/',
  496. method: 'GET',
  497. statusCode: 404,
  498. });
  499. const getProjectThresholdMock = MockApiClient.addMockResponse({
  500. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  501. method: 'GET',
  502. body: {
  503. threshold: '200',
  504. metric: 'duration',
  505. },
  506. });
  507. render(<TestComponent router={router} location={router.location} />, {
  508. context: routerContext,
  509. organization,
  510. });
  511. await screen.findByText('Transaction Summary');
  512. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  513. expect(getProjectThresholdMock).toHaveBeenCalledTimes(1);
  514. });
  515. it('triggers a navigation on search', function () {
  516. const {organization, router, routerContext} = initializeData();
  517. render(<TestComponent router={router} location={router.location} />, {
  518. context: routerContext,
  519. organization,
  520. });
  521. // Fill out the search box, and submit it.
  522. userEvent.type(screen.getByLabelText('Search events'), 'user.email:uhoh*{enter}');
  523. // Check the navigation.
  524. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  525. expect(browserHistory.push).toHaveBeenCalledWith({
  526. pathname: undefined,
  527. query: {
  528. transaction: '/performance',
  529. project: '2',
  530. statsPeriod: '14d',
  531. query: 'user.email:uhoh*',
  532. transactionCursor: '1:0:0',
  533. },
  534. });
  535. });
  536. it('can mark a transaction as key', async function () {
  537. const {organization, router, routerContext} = initializeData();
  538. render(<TestComponent router={router} location={router.location} />, {
  539. context: routerContext,
  540. organization,
  541. });
  542. const mockUpdate = MockApiClient.addMockResponse({
  543. url: `/organizations/org-slug/key-transactions/`,
  544. method: 'POST',
  545. body: {},
  546. });
  547. await screen.findByRole('button', {name: 'Star for Team'});
  548. // Click the key transaction button
  549. userEvent.click(screen.getByRole('button', {name: 'Star for Team'}));
  550. userEvent.click(screen.getByText('team1'), undefined, {
  551. skipPointerEventsCheck: true,
  552. });
  553. // Ensure request was made.
  554. expect(mockUpdate).toHaveBeenCalled();
  555. });
  556. it('triggers a navigation on transaction filter', async function () {
  557. const {organization, router, routerContext} = initializeData();
  558. render(<TestComponent router={router} location={router.location} />, {
  559. context: routerContext,
  560. organization,
  561. });
  562. await screen.findByText('Transaction Summary');
  563. // Open the transaction filter dropdown
  564. userEvent.click(
  565. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  566. );
  567. userEvent.click(screen.getAllByText('Slow Transactions (p95)')[1]);
  568. // Check the navigation.
  569. expect(browserHistory.push).toHaveBeenCalledWith({
  570. pathname: undefined,
  571. query: {
  572. transaction: '/performance',
  573. project: '2',
  574. showTransactions: 'slow',
  575. transactionCursor: undefined,
  576. },
  577. });
  578. });
  579. it('renders pagination buttons', async function () {
  580. const {organization, router, routerContext} = initializeData();
  581. render(<TestComponent router={router} location={router.location} />, {
  582. context: routerContext,
  583. organization,
  584. });
  585. await screen.findByText('Transaction Summary');
  586. expect(await screen.findByLabelText('Previous')).toBeInTheDocument();
  587. // Click the 'next' button
  588. userEvent.click(screen.getByLabelText('Next'));
  589. // Check the navigation.
  590. expect(browserHistory.push).toHaveBeenCalledWith({
  591. pathname: undefined,
  592. query: {
  593. transaction: '/performance',
  594. project: '2',
  595. transactionCursor: '2:0:0',
  596. },
  597. });
  598. });
  599. it('forwards conditions to related issues', async function () {
  600. const issueGet = MockApiClient.addMockResponse({
  601. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  602. body: [],
  603. });
  604. const {organization, router, routerContext} = initializeData({
  605. query: {query: 'tag:value'},
  606. });
  607. render(<TestComponent router={router} location={router.location} />, {
  608. context: routerContext,
  609. organization,
  610. });
  611. await screen.findByText('Transaction Summary');
  612. expect(issueGet).toHaveBeenCalled();
  613. });
  614. it('does not forward event type to related issues', async function () {
  615. const issueGet = MockApiClient.addMockResponse({
  616. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  617. body: [],
  618. match: [
  619. (_, options) => {
  620. // event.type must NOT be in the query params
  621. return !options.query?.query?.includes('event.type');
  622. },
  623. ],
  624. });
  625. const {organization, router, routerContext} = initializeData({
  626. query: {query: 'tag:value event.type:transaction'},
  627. });
  628. render(<TestComponent router={router} location={router.location} />, {
  629. context: routerContext,
  630. organization,
  631. });
  632. await screen.findByText('Transaction Summary');
  633. expect(issueGet).toHaveBeenCalled();
  634. });
  635. it('renders the suspect spans table if the feature is enabled', async function () {
  636. MockApiClient.addMockResponse({
  637. url: '/organizations/org-slug/events-spans-performance/',
  638. body: [],
  639. });
  640. const {organization, router, routerContext} = initializeData({
  641. features: ['performance-suspect-spans-view'],
  642. });
  643. render(<TestComponent router={router} location={router.location} />, {
  644. context: routerContext,
  645. organization,
  646. });
  647. expect(await screen.findByText('Suspect Spans')).toBeInTheDocument();
  648. });
  649. it('adds search condition on transaction status when clicking on status breakdown', async function () {
  650. const {organization, router, routerContext} = initializeData();
  651. render(<TestComponent router={router} location={router.location} />, {
  652. context: routerContext,
  653. organization,
  654. });
  655. await screen.findByTestId('status-ok');
  656. userEvent.click(screen.getByTestId('status-ok'));
  657. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  658. expect(browserHistory.push).toHaveBeenCalledWith(
  659. expect.objectContaining({
  660. query: expect.objectContaining({
  661. query: expect.stringContaining('transaction.status:ok'),
  662. }),
  663. })
  664. );
  665. });
  666. it('appends tag value to existing query when clicked', async function () {
  667. const {organization, router, routerContext} = initializeData();
  668. render(<TestComponent router={router} location={router.location} />, {
  669. context: routerContext,
  670. organization,
  671. });
  672. await screen.findByText('Tag Summary');
  673. userEvent.click(
  674. screen.getByLabelText('Add the environment dev segment tag to the search query')
  675. );
  676. userEvent.click(
  677. screen.getByLabelText('Add the foo bar segment tag to the search query')
  678. );
  679. userEvent.click(
  680. screen.getByLabelText('Add the user id:100 segment tag to the search query')
  681. );
  682. expect(router.push).toHaveBeenCalledTimes(3);
  683. expect(router.push).toHaveBeenNthCalledWith(1, {
  684. query: {
  685. project: '2',
  686. query: 'tags[environment]:dev',
  687. transaction: '/performance',
  688. transactionCursor: '1:0:0',
  689. },
  690. });
  691. expect(router.push).toHaveBeenNthCalledWith(2, {
  692. query: {
  693. project: '2',
  694. query: 'foo:bar',
  695. transaction: '/performance',
  696. transactionCursor: '1:0:0',
  697. },
  698. });
  699. expect(router.push).toHaveBeenNthCalledWith(3, {
  700. query: {
  701. project: '2',
  702. query: 'user:"id:100"',
  703. transaction: '/performance',
  704. transactionCursor: '1:0:0',
  705. },
  706. });
  707. });
  708. });
  709. describe('with events', function () {
  710. it('renders basic UI elements', async function () {
  711. const {organization, router, routerContext} = initializeData({
  712. features: ['performance-frontend-use-events-endpoint'],
  713. });
  714. render(<TestComponent router={router} location={router.location} />, {
  715. context: routerContext,
  716. organization,
  717. });
  718. // It shows the header
  719. await screen.findByText('Transaction Summary');
  720. expect(screen.getByRole('heading', {name: '/performance'})).toBeInTheDocument();
  721. // It shows a chart
  722. expect(
  723. screen.getByRole('button', {name: 'Display Duration Breakdown'})
  724. ).toBeInTheDocument();
  725. // It shows a searchbar
  726. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  727. // It shows a table
  728. expect(screen.getByTestId('transactions-table')).toBeInTheDocument();
  729. // Ensure open in discover button exists.
  730. expect(screen.getByTestId('transaction-events-open')).toBeInTheDocument();
  731. // Ensure open issues button exists.
  732. expect(screen.getByRole('button', {name: 'Open in Issues'})).toBeInTheDocument();
  733. // Ensure transaction filter button exists
  734. expect(
  735. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  736. ).toBeInTheDocument();
  737. // Ensure create alert from discover is hidden without metric alert
  738. expect(
  739. screen.queryByRole('button', {name: 'Create Alert'})
  740. ).not.toBeInTheDocument();
  741. // Ensure status breakdown exists
  742. expect(screen.getByText('Status Breakdown')).toBeInTheDocument();
  743. });
  744. it('renders feature flagged UI elements', function () {
  745. const {organization, router, routerContext} = initializeData({
  746. features: ['incidents', 'performance-frontend-use-events-endpoint'],
  747. });
  748. render(<TestComponent router={router} location={router.location} />, {
  749. context: routerContext,
  750. organization,
  751. });
  752. // Ensure create alert from discover is shown with metric alerts
  753. expect(screen.getByRole('button', {name: 'Create Alert'})).toBeInTheDocument();
  754. });
  755. it('renders Web Vitals widget', async function () {
  756. const {organization, router, routerContext} = initializeData({
  757. features: ['performance-frontend-use-events-endpoint'],
  758. project: TestStubs.Project({teams, platform: 'javascript'}),
  759. query: {
  760. query:
  761. 'transaction.duration:<15m transaction.op:pageload event.type:transaction transaction:/organizations/:orgId/issues/',
  762. },
  763. });
  764. render(<TestComponent router={router} location={router.location} />, {
  765. context: routerContext,
  766. organization,
  767. });
  768. // It renders the web vitals widget
  769. await screen.findByRole('heading', {name: 'Web Vitals'});
  770. const vitalStatues = screen.getAllByTestId('vital-status');
  771. expect(vitalStatues).toHaveLength(3);
  772. expect(vitalStatues[0]).toHaveTextContent('31%');
  773. expect(vitalStatues[1]).toHaveTextContent('65%');
  774. expect(vitalStatues[2]).toHaveTextContent('3%');
  775. });
  776. it('renders sidebar widgets', async function () {
  777. const {organization, router, routerContext} = initializeData({
  778. features: ['performance-frontend-use-events-endpoint'],
  779. });
  780. render(<TestComponent router={router} location={router.location} />, {
  781. context: routerContext,
  782. organization,
  783. });
  784. // Renders Apdex widget
  785. await screen.findByRole('heading', {name: 'Apdex'});
  786. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  787. // Renders Failure Rate widget
  788. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  789. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  790. // Renders TPM widget
  791. expect(screen.getByRole('heading', {name: 'TPM'})).toBeInTheDocument();
  792. expect(screen.getByTestId('tpm-summary-value')).toHaveTextContent('1 tpm');
  793. });
  794. it('fetches transaction threshold', function () {
  795. const {organization, router, routerContext} = initializeData({
  796. features: ['performance-frontend-use-events-endpoint'],
  797. });
  798. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  799. url: '/organizations/org-slug/project-transaction-threshold-override/',
  800. method: 'GET',
  801. body: {
  802. threshold: '800',
  803. metric: 'lcp',
  804. },
  805. });
  806. const getProjectThresholdMock = MockApiClient.addMockResponse({
  807. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  808. method: 'GET',
  809. body: {
  810. threshold: '200',
  811. metric: 'duration',
  812. },
  813. });
  814. render(<TestComponent router={router} location={router.location} />, {
  815. context: routerContext,
  816. organization,
  817. });
  818. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  819. expect(getProjectThresholdMock).not.toHaveBeenCalled();
  820. });
  821. it('fetches project transaction threshdold', async function () {
  822. const {organization, router, routerContext} = initializeData({
  823. features: ['performance-frontend-use-events-endpoint'],
  824. });
  825. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  826. url: '/organizations/org-slug/project-transaction-threshold-override/',
  827. method: 'GET',
  828. statusCode: 404,
  829. });
  830. const getProjectThresholdMock = MockApiClient.addMockResponse({
  831. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  832. method: 'GET',
  833. body: {
  834. threshold: '200',
  835. metric: 'duration',
  836. },
  837. });
  838. render(<TestComponent router={router} location={router.location} />, {
  839. context: routerContext,
  840. organization,
  841. });
  842. await screen.findByText('Transaction Summary');
  843. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  844. expect(getProjectThresholdMock).toHaveBeenCalledTimes(1);
  845. });
  846. it('triggers a navigation on search', function () {
  847. const {organization, router, routerContext} = initializeData({
  848. features: ['performance-frontend-use-events-endpoint'],
  849. });
  850. render(<TestComponent router={router} location={router.location} />, {
  851. context: routerContext,
  852. organization,
  853. });
  854. // Fill out the search box, and submit it.
  855. userEvent.type(screen.getByLabelText('Search events'), 'user.email:uhoh*{enter}');
  856. // Check the navigation.
  857. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  858. expect(browserHistory.push).toHaveBeenCalledWith({
  859. pathname: undefined,
  860. query: {
  861. transaction: '/performance',
  862. project: '2',
  863. statsPeriod: '14d',
  864. query: 'user.email:uhoh*',
  865. transactionCursor: '1:0:0',
  866. },
  867. });
  868. });
  869. it('can mark a transaction as key', async function () {
  870. const {organization, router, routerContext} = initializeData({
  871. features: ['performance-frontend-use-events-endpoint'],
  872. });
  873. render(<TestComponent router={router} location={router.location} />, {
  874. context: routerContext,
  875. organization,
  876. });
  877. const mockUpdate = MockApiClient.addMockResponse({
  878. url: `/organizations/org-slug/key-transactions/`,
  879. method: 'POST',
  880. body: {},
  881. });
  882. await screen.findByRole('button', {name: 'Star for Team'});
  883. // Click the key transaction button
  884. userEvent.click(screen.getByRole('button', {name: 'Star for Team'}));
  885. userEvent.click(screen.getByText('team1'), undefined, {
  886. skipPointerEventsCheck: true,
  887. });
  888. // Ensure request was made.
  889. expect(mockUpdate).toHaveBeenCalled();
  890. });
  891. it('triggers a navigation on transaction filter', async function () {
  892. const {organization, router, routerContext} = initializeData({
  893. features: ['performance-frontend-use-events-endpoint'],
  894. });
  895. render(<TestComponent router={router} location={router.location} />, {
  896. context: routerContext,
  897. organization,
  898. });
  899. await screen.findByText('Transaction Summary');
  900. // Open the transaction filter dropdown
  901. userEvent.click(
  902. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  903. );
  904. userEvent.click(screen.getAllByText('Slow Transactions (p95)')[1]);
  905. // Check the navigation.
  906. expect(browserHistory.push).toHaveBeenCalledWith({
  907. pathname: undefined,
  908. query: {
  909. transaction: '/performance',
  910. project: '2',
  911. showTransactions: 'slow',
  912. transactionCursor: undefined,
  913. },
  914. });
  915. });
  916. it('renders pagination buttons', async function () {
  917. const {organization, router, routerContext} = initializeData({
  918. features: ['performance-frontend-use-events-endpoint'],
  919. });
  920. render(<TestComponent router={router} location={router.location} />, {
  921. context: routerContext,
  922. organization,
  923. });
  924. await screen.findByText('Transaction Summary');
  925. expect(await screen.findByLabelText('Previous')).toBeInTheDocument();
  926. // Click the 'next' button
  927. userEvent.click(screen.getByLabelText('Next'));
  928. // Check the navigation.
  929. expect(browserHistory.push).toHaveBeenCalledWith({
  930. pathname: undefined,
  931. query: {
  932. transaction: '/performance',
  933. project: '2',
  934. transactionCursor: '2:0:0',
  935. },
  936. });
  937. });
  938. it('forwards conditions to related issues', async function () {
  939. const issueGet = MockApiClient.addMockResponse({
  940. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  941. body: [],
  942. });
  943. const {organization, router, routerContext} = initializeData({
  944. features: ['performance-frontend-use-events-endpoint'],
  945. query: {query: 'tag:value'},
  946. });
  947. render(<TestComponent router={router} location={router.location} />, {
  948. context: routerContext,
  949. organization,
  950. });
  951. await screen.findByText('Transaction Summary');
  952. expect(issueGet).toHaveBeenCalled();
  953. });
  954. it('does not forward event type to related issues', async function () {
  955. const issueGet = MockApiClient.addMockResponse({
  956. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  957. body: [],
  958. match: [
  959. (_, options) => {
  960. // event.type must NOT be in the query params
  961. return !options.query?.query?.includes('event.type');
  962. },
  963. ],
  964. });
  965. const {organization, router, routerContext} = initializeData({
  966. features: ['performance-frontend-use-events-endpoint'],
  967. query: {query: 'tag:value event.type:transaction'},
  968. });
  969. render(<TestComponent router={router} location={router.location} />, {
  970. context: routerContext,
  971. organization,
  972. });
  973. await screen.findByText('Transaction Summary');
  974. expect(issueGet).toHaveBeenCalled();
  975. });
  976. it('renders the suspect spans table if the feature is enabled', async function () {
  977. MockApiClient.addMockResponse({
  978. url: '/organizations/org-slug/events-spans-performance/',
  979. body: [],
  980. });
  981. const {organization, router, routerContext} = initializeData({
  982. features: [
  983. 'performance-suspect-spans-view',
  984. 'performance-frontend-use-events-endpoint',
  985. ],
  986. });
  987. render(<TestComponent router={router} location={router.location} />, {
  988. context: routerContext,
  989. organization,
  990. });
  991. expect(await screen.findByText('Suspect Spans')).toBeInTheDocument();
  992. });
  993. it('adds search condition on transaction status when clicking on status breakdown', async function () {
  994. const {organization, router, routerContext} = initializeData({
  995. features: ['performance-frontend-use-events-endpoint'],
  996. });
  997. render(<TestComponent router={router} location={router.location} />, {
  998. context: routerContext,
  999. organization,
  1000. });
  1001. await screen.findByTestId('status-ok');
  1002. userEvent.click(screen.getByTestId('status-ok'));
  1003. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  1004. expect(browserHistory.push).toHaveBeenCalledWith(
  1005. expect.objectContaining({
  1006. query: expect.objectContaining({
  1007. query: expect.stringContaining('transaction.status:ok'),
  1008. }),
  1009. })
  1010. );
  1011. });
  1012. it('appends tag value to existing query when clicked', async function () {
  1013. const {organization, router, routerContext} = initializeData({
  1014. features: ['performance-frontend-use-events-endpoint'],
  1015. });
  1016. render(<TestComponent router={router} location={router.location} />, {
  1017. context: routerContext,
  1018. organization,
  1019. });
  1020. await screen.findByText('Tag Summary');
  1021. userEvent.click(
  1022. screen.getByLabelText('Add the environment dev segment tag to the search query')
  1023. );
  1024. userEvent.click(
  1025. screen.getByLabelText('Add the foo bar segment tag to the search query')
  1026. );
  1027. expect(router.push).toHaveBeenCalledTimes(2);
  1028. expect(router.push).toHaveBeenNthCalledWith(1, {
  1029. query: {
  1030. project: '2',
  1031. query: 'tags[environment]:dev',
  1032. transaction: '/performance',
  1033. transactionCursor: '1:0:0',
  1034. },
  1035. });
  1036. expect(router.push).toHaveBeenNthCalledWith(2, {
  1037. query: {
  1038. project: '2',
  1039. query: 'foo:bar',
  1040. transaction: '/performance',
  1041. transactionCursor: '1:0:0',
  1042. },
  1043. });
  1044. });
  1045. });
  1046. });