transactionSummary.spec.tsx 38 KB

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