index.spec.tsx 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264
  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 Mock unfiltered totals for percentage calculations
  244. MockApiClient.addMockResponse({
  245. url: '/organizations/org-slug/events/',
  246. body: {
  247. meta: {
  248. fields: {
  249. 'tpm()': 'number',
  250. 'count()': 'number',
  251. },
  252. },
  253. data: [
  254. {
  255. 'count()': 2,
  256. 'tpm()': 1,
  257. },
  258. ],
  259. },
  260. match: [
  261. (_url, options) => {
  262. return (
  263. options.query?.field?.includes('tpm()') &&
  264. !options.query?.field?.includes('p95()')
  265. );
  266. },
  267. ],
  268. });
  269. // Events Transaction list response
  270. MockApiClient.addMockResponse({
  271. url: '/organizations/org-slug/events/',
  272. headers: {
  273. Link:
  274. '<http://localhost/api/0/organizations/org-slug/events/?cursor=2:0:0>; rel="next"; results="true"; cursor="2:0:0",' +
  275. '<http://localhost/api/0/organizations/org-slug/events/?cursor=1:0:0>; rel="previous"; results="false"; cursor="1:0:0"',
  276. },
  277. body: {
  278. meta: {
  279. fields: {
  280. id: 'string',
  281. 'user.display': 'string',
  282. 'transaction.duration': 'duration',
  283. 'project.id': 'integer',
  284. timestamp: 'date',
  285. },
  286. },
  287. data: [
  288. {
  289. id: 'deadbeef',
  290. 'user.display': 'uhoh@example.com',
  291. 'transaction.duration': 400,
  292. 'project.id': 2,
  293. timestamp: '2020-05-21T15:31:18+00:00',
  294. },
  295. ],
  296. },
  297. match: [
  298. (_url, options) => {
  299. return options.query?.field?.includes('user.display');
  300. },
  301. ],
  302. });
  303. // Events Mock totals for status breakdown
  304. MockApiClient.addMockResponse({
  305. url: '/organizations/org-slug/events/',
  306. body: {
  307. meta: {
  308. fields: {
  309. 'transaction.status': 'string',
  310. 'count()': 'number',
  311. },
  312. },
  313. data: [
  314. {
  315. 'count()': 2,
  316. 'transaction.status': 'ok',
  317. },
  318. ],
  319. },
  320. match: [
  321. (_url, options) => {
  322. return options.query?.field?.includes('transaction.status');
  323. },
  324. ],
  325. });
  326. MockApiClient.addMockResponse({
  327. url: '/organizations/org-slug/events-facets/',
  328. body: [
  329. {
  330. key: 'release',
  331. topValues: [{count: 3, value: 'abcd123', name: 'abcd123'}],
  332. },
  333. {
  334. key: 'environment',
  335. topValues: [
  336. {count: 2, value: 'dev', name: 'dev'},
  337. {count: 1, value: 'prod', name: 'prod'},
  338. ],
  339. },
  340. {
  341. key: 'foo',
  342. topValues: [
  343. {count: 2, value: 'bar', name: 'bar'},
  344. {count: 1, value: 'baz', name: 'baz'},
  345. ],
  346. },
  347. {
  348. key: 'user',
  349. topValues: [
  350. {count: 2, value: 'id:100', name: '100'},
  351. {count: 1, value: 'id:101', name: '101'},
  352. ],
  353. },
  354. ],
  355. });
  356. MockApiClient.addMockResponse({
  357. url: '/organizations/org-slug/project-transaction-threshold-override/',
  358. method: 'GET',
  359. body: {
  360. threshold: '800',
  361. metric: 'lcp',
  362. },
  363. });
  364. MockApiClient.addMockResponse({
  365. url: '/organizations/org-slug/events-vitals/',
  366. body: {
  367. 'measurements.fcp': {
  368. poor: 3,
  369. meh: 100,
  370. good: 47,
  371. total: 150,
  372. p75: 1500,
  373. },
  374. 'measurements.lcp': {
  375. poor: 2,
  376. meh: 38,
  377. good: 40,
  378. total: 80,
  379. p75: 2750,
  380. },
  381. 'measurements.fid': {
  382. poor: 2,
  383. meh: 53,
  384. good: 5,
  385. total: 60,
  386. p75: 1000,
  387. },
  388. 'measurements.cls': {
  389. poor: 3,
  390. meh: 10,
  391. good: 4,
  392. total: 17,
  393. p75: 0.2,
  394. },
  395. },
  396. });
  397. MockApiClient.addMockResponse({
  398. method: 'GET',
  399. url: `/organizations/org-slug/key-transactions-list/`,
  400. body: teams.map(({id}) => ({
  401. team: id,
  402. count: 0,
  403. keyed: [],
  404. })),
  405. });
  406. MockApiClient.addMockResponse({
  407. url: '/organizations/org-slug/events-has-measurements/',
  408. body: {measurements: false},
  409. });
  410. MockApiClient.addMockResponse({
  411. url: '/organizations/org-slug/events-spans-performance/',
  412. body: [
  413. {
  414. op: 'ui.long-task',
  415. group: 'c777169faad84eb4',
  416. description: 'Main UI thread blocked',
  417. frequency: 713,
  418. count: 9040,
  419. avgOccurrences: null,
  420. sumExclusiveTime: 1743893.9822921753,
  421. p50ExclusiveTime: null,
  422. p75ExclusiveTime: 244.9998779296875,
  423. p95ExclusiveTime: null,
  424. p99ExclusiveTime: null,
  425. },
  426. ],
  427. });
  428. jest.spyOn(MEPSetting, 'get').mockImplementation(() => MEPState.auto);
  429. });
  430. afterEach(function () {
  431. MockApiClient.clearMockResponses();
  432. ProjectsStore.reset();
  433. jest.clearAllMocks();
  434. jest.restoreAllMocks();
  435. });
  436. describe('with discover', function () {
  437. it('renders basic UI elements', async function () {
  438. const {organization, router, routerContext} = initializeData();
  439. render(<TestComponent router={router} location={router.location} />, {
  440. context: routerContext,
  441. organization,
  442. });
  443. // It shows the header
  444. await screen.findByText('Transaction Summary');
  445. expect(screen.getByRole('heading', {name: '/performance'})).toBeInTheDocument();
  446. // It shows a chart
  447. expect(
  448. screen.getByRole('button', {name: 'Display Duration Breakdown'})
  449. ).toBeInTheDocument();
  450. // It shows a searchbar
  451. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  452. // It shows a table
  453. expect(screen.getByTestId('transactions-table')).toBeInTheDocument();
  454. // Ensure open in discover button exists.
  455. expect(screen.getByTestId('transaction-events-open')).toBeInTheDocument();
  456. // Ensure open issues button exists.
  457. expect(screen.getByRole('button', {name: 'Open in Issues'})).toBeInTheDocument();
  458. // Ensure transaction filter button exists
  459. expect(
  460. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  461. ).toBeInTheDocument();
  462. // Ensure ops breakdown filter exists
  463. expect(
  464. screen.getByRole('button', {name: 'Filter by operation'})
  465. ).toBeInTheDocument();
  466. // Ensure create alert from discover is hidden without metric alert
  467. expect(
  468. screen.queryByRole('button', {name: 'Create Alert'})
  469. ).not.toBeInTheDocument();
  470. // Ensure status breakdown exists
  471. expect(screen.getByText('Status Breakdown')).toBeInTheDocument();
  472. });
  473. it('renders feature flagged UI elements', function () {
  474. const {organization, router, routerContext} = initializeData({
  475. features: ['incidents'],
  476. });
  477. render(<TestComponent router={router} location={router.location} />, {
  478. context: routerContext,
  479. organization,
  480. });
  481. // Ensure create alert from discover is shown with metric alerts
  482. expect(screen.getByRole('button', {name: 'Create Alert'})).toBeInTheDocument();
  483. });
  484. it('renders Web Vitals widget', async function () {
  485. const {organization, router, routerContext} = initializeData({
  486. project: TestStubs.Project({teams, platform: 'javascript'}),
  487. query: {
  488. query:
  489. 'transaction.duration:<15m transaction.op:pageload event.type:transaction transaction:/organizations/:orgId/issues/',
  490. },
  491. });
  492. render(<TestComponent router={router} location={router.location} />, {
  493. context: routerContext,
  494. organization,
  495. });
  496. // It renders the web vitals widget
  497. await screen.findByRole('heading', {name: 'Web Vitals'});
  498. const vitalStatues = screen.getAllByTestId('vital-status');
  499. expect(vitalStatues).toHaveLength(3);
  500. expect(vitalStatues[0]).toHaveTextContent('31%');
  501. expect(vitalStatues[1]).toHaveTextContent('65%');
  502. expect(vitalStatues[2]).toHaveTextContent('3%');
  503. });
  504. it('renders sidebar widgets', async function () {
  505. const {organization, router, routerContext} = initializeData();
  506. render(<TestComponent router={router} location={router.location} />, {
  507. context: routerContext,
  508. organization,
  509. });
  510. // Renders Apdex widget
  511. await screen.findByRole('heading', {name: 'Apdex'});
  512. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  513. // Renders Failure Rate widget
  514. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  515. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  516. // Renders TPM widget
  517. expect(screen.getByRole('heading', {name: 'TPM'})).toBeInTheDocument();
  518. expect(screen.getByTestId('tpm-summary-value')).toHaveTextContent('1 tpm');
  519. });
  520. it('fetches transaction threshold', function () {
  521. const {organization, router, routerContext} = initializeData();
  522. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  523. url: '/organizations/org-slug/project-transaction-threshold-override/',
  524. method: 'GET',
  525. body: {
  526. threshold: '800',
  527. metric: 'lcp',
  528. },
  529. });
  530. const getProjectThresholdMock = MockApiClient.addMockResponse({
  531. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  532. method: 'GET',
  533. body: {
  534. threshold: '200',
  535. metric: 'duration',
  536. },
  537. });
  538. render(<TestComponent router={router} location={router.location} />, {
  539. context: routerContext,
  540. organization,
  541. });
  542. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  543. expect(getProjectThresholdMock).not.toHaveBeenCalled();
  544. });
  545. it('fetches project transaction threshdold', async function () {
  546. const {organization, router, routerContext} = initializeData();
  547. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  548. url: '/organizations/org-slug/project-transaction-threshold-override/',
  549. method: 'GET',
  550. statusCode: 404,
  551. });
  552. const getProjectThresholdMock = MockApiClient.addMockResponse({
  553. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  554. method: 'GET',
  555. body: {
  556. threshold: '200',
  557. metric: 'duration',
  558. },
  559. });
  560. render(<TestComponent router={router} location={router.location} />, {
  561. context: routerContext,
  562. organization,
  563. });
  564. await screen.findByText('Transaction Summary');
  565. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  566. expect(getProjectThresholdMock).toHaveBeenCalledTimes(1);
  567. });
  568. it('triggers a navigation on search', function () {
  569. const {organization, router, routerContext} = initializeData();
  570. render(<TestComponent router={router} location={router.location} />, {
  571. context: routerContext,
  572. organization,
  573. });
  574. // Fill out the search box, and submit it.
  575. userEvent.type(screen.getByLabelText('Search events'), 'user.email:uhoh*{enter}');
  576. // Check the navigation.
  577. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  578. expect(browserHistory.push).toHaveBeenCalledWith({
  579. pathname: undefined,
  580. query: {
  581. transaction: '/performance',
  582. project: '2',
  583. statsPeriod: '14d',
  584. query: 'user.email:uhoh*',
  585. transactionCursor: '1:0:0',
  586. },
  587. });
  588. });
  589. it('can mark a transaction as key', async function () {
  590. const {organization, router, routerContext} = initializeData();
  591. render(<TestComponent router={router} location={router.location} />, {
  592. context: routerContext,
  593. organization,
  594. });
  595. const mockUpdate = MockApiClient.addMockResponse({
  596. url: `/organizations/org-slug/key-transactions/`,
  597. method: 'POST',
  598. body: {},
  599. });
  600. await screen.findByRole('button', {name: 'Star for Team'});
  601. // Click the key transaction button
  602. userEvent.click(screen.getByRole('button', {name: 'Star for Team'}));
  603. userEvent.click(screen.getByText('team1'), undefined, {
  604. skipPointerEventsCheck: true,
  605. });
  606. // Ensure request was made.
  607. expect(mockUpdate).toHaveBeenCalled();
  608. });
  609. it('triggers a navigation on transaction filter', async function () {
  610. const {organization, router, routerContext} = initializeData();
  611. render(<TestComponent router={router} location={router.location} />, {
  612. context: routerContext,
  613. organization,
  614. });
  615. await screen.findByText('Transaction Summary');
  616. // Open the transaction filter dropdown
  617. userEvent.click(
  618. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  619. );
  620. userEvent.click(screen.getAllByText('Slow Transactions (p95)')[1]);
  621. // Check the navigation.
  622. expect(browserHistory.push).toHaveBeenCalledWith({
  623. pathname: undefined,
  624. query: {
  625. transaction: '/performance',
  626. project: '2',
  627. showTransactions: 'slow',
  628. transactionCursor: undefined,
  629. },
  630. });
  631. });
  632. it('renders pagination buttons', async function () {
  633. const {organization, router, routerContext} = initializeData();
  634. render(<TestComponent router={router} location={router.location} />, {
  635. context: routerContext,
  636. organization,
  637. });
  638. await screen.findByText('Transaction Summary');
  639. expect(await screen.findByLabelText('Previous')).toBeInTheDocument();
  640. // Click the 'next' button
  641. userEvent.click(screen.getByLabelText('Next'));
  642. // Check the navigation.
  643. expect(browserHistory.push).toHaveBeenCalledWith({
  644. pathname: undefined,
  645. query: {
  646. transaction: '/performance',
  647. project: '2',
  648. transactionCursor: '2:0:0',
  649. },
  650. });
  651. });
  652. it('forwards conditions to related issues', async function () {
  653. const issueGet = MockApiClient.addMockResponse({
  654. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  655. body: [],
  656. });
  657. const {organization, router, routerContext} = initializeData({
  658. query: {query: 'tag:value'},
  659. });
  660. render(<TestComponent router={router} location={router.location} />, {
  661. context: routerContext,
  662. organization,
  663. });
  664. await screen.findByText('Transaction Summary');
  665. expect(issueGet).toHaveBeenCalled();
  666. });
  667. it('does not forward event type to related issues', async function () {
  668. const issueGet = MockApiClient.addMockResponse({
  669. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  670. body: [],
  671. match: [
  672. (_, options) => {
  673. // event.type must NOT be in the query params
  674. return !options.query?.query?.includes('event.type');
  675. },
  676. ],
  677. });
  678. const {organization, router, routerContext} = initializeData({
  679. query: {query: 'tag:value event.type:transaction'},
  680. });
  681. render(<TestComponent router={router} location={router.location} />, {
  682. context: routerContext,
  683. organization,
  684. });
  685. await screen.findByText('Transaction Summary');
  686. expect(issueGet).toHaveBeenCalled();
  687. });
  688. it('renders the suspect spans table if the feature is enabled', async function () {
  689. MockApiClient.addMockResponse({
  690. url: '/organizations/org-slug/events-spans-performance/',
  691. body: [],
  692. });
  693. const {organization, router, routerContext} = initializeData({});
  694. render(<TestComponent router={router} location={router.location} />, {
  695. context: routerContext,
  696. organization,
  697. });
  698. expect(await screen.findByText('Suspect Spans')).toBeInTheDocument();
  699. });
  700. it('adds search condition on transaction status when clicking on status breakdown', async function () {
  701. const {organization, router, routerContext} = initializeData();
  702. render(<TestComponent router={router} location={router.location} />, {
  703. context: routerContext,
  704. organization,
  705. });
  706. await screen.findByTestId('status-ok');
  707. userEvent.click(screen.getByTestId('status-ok'));
  708. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  709. expect(browserHistory.push).toHaveBeenCalledWith(
  710. expect.objectContaining({
  711. query: expect.objectContaining({
  712. query: expect.stringContaining('transaction.status:ok'),
  713. }),
  714. })
  715. );
  716. });
  717. it('appends tag value to existing query when clicked', async function () {
  718. const {organization, router, routerContext} = initializeData();
  719. render(<TestComponent router={router} location={router.location} />, {
  720. context: routerContext,
  721. organization,
  722. });
  723. await screen.findByText('Tag Summary');
  724. userEvent.click(
  725. screen.getByLabelText(
  726. 'environment, dev, 100% of all events. View events with this tag value.'
  727. )
  728. );
  729. userEvent.click(
  730. screen.getByLabelText(
  731. 'foo, bar, 100% of all events. View events with this tag value.'
  732. )
  733. );
  734. userEvent.click(
  735. screen.getByLabelText(
  736. 'user, id:100, 100% of all events. View events with this tag value.'
  737. )
  738. );
  739. expect(router.push).toHaveBeenCalledTimes(3);
  740. expect(router.push).toHaveBeenNthCalledWith(1, {
  741. query: {
  742. project: '2',
  743. query: 'tags[environment]:dev',
  744. transaction: '/performance',
  745. transactionCursor: '1:0:0',
  746. },
  747. });
  748. expect(router.push).toHaveBeenNthCalledWith(2, {
  749. query: {
  750. project: '2',
  751. query: 'foo:bar',
  752. transaction: '/performance',
  753. transactionCursor: '1:0:0',
  754. },
  755. });
  756. expect(router.push).toHaveBeenNthCalledWith(3, {
  757. query: {
  758. project: '2',
  759. query: 'user:"id:100"',
  760. transaction: '/performance',
  761. transactionCursor: '1:0:0',
  762. },
  763. });
  764. });
  765. });
  766. describe('with events', function () {
  767. it('renders basic UI elements', async function () {
  768. const {organization, router, routerContext} = initializeData();
  769. render(<TestComponent router={router} location={router.location} />, {
  770. context: routerContext,
  771. organization,
  772. });
  773. // It shows the header
  774. await screen.findByText('Transaction Summary');
  775. expect(screen.getByRole('heading', {name: '/performance'})).toBeInTheDocument();
  776. // It shows a chart
  777. expect(
  778. screen.getByRole('button', {name: 'Display Duration Breakdown'})
  779. ).toBeInTheDocument();
  780. // It shows a searchbar
  781. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  782. // It shows a table
  783. expect(screen.getByTestId('transactions-table')).toBeInTheDocument();
  784. // Ensure open in discover button exists.
  785. expect(screen.getByTestId('transaction-events-open')).toBeInTheDocument();
  786. // Ensure open issues button exists.
  787. expect(screen.getByRole('button', {name: 'Open in Issues'})).toBeInTheDocument();
  788. // Ensure transaction filter button exists
  789. expect(
  790. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  791. ).toBeInTheDocument();
  792. // Ensure create alert from discover is hidden without metric alert
  793. expect(
  794. screen.queryByRole('button', {name: 'Create Alert'})
  795. ).not.toBeInTheDocument();
  796. // Ensure status breakdown exists
  797. expect(screen.getByText('Status Breakdown')).toBeInTheDocument();
  798. });
  799. it('renders feature flagged UI elements', function () {
  800. const {organization, router, routerContext} = initializeData({
  801. features: ['incidents'],
  802. });
  803. render(<TestComponent router={router} location={router.location} />, {
  804. context: routerContext,
  805. organization,
  806. });
  807. // Ensure create alert from discover is shown with metric alerts
  808. expect(screen.getByRole('button', {name: 'Create Alert'})).toBeInTheDocument();
  809. });
  810. it('renders Web Vitals widget', async function () {
  811. const {organization, router, routerContext} = initializeData({
  812. project: TestStubs.Project({teams, platform: 'javascript'}),
  813. query: {
  814. query:
  815. 'transaction.duration:<15m transaction.op:pageload event.type:transaction transaction:/organizations/:orgId/issues/',
  816. },
  817. });
  818. render(<TestComponent router={router} location={router.location} />, {
  819. context: routerContext,
  820. organization,
  821. });
  822. // It renders the web vitals widget
  823. await screen.findByRole('heading', {name: 'Web Vitals'});
  824. const vitalStatues = screen.getAllByTestId('vital-status');
  825. expect(vitalStatues).toHaveLength(3);
  826. expect(vitalStatues[0]).toHaveTextContent('31%');
  827. expect(vitalStatues[1]).toHaveTextContent('65%');
  828. expect(vitalStatues[2]).toHaveTextContent('3%');
  829. });
  830. it('renders sidebar widgets', async function () {
  831. const {organization, router, routerContext} = initializeData({});
  832. render(<TestComponent router={router} location={router.location} />, {
  833. context: routerContext,
  834. organization,
  835. });
  836. // Renders Apdex widget
  837. await screen.findByRole('heading', {name: 'Apdex'});
  838. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  839. // Renders Failure Rate widget
  840. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  841. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  842. // Renders TPM widget
  843. expect(screen.getByRole('heading', {name: 'TPM'})).toBeInTheDocument();
  844. expect(screen.getByTestId('tpm-summary-value')).toHaveTextContent('1 tpm');
  845. });
  846. it('fetches transaction threshold', function () {
  847. const {organization, router, routerContext} = initializeData();
  848. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  849. url: '/organizations/org-slug/project-transaction-threshold-override/',
  850. method: 'GET',
  851. body: {
  852. threshold: '800',
  853. metric: 'lcp',
  854. },
  855. });
  856. const getProjectThresholdMock = MockApiClient.addMockResponse({
  857. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  858. method: 'GET',
  859. body: {
  860. threshold: '200',
  861. metric: 'duration',
  862. },
  863. });
  864. render(<TestComponent router={router} location={router.location} />, {
  865. context: routerContext,
  866. organization,
  867. });
  868. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  869. expect(getProjectThresholdMock).not.toHaveBeenCalled();
  870. });
  871. it('fetches project transaction threshdold', async function () {
  872. const {organization, router, routerContext} = initializeData();
  873. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  874. url: '/organizations/org-slug/project-transaction-threshold-override/',
  875. method: 'GET',
  876. statusCode: 404,
  877. });
  878. const getProjectThresholdMock = MockApiClient.addMockResponse({
  879. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  880. method: 'GET',
  881. body: {
  882. threshold: '200',
  883. metric: 'duration',
  884. },
  885. });
  886. render(<TestComponent router={router} location={router.location} />, {
  887. context: routerContext,
  888. organization,
  889. });
  890. await screen.findByText('Transaction Summary');
  891. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  892. expect(getProjectThresholdMock).toHaveBeenCalledTimes(1);
  893. });
  894. it('triggers a navigation on search', function () {
  895. const {organization, router, routerContext} = initializeData();
  896. render(<TestComponent router={router} location={router.location} />, {
  897. context: routerContext,
  898. organization,
  899. });
  900. // Fill out the search box, and submit it.
  901. userEvent.type(screen.getByLabelText('Search events'), 'user.email:uhoh*{enter}');
  902. // Check the navigation.
  903. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  904. expect(browserHistory.push).toHaveBeenCalledWith({
  905. pathname: undefined,
  906. query: {
  907. transaction: '/performance',
  908. project: '2',
  909. statsPeriod: '14d',
  910. query: 'user.email:uhoh*',
  911. transactionCursor: '1:0:0',
  912. },
  913. });
  914. });
  915. it('can mark a transaction as key', async function () {
  916. const {organization, router, routerContext} = initializeData();
  917. render(<TestComponent router={router} location={router.location} />, {
  918. context: routerContext,
  919. organization,
  920. });
  921. const mockUpdate = MockApiClient.addMockResponse({
  922. url: `/organizations/org-slug/key-transactions/`,
  923. method: 'POST',
  924. body: {},
  925. });
  926. await screen.findByRole('button', {name: 'Star for Team'});
  927. // Click the key transaction button
  928. userEvent.click(screen.getByRole('button', {name: 'Star for Team'}));
  929. userEvent.click(screen.getByText('team1'), undefined, {
  930. skipPointerEventsCheck: true,
  931. });
  932. // Ensure request was made.
  933. expect(mockUpdate).toHaveBeenCalled();
  934. });
  935. it('triggers a navigation on transaction filter', async function () {
  936. const {organization, router, routerContext} = initializeData();
  937. render(<TestComponent router={router} location={router.location} />, {
  938. context: routerContext,
  939. organization,
  940. });
  941. await screen.findByText('Transaction Summary');
  942. // Open the transaction filter dropdown
  943. userEvent.click(
  944. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  945. );
  946. userEvent.click(screen.getAllByText('Slow Transactions (p95)')[1]);
  947. // Check the navigation.
  948. expect(browserHistory.push).toHaveBeenCalledWith({
  949. pathname: undefined,
  950. query: {
  951. transaction: '/performance',
  952. project: '2',
  953. showTransactions: 'slow',
  954. transactionCursor: undefined,
  955. },
  956. });
  957. });
  958. it('renders pagination buttons', async function () {
  959. const {organization, router, routerContext} = initializeData();
  960. render(<TestComponent router={router} location={router.location} />, {
  961. context: routerContext,
  962. organization,
  963. });
  964. await screen.findByText('Transaction Summary');
  965. expect(await screen.findByLabelText('Previous')).toBeInTheDocument();
  966. // Click the 'next' button
  967. userEvent.click(screen.getByLabelText('Next'));
  968. // Check the navigation.
  969. expect(browserHistory.push).toHaveBeenCalledWith({
  970. pathname: undefined,
  971. query: {
  972. transaction: '/performance',
  973. project: '2',
  974. transactionCursor: '2:0:0',
  975. },
  976. });
  977. });
  978. it('forwards conditions to related issues', async function () {
  979. const issueGet = MockApiClient.addMockResponse({
  980. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  981. body: [],
  982. });
  983. const {organization, router, routerContext} = initializeData({
  984. query: {query: 'tag:value'},
  985. });
  986. render(<TestComponent router={router} location={router.location} />, {
  987. context: routerContext,
  988. organization,
  989. });
  990. await screen.findByText('Transaction Summary');
  991. expect(issueGet).toHaveBeenCalled();
  992. });
  993. it('does not forward event type to related issues', async function () {
  994. const issueGet = MockApiClient.addMockResponse({
  995. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  996. body: [],
  997. match: [
  998. (_, options) => {
  999. // event.type must NOT be in the query params
  1000. return !options.query?.query?.includes('event.type');
  1001. },
  1002. ],
  1003. });
  1004. const {organization, router, routerContext} = initializeData({
  1005. query: {query: 'tag:value event.type:transaction'},
  1006. });
  1007. render(<TestComponent router={router} location={router.location} />, {
  1008. context: routerContext,
  1009. organization,
  1010. });
  1011. await screen.findByText('Transaction Summary');
  1012. expect(issueGet).toHaveBeenCalled();
  1013. });
  1014. it('renders the suspect spans table if the feature is enabled', async function () {
  1015. MockApiClient.addMockResponse({
  1016. url: '/organizations/org-slug/events-spans-performance/',
  1017. body: [],
  1018. });
  1019. const {organization, router, routerContext} = initializeData({
  1020. features: ['performance-suspect-spans-view'],
  1021. });
  1022. render(<TestComponent router={router} location={router.location} />, {
  1023. context: routerContext,
  1024. organization,
  1025. });
  1026. expect(await screen.findByText('Suspect Spans')).toBeInTheDocument();
  1027. });
  1028. it('adds search condition on transaction status when clicking on status breakdown', async function () {
  1029. const {organization, router, routerContext} = initializeData();
  1030. render(<TestComponent router={router} location={router.location} />, {
  1031. context: routerContext,
  1032. organization,
  1033. });
  1034. await screen.findByTestId('status-ok');
  1035. userEvent.click(screen.getByTestId('status-ok'));
  1036. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  1037. expect(browserHistory.push).toHaveBeenCalledWith(
  1038. expect.objectContaining({
  1039. query: expect.objectContaining({
  1040. query: expect.stringContaining('transaction.status:ok'),
  1041. }),
  1042. })
  1043. );
  1044. });
  1045. it('appends tag value to existing query when clicked', async function () {
  1046. const {organization, router, routerContext} = initializeData();
  1047. render(<TestComponent router={router} location={router.location} />, {
  1048. context: routerContext,
  1049. organization,
  1050. });
  1051. await screen.findByText('Tag Summary');
  1052. userEvent.click(
  1053. screen.getByLabelText(
  1054. 'environment, dev, 100% of all events. View events with this tag value.'
  1055. )
  1056. );
  1057. userEvent.click(
  1058. screen.getByLabelText(
  1059. 'foo, bar, 100% of all events. View events with this tag value.'
  1060. )
  1061. );
  1062. expect(router.push).toHaveBeenCalledTimes(2);
  1063. expect(router.push).toHaveBeenNthCalledWith(1, {
  1064. query: {
  1065. project: '2',
  1066. query: 'tags[environment]:dev',
  1067. transaction: '/performance',
  1068. transactionCursor: '1:0:0',
  1069. },
  1070. });
  1071. expect(router.push).toHaveBeenNthCalledWith(2, {
  1072. query: {
  1073. project: '2',
  1074. query: 'foo:bar',
  1075. transaction: '/performance',
  1076. transactionCursor: '1:0:0',
  1077. },
  1078. });
  1079. });
  1080. });
  1081. });