index.spec.tsx 39 KB

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