index.spec.tsx 38 KB

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