index.spec.tsx 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222
  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. MockApiClient.addMockResponse({
  376. url: '/organizations/org-slug/events-spans-performance/',
  377. body: [
  378. {
  379. op: 'ui.long-task',
  380. group: 'c777169faad84eb4',
  381. description: 'Main UI thread blocked',
  382. frequency: 713,
  383. count: 9040,
  384. avgOccurrences: null,
  385. sumExclusiveTime: 1743893.9822921753,
  386. p50ExclusiveTime: null,
  387. p75ExclusiveTime: 244.9998779296875,
  388. p95ExclusiveTime: null,
  389. p99ExclusiveTime: null,
  390. },
  391. ],
  392. });
  393. jest.spyOn(MEPSetting, 'get').mockImplementation(() => MEPState.auto);
  394. });
  395. afterEach(function () {
  396. MockApiClient.clearMockResponses();
  397. ProjectsStore.reset();
  398. jest.clearAllMocks();
  399. // @ts-ignore no-console
  400. // eslint-disable-next-line no-console
  401. console.error.mockRestore();
  402. });
  403. describe('with eventsv2', function () {
  404. it('renders basic UI elements', async function () {
  405. const {organization, router, routerContext} = initializeData();
  406. render(<TestComponent router={router} location={router.location} />, {
  407. context: routerContext,
  408. organization,
  409. });
  410. // It shows the header
  411. await screen.findByText('Transaction Summary');
  412. expect(screen.getByRole('heading', {name: '/performance'})).toBeInTheDocument();
  413. // It shows a chart
  414. expect(
  415. screen.getByRole('button', {name: 'Display Duration Breakdown'})
  416. ).toBeInTheDocument();
  417. // It shows a searchbar
  418. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  419. // It shows a table
  420. expect(screen.getByTestId('transactions-table')).toBeInTheDocument();
  421. // Ensure open in discover button exists.
  422. expect(screen.getByTestId('transaction-events-open')).toBeInTheDocument();
  423. // Ensure open issues button exists.
  424. expect(screen.getByRole('button', {name: 'Open in Issues'})).toBeInTheDocument();
  425. // Ensure transaction filter button exists
  426. expect(
  427. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  428. ).toBeInTheDocument();
  429. // Ensure ops breakdown filter exists
  430. expect(
  431. screen.getByRole('button', {name: 'Filter by operation'})
  432. ).toBeInTheDocument();
  433. // Ensure create alert from discover is hidden without metric alert
  434. expect(
  435. screen.queryByRole('button', {name: 'Create Alert'})
  436. ).not.toBeInTheDocument();
  437. // Ensure status breakdown exists
  438. expect(screen.getByText('Status Breakdown')).toBeInTheDocument();
  439. });
  440. it('renders feature flagged UI elements', function () {
  441. const {organization, router, routerContext} = initializeData({
  442. features: ['incidents'],
  443. });
  444. render(<TestComponent router={router} location={router.location} />, {
  445. context: routerContext,
  446. organization,
  447. });
  448. // Ensure create alert from discover is shown with metric alerts
  449. expect(screen.getByRole('button', {name: 'Create Alert'})).toBeInTheDocument();
  450. });
  451. it('renders Web Vitals widget', async function () {
  452. const {organization, router, routerContext} = initializeData({
  453. project: TestStubs.Project({teams, platform: 'javascript'}),
  454. query: {
  455. query:
  456. 'transaction.duration:<15m transaction.op:pageload event.type:transaction transaction:/organizations/:orgId/issues/',
  457. },
  458. });
  459. render(<TestComponent router={router} location={router.location} />, {
  460. context: routerContext,
  461. organization,
  462. });
  463. // It renders the web vitals widget
  464. await screen.findByRole('heading', {name: 'Web Vitals'});
  465. const vitalStatues = screen.getAllByTestId('vital-status');
  466. expect(vitalStatues).toHaveLength(3);
  467. expect(vitalStatues[0]).toHaveTextContent('31%');
  468. expect(vitalStatues[1]).toHaveTextContent('65%');
  469. expect(vitalStatues[2]).toHaveTextContent('3%');
  470. });
  471. it('renders sidebar widgets', async function () {
  472. const {organization, router, routerContext} = initializeData();
  473. render(<TestComponent router={router} location={router.location} />, {
  474. context: routerContext,
  475. organization,
  476. });
  477. // Renders Apdex widget
  478. await screen.findByRole('heading', {name: 'Apdex'});
  479. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  480. // Renders Failure Rate widget
  481. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  482. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  483. // Renders TPM widget
  484. expect(screen.getByRole('heading', {name: 'TPM'})).toBeInTheDocument();
  485. expect(screen.getByTestId('tpm-summary-value')).toHaveTextContent('1 tpm');
  486. });
  487. it('fetches transaction threshold', function () {
  488. const {organization, router, routerContext} = initializeData();
  489. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  490. url: '/organizations/org-slug/project-transaction-threshold-override/',
  491. method: 'GET',
  492. body: {
  493. threshold: '800',
  494. metric: 'lcp',
  495. },
  496. });
  497. const getProjectThresholdMock = MockApiClient.addMockResponse({
  498. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  499. method: 'GET',
  500. body: {
  501. threshold: '200',
  502. metric: 'duration',
  503. },
  504. });
  505. render(<TestComponent router={router} location={router.location} />, {
  506. context: routerContext,
  507. organization,
  508. });
  509. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  510. expect(getProjectThresholdMock).not.toHaveBeenCalled();
  511. });
  512. it('fetches project transaction threshdold', async function () {
  513. const {organization, router, routerContext} = initializeData();
  514. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  515. url: '/organizations/org-slug/project-transaction-threshold-override/',
  516. method: 'GET',
  517. statusCode: 404,
  518. });
  519. const getProjectThresholdMock = MockApiClient.addMockResponse({
  520. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  521. method: 'GET',
  522. body: {
  523. threshold: '200',
  524. metric: 'duration',
  525. },
  526. });
  527. render(<TestComponent router={router} location={router.location} />, {
  528. context: routerContext,
  529. organization,
  530. });
  531. await screen.findByText('Transaction Summary');
  532. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  533. expect(getProjectThresholdMock).toHaveBeenCalledTimes(1);
  534. });
  535. it('triggers a navigation on search', function () {
  536. const {organization, router, routerContext} = initializeData();
  537. render(<TestComponent router={router} location={router.location} />, {
  538. context: routerContext,
  539. organization,
  540. });
  541. // Fill out the search box, and submit it.
  542. userEvent.type(screen.getByLabelText('Search events'), 'user.email:uhoh*{enter}');
  543. // Check the navigation.
  544. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  545. expect(browserHistory.push).toHaveBeenCalledWith({
  546. pathname: undefined,
  547. query: {
  548. transaction: '/performance',
  549. project: '2',
  550. statsPeriod: '14d',
  551. query: 'user.email:uhoh*',
  552. transactionCursor: '1:0:0',
  553. },
  554. });
  555. });
  556. it('can mark a transaction as key', async function () {
  557. const {organization, router, routerContext} = initializeData();
  558. render(<TestComponent router={router} location={router.location} />, {
  559. context: routerContext,
  560. organization,
  561. });
  562. const mockUpdate = MockApiClient.addMockResponse({
  563. url: `/organizations/org-slug/key-transactions/`,
  564. method: 'POST',
  565. body: {},
  566. });
  567. await screen.findByRole('button', {name: 'Star for Team'});
  568. // Click the key transaction button
  569. userEvent.click(screen.getByRole('button', {name: 'Star for Team'}));
  570. userEvent.click(screen.getByText('team1'), undefined, {
  571. skipPointerEventsCheck: true,
  572. });
  573. // Ensure request was made.
  574. expect(mockUpdate).toHaveBeenCalled();
  575. });
  576. it('triggers a navigation on transaction filter', async function () {
  577. const {organization, router, routerContext} = initializeData();
  578. render(<TestComponent router={router} location={router.location} />, {
  579. context: routerContext,
  580. organization,
  581. });
  582. await screen.findByText('Transaction Summary');
  583. // Open the transaction filter dropdown
  584. userEvent.click(
  585. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  586. );
  587. userEvent.click(screen.getAllByText('Slow Transactions (p95)')[1]);
  588. // Check the navigation.
  589. expect(browserHistory.push).toHaveBeenCalledWith({
  590. pathname: undefined,
  591. query: {
  592. transaction: '/performance',
  593. project: '2',
  594. showTransactions: 'slow',
  595. transactionCursor: undefined,
  596. },
  597. });
  598. });
  599. it('renders pagination buttons', async function () {
  600. const {organization, router, routerContext} = initializeData();
  601. render(<TestComponent router={router} location={router.location} />, {
  602. context: routerContext,
  603. organization,
  604. });
  605. await screen.findByText('Transaction Summary');
  606. expect(await screen.findByLabelText('Previous')).toBeInTheDocument();
  607. // Click the 'next' button
  608. userEvent.click(screen.getByLabelText('Next'));
  609. // Check the navigation.
  610. expect(browserHistory.push).toHaveBeenCalledWith({
  611. pathname: undefined,
  612. query: {
  613. transaction: '/performance',
  614. project: '2',
  615. transactionCursor: '2:0:0',
  616. },
  617. });
  618. });
  619. it('forwards conditions to related issues', async function () {
  620. const issueGet = MockApiClient.addMockResponse({
  621. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  622. body: [],
  623. });
  624. const {organization, router, routerContext} = initializeData({
  625. query: {query: 'tag:value'},
  626. });
  627. render(<TestComponent router={router} location={router.location} />, {
  628. context: routerContext,
  629. organization,
  630. });
  631. await screen.findByText('Transaction Summary');
  632. expect(issueGet).toHaveBeenCalled();
  633. });
  634. it('does not forward event type to related issues', async function () {
  635. const issueGet = MockApiClient.addMockResponse({
  636. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  637. body: [],
  638. match: [
  639. (_, options) => {
  640. // event.type must NOT be in the query params
  641. return !options.query?.query?.includes('event.type');
  642. },
  643. ],
  644. });
  645. const {organization, router, routerContext} = initializeData({
  646. query: {query: 'tag:value event.type:transaction'},
  647. });
  648. render(<TestComponent router={router} location={router.location} />, {
  649. context: routerContext,
  650. organization,
  651. });
  652. await screen.findByText('Transaction Summary');
  653. expect(issueGet).toHaveBeenCalled();
  654. });
  655. it('renders the suspect spans table if the feature is enabled', async function () {
  656. MockApiClient.addMockResponse({
  657. url: '/organizations/org-slug/events-spans-performance/',
  658. body: [],
  659. });
  660. const {organization, router, routerContext} = initializeData({});
  661. render(<TestComponent router={router} location={router.location} />, {
  662. context: routerContext,
  663. organization,
  664. });
  665. expect(await screen.findByText('Suspect Spans')).toBeInTheDocument();
  666. });
  667. it('adds search condition on transaction status when clicking on status breakdown', async function () {
  668. const {organization, router, routerContext} = initializeData();
  669. render(<TestComponent router={router} location={router.location} />, {
  670. context: routerContext,
  671. organization,
  672. });
  673. await screen.findByTestId('status-ok');
  674. userEvent.click(screen.getByTestId('status-ok'));
  675. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  676. expect(browserHistory.push).toHaveBeenCalledWith(
  677. expect.objectContaining({
  678. query: expect.objectContaining({
  679. query: expect.stringContaining('transaction.status:ok'),
  680. }),
  681. })
  682. );
  683. });
  684. it('appends tag value to existing query when clicked', async function () {
  685. const {organization, router, routerContext} = initializeData();
  686. render(<TestComponent router={router} location={router.location} />, {
  687. context: routerContext,
  688. organization,
  689. });
  690. await screen.findByText('Tag Summary');
  691. userEvent.click(
  692. screen.getByLabelText('Add the environment dev segment tag to the search query')
  693. );
  694. userEvent.click(
  695. screen.getByLabelText('Add the foo bar segment tag to the search query')
  696. );
  697. userEvent.click(
  698. screen.getByLabelText('Add the user id:100 segment tag to the search query')
  699. );
  700. expect(router.push).toHaveBeenCalledTimes(3);
  701. expect(router.push).toHaveBeenNthCalledWith(1, {
  702. query: {
  703. project: '2',
  704. query: 'tags[environment]:dev',
  705. transaction: '/performance',
  706. transactionCursor: '1:0:0',
  707. },
  708. });
  709. expect(router.push).toHaveBeenNthCalledWith(2, {
  710. query: {
  711. project: '2',
  712. query: 'foo:bar',
  713. transaction: '/performance',
  714. transactionCursor: '1:0:0',
  715. },
  716. });
  717. expect(router.push).toHaveBeenNthCalledWith(3, {
  718. query: {
  719. project: '2',
  720. query: 'user:"id:100"',
  721. transaction: '/performance',
  722. transactionCursor: '1:0:0',
  723. },
  724. });
  725. });
  726. });
  727. describe('with events', function () {
  728. it('renders basic UI elements', async function () {
  729. const {organization, router, routerContext} = initializeData();
  730. render(<TestComponent router={router} location={router.location} />, {
  731. context: routerContext,
  732. organization,
  733. });
  734. // It shows the header
  735. await screen.findByText('Transaction Summary');
  736. expect(screen.getByRole('heading', {name: '/performance'})).toBeInTheDocument();
  737. // It shows a chart
  738. expect(
  739. screen.getByRole('button', {name: 'Display Duration Breakdown'})
  740. ).toBeInTheDocument();
  741. // It shows a searchbar
  742. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  743. // It shows a table
  744. expect(screen.getByTestId('transactions-table')).toBeInTheDocument();
  745. // Ensure open in discover button exists.
  746. expect(screen.getByTestId('transaction-events-open')).toBeInTheDocument();
  747. // Ensure open issues button exists.
  748. expect(screen.getByRole('button', {name: 'Open in Issues'})).toBeInTheDocument();
  749. // Ensure transaction filter button exists
  750. expect(
  751. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  752. ).toBeInTheDocument();
  753. // Ensure create alert from discover is hidden without metric alert
  754. expect(
  755. screen.queryByRole('button', {name: 'Create Alert'})
  756. ).not.toBeInTheDocument();
  757. // Ensure status breakdown exists
  758. expect(screen.getByText('Status Breakdown')).toBeInTheDocument();
  759. });
  760. it('renders feature flagged UI elements', function () {
  761. const {organization, router, routerContext} = initializeData({
  762. features: ['incidents'],
  763. });
  764. render(<TestComponent router={router} location={router.location} />, {
  765. context: routerContext,
  766. organization,
  767. });
  768. // Ensure create alert from discover is shown with metric alerts
  769. expect(screen.getByRole('button', {name: 'Create Alert'})).toBeInTheDocument();
  770. });
  771. it('renders Web Vitals widget', async function () {
  772. const {organization, router, routerContext} = initializeData({
  773. project: TestStubs.Project({teams, platform: 'javascript'}),
  774. query: {
  775. query:
  776. 'transaction.duration:<15m transaction.op:pageload event.type:transaction transaction:/organizations/:orgId/issues/',
  777. },
  778. });
  779. render(<TestComponent router={router} location={router.location} />, {
  780. context: routerContext,
  781. organization,
  782. });
  783. // It renders the web vitals widget
  784. await screen.findByRole('heading', {name: 'Web Vitals'});
  785. const vitalStatues = screen.getAllByTestId('vital-status');
  786. expect(vitalStatues).toHaveLength(3);
  787. expect(vitalStatues[0]).toHaveTextContent('31%');
  788. expect(vitalStatues[1]).toHaveTextContent('65%');
  789. expect(vitalStatues[2]).toHaveTextContent('3%');
  790. });
  791. it('renders sidebar widgets', async function () {
  792. const {organization, router, routerContext} = initializeData({});
  793. render(<TestComponent router={router} location={router.location} />, {
  794. context: routerContext,
  795. organization,
  796. });
  797. // Renders Apdex widget
  798. await screen.findByRole('heading', {name: 'Apdex'});
  799. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  800. // Renders Failure Rate widget
  801. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  802. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  803. // Renders TPM widget
  804. expect(screen.getByRole('heading', {name: 'TPM'})).toBeInTheDocument();
  805. expect(screen.getByTestId('tpm-summary-value')).toHaveTextContent('1 tpm');
  806. });
  807. it('fetches transaction threshold', function () {
  808. const {organization, router, routerContext} = initializeData();
  809. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  810. url: '/organizations/org-slug/project-transaction-threshold-override/',
  811. method: 'GET',
  812. body: {
  813. threshold: '800',
  814. metric: 'lcp',
  815. },
  816. });
  817. const getProjectThresholdMock = MockApiClient.addMockResponse({
  818. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  819. method: 'GET',
  820. body: {
  821. threshold: '200',
  822. metric: 'duration',
  823. },
  824. });
  825. render(<TestComponent router={router} location={router.location} />, {
  826. context: routerContext,
  827. organization,
  828. });
  829. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  830. expect(getProjectThresholdMock).not.toHaveBeenCalled();
  831. });
  832. it('fetches project transaction threshdold', async function () {
  833. const {organization, router, routerContext} = initializeData();
  834. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  835. url: '/organizations/org-slug/project-transaction-threshold-override/',
  836. method: 'GET',
  837. statusCode: 404,
  838. });
  839. const getProjectThresholdMock = MockApiClient.addMockResponse({
  840. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  841. method: 'GET',
  842. body: {
  843. threshold: '200',
  844. metric: 'duration',
  845. },
  846. });
  847. render(<TestComponent router={router} location={router.location} />, {
  848. context: routerContext,
  849. organization,
  850. });
  851. await screen.findByText('Transaction Summary');
  852. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  853. expect(getProjectThresholdMock).toHaveBeenCalledTimes(1);
  854. });
  855. it('triggers a navigation on search', function () {
  856. const {organization, router, routerContext} = initializeData();
  857. render(<TestComponent router={router} location={router.location} />, {
  858. context: routerContext,
  859. organization,
  860. });
  861. // Fill out the search box, and submit it.
  862. userEvent.type(screen.getByLabelText('Search events'), 'user.email:uhoh*{enter}');
  863. // Check the navigation.
  864. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  865. expect(browserHistory.push).toHaveBeenCalledWith({
  866. pathname: undefined,
  867. query: {
  868. transaction: '/performance',
  869. project: '2',
  870. statsPeriod: '14d',
  871. query: 'user.email:uhoh*',
  872. transactionCursor: '1:0:0',
  873. },
  874. });
  875. });
  876. it('can mark a transaction as key', async function () {
  877. const {organization, router, routerContext} = initializeData();
  878. render(<TestComponent router={router} location={router.location} />, {
  879. context: routerContext,
  880. organization,
  881. });
  882. const mockUpdate = MockApiClient.addMockResponse({
  883. url: `/organizations/org-slug/key-transactions/`,
  884. method: 'POST',
  885. body: {},
  886. });
  887. await screen.findByRole('button', {name: 'Star for Team'});
  888. // Click the key transaction button
  889. userEvent.click(screen.getByRole('button', {name: 'Star for Team'}));
  890. userEvent.click(screen.getByText('team1'), undefined, {
  891. skipPointerEventsCheck: true,
  892. });
  893. // Ensure request was made.
  894. expect(mockUpdate).toHaveBeenCalled();
  895. });
  896. it('triggers a navigation on transaction filter', async function () {
  897. const {organization, router, routerContext} = initializeData();
  898. render(<TestComponent router={router} location={router.location} />, {
  899. context: routerContext,
  900. organization,
  901. });
  902. await screen.findByText('Transaction Summary');
  903. // Open the transaction filter dropdown
  904. userEvent.click(
  905. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  906. );
  907. userEvent.click(screen.getAllByText('Slow Transactions (p95)')[1]);
  908. // Check the navigation.
  909. expect(browserHistory.push).toHaveBeenCalledWith({
  910. pathname: undefined,
  911. query: {
  912. transaction: '/performance',
  913. project: '2',
  914. showTransactions: 'slow',
  915. transactionCursor: undefined,
  916. },
  917. });
  918. });
  919. it('renders pagination buttons', async function () {
  920. const {organization, router, routerContext} = initializeData();
  921. render(<TestComponent router={router} location={router.location} />, {
  922. context: routerContext,
  923. organization,
  924. });
  925. await screen.findByText('Transaction Summary');
  926. expect(await screen.findByLabelText('Previous')).toBeInTheDocument();
  927. // Click the 'next' button
  928. userEvent.click(screen.getByLabelText('Next'));
  929. // Check the navigation.
  930. expect(browserHistory.push).toHaveBeenCalledWith({
  931. pathname: undefined,
  932. query: {
  933. transaction: '/performance',
  934. project: '2',
  935. transactionCursor: '2:0:0',
  936. },
  937. });
  938. });
  939. it('forwards conditions to related issues', async function () {
  940. const issueGet = MockApiClient.addMockResponse({
  941. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  942. body: [],
  943. });
  944. const {organization, router, routerContext} = initializeData({
  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. query: {query: 'tag:value event.type:transaction'},
  967. });
  968. render(<TestComponent router={router} location={router.location} />, {
  969. context: routerContext,
  970. organization,
  971. });
  972. await screen.findByText('Transaction Summary');
  973. expect(issueGet).toHaveBeenCalled();
  974. });
  975. it('renders the suspect spans table if the feature is enabled', async function () {
  976. MockApiClient.addMockResponse({
  977. url: '/organizations/org-slug/events-spans-performance/',
  978. body: [],
  979. });
  980. const {organization, router, routerContext} = initializeData({
  981. features: ['performance-suspect-spans-view'],
  982. });
  983. render(<TestComponent router={router} location={router.location} />, {
  984. context: routerContext,
  985. organization,
  986. });
  987. expect(await screen.findByText('Suspect Spans')).toBeInTheDocument();
  988. });
  989. it('adds search condition on transaction status when clicking on status breakdown', async function () {
  990. const {organization, router, routerContext} = initializeData();
  991. render(<TestComponent router={router} location={router.location} />, {
  992. context: routerContext,
  993. organization,
  994. });
  995. await screen.findByTestId('status-ok');
  996. userEvent.click(screen.getByTestId('status-ok'));
  997. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  998. expect(browserHistory.push).toHaveBeenCalledWith(
  999. expect.objectContaining({
  1000. query: expect.objectContaining({
  1001. query: expect.stringContaining('transaction.status:ok'),
  1002. }),
  1003. })
  1004. );
  1005. });
  1006. it('appends tag value to existing query when clicked', async function () {
  1007. const {organization, router, routerContext} = initializeData();
  1008. render(<TestComponent router={router} location={router.location} />, {
  1009. context: routerContext,
  1010. organization,
  1011. });
  1012. await screen.findByText('Tag Summary');
  1013. userEvent.click(
  1014. screen.getByLabelText('Add the environment dev segment tag to the search query')
  1015. );
  1016. userEvent.click(
  1017. screen.getByLabelText('Add the foo bar segment tag to the search query')
  1018. );
  1019. expect(router.push).toHaveBeenCalledTimes(2);
  1020. expect(router.push).toHaveBeenNthCalledWith(1, {
  1021. query: {
  1022. project: '2',
  1023. query: 'tags[environment]:dev',
  1024. transaction: '/performance',
  1025. transactionCursor: '1:0:0',
  1026. },
  1027. });
  1028. expect(router.push).toHaveBeenNthCalledWith(2, {
  1029. query: {
  1030. project: '2',
  1031. query: 'foo:bar',
  1032. transaction: '/performance',
  1033. transactionCursor: '1:0:0',
  1034. },
  1035. });
  1036. });
  1037. });
  1038. });