index.spec.tsx 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087
  1. import {browserHistory, InjectedRouter} from 'react-router';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {
  4. render,
  5. renderGlobalModal,
  6. screen,
  7. userEvent,
  8. waitFor,
  9. } from 'sentry-test/reactTestingLibrary';
  10. import OrganizationStore from 'sentry/stores/organizationStore';
  11. import ProjectsStore from 'sentry/stores/projectsStore';
  12. import TeamStore from 'sentry/stores/teamStore';
  13. import {Project} from 'sentry/types';
  14. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  15. import {
  16. MEPSetting,
  17. MEPState,
  18. } from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  19. import {QueryClient, QueryClientProvider} from 'sentry/utils/queryClient';
  20. import TransactionSummary from 'sentry/views/performance/transactionSummary/transactionOverview';
  21. import {RouteContext} from 'sentry/views/routeContext';
  22. const teams = [
  23. TestStubs.Team({id: '1', slug: 'team1', name: 'Team 1'}),
  24. TestStubs.Team({id: '2', slug: 'team2', name: 'Team 2'}),
  25. ];
  26. function initializeData({
  27. features: additionalFeatures = [],
  28. query = {},
  29. project: prj,
  30. projects,
  31. }: {
  32. features?: string[];
  33. project?: Project;
  34. projects?: Project[];
  35. query?: Record<string, any>;
  36. } = {}) {
  37. const features = ['discover-basic', 'performance-view', ...additionalFeatures];
  38. const project = prj ?? TestStubs.Project({teams});
  39. const organization = TestStubs.Organization({
  40. features,
  41. projects: projects ? projects : [project],
  42. });
  43. const initialData = initializeOrg({
  44. organization,
  45. router: {
  46. location: {
  47. query: {
  48. transaction: '/performance',
  49. project: project.id,
  50. transactionCursor: '1:0:0',
  51. ...query,
  52. },
  53. },
  54. },
  55. });
  56. ProjectsStore.loadInitialData(initialData.organization.projects);
  57. TeamStore.loadInitialData(teams, false, null);
  58. return initialData;
  59. }
  60. function TestComponent({
  61. router,
  62. ...props
  63. }: React.ComponentProps<typeof TransactionSummary> & {
  64. router: InjectedRouter<Record<string, string>, any>;
  65. }) {
  66. const client = new QueryClient();
  67. return (
  68. <QueryClientProvider client={client}>
  69. <RouteContext.Provider value={{router, ...router}}>
  70. <TransactionSummary {...props} />
  71. </RouteContext.Provider>
  72. </QueryClientProvider>
  73. );
  74. }
  75. describe('Performance > TransactionSummary', function () {
  76. let eventStatsMock: jest.Mock;
  77. beforeEach(function () {
  78. // @ts-ignore no-console
  79. // eslint-disable-next-line no-console
  80. jest.spyOn(console, 'error').mockImplementation(jest.fn());
  81. MockApiClient.clearMockResponses();
  82. MockApiClient.addMockResponse({
  83. url: '/organizations/org-slug/projects/',
  84. body: [],
  85. });
  86. MockApiClient.addMockResponse({
  87. url: '/organizations/org-slug/tags/',
  88. body: [],
  89. });
  90. MockApiClient.addMockResponse({
  91. url: '/organizations/org-slug/tags/user.email/values/',
  92. body: [],
  93. });
  94. eventStatsMock = MockApiClient.addMockResponse({
  95. url: '/organizations/org-slug/events-stats/',
  96. body: {data: [[123, []]]},
  97. });
  98. MockApiClient.addMockResponse({
  99. url: '/organizations/org-slug/releases/stats/',
  100. body: [],
  101. });
  102. MockApiClient.addMockResponse({
  103. url: '/organizations/org-slug/issues/?limit=5&project=2&query=is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  104. body: [],
  105. });
  106. MockApiClient.addMockResponse({
  107. url: '/organizations/org-slug/users/',
  108. body: [],
  109. });
  110. MockApiClient.addMockResponse({
  111. url: '/organizations/org-slug/recent-searches/',
  112. body: [],
  113. });
  114. MockApiClient.addMockResponse({
  115. url: '/organizations/org-slug/recent-searches/',
  116. method: 'POST',
  117. body: [],
  118. });
  119. MockApiClient.addMockResponse({
  120. url: '/organizations/org-slug/sdk-updates/',
  121. body: [],
  122. });
  123. MockApiClient.addMockResponse({
  124. url: '/prompts-activity/',
  125. body: {},
  126. });
  127. MockApiClient.addMockResponse({
  128. url: '/organizations/org-slug/events-facets-performance/',
  129. body: {},
  130. });
  131. // Events Mock totals for the sidebar and other summary data
  132. MockApiClient.addMockResponse({
  133. url: '/organizations/org-slug/events/',
  134. body: {
  135. meta: {
  136. fields: {
  137. 'count()': 'number',
  138. 'apdex()': 'number',
  139. 'count_miserable_user()': 'number',
  140. 'user_misery()': 'number',
  141. 'count_unique_user()': 'number',
  142. 'p95()': 'number',
  143. 'failure_rate()': 'number',
  144. 'tpm()': 'number',
  145. project_threshold_config: 'string',
  146. },
  147. },
  148. data: [
  149. {
  150. 'count()': 2,
  151. 'apdex()': 0.6,
  152. 'count_miserable_user()': 122,
  153. 'user_misery()': 0.114,
  154. 'count_unique_user()': 1,
  155. 'p95()': 750.123,
  156. 'failure_rate()': 1,
  157. 'tpm()': 1,
  158. project_threshold_config: ['duration', 300],
  159. },
  160. ],
  161. },
  162. match: [
  163. (_url, options) => {
  164. return options.query?.field?.includes('p95()');
  165. },
  166. ],
  167. });
  168. // [Metrics Enhanced] Events Mock totals for the sidebar and other summary data
  169. MockApiClient.addMockResponse({
  170. url: '/organizations/org-slug/events/',
  171. body: {
  172. meta: {
  173. fields: {
  174. 'count()': 'number',
  175. 'apdex()': 'number',
  176. 'count_miserable_user()': 'number',
  177. 'user_misery()': 'number',
  178. 'count_unique_user()': 'number',
  179. 'p95()': 'number',
  180. 'failure_rate()': 'number',
  181. 'tpm()': 'number',
  182. project_threshold_config: 'string',
  183. },
  184. isMetricsData: true,
  185. },
  186. data: [
  187. {
  188. 'count()': 200,
  189. 'apdex()': 0.5,
  190. 'count_miserable_user()': 120,
  191. 'user_misery()': 0.1,
  192. 'count_unique_user()': 100,
  193. 'p95()': 731.3132,
  194. 'failure_rate()': 1,
  195. 'tpm()': 100,
  196. project_threshold_config: ['duration', 300],
  197. },
  198. ],
  199. },
  200. match: [
  201. (_url, options) => {
  202. const isMetricsEnhanced =
  203. options.query?.dataset === DiscoverDatasets.METRICS_ENHANCED;
  204. return options.query?.field?.includes('p95()') && isMetricsEnhanced;
  205. },
  206. ],
  207. });
  208. // Events Mock unfiltered totals for percentage calculations
  209. MockApiClient.addMockResponse({
  210. url: '/organizations/org-slug/events/',
  211. body: {
  212. meta: {
  213. fields: {
  214. 'tpm()': 'number',
  215. },
  216. },
  217. data: [
  218. {
  219. 'tpm()': 1,
  220. },
  221. ],
  222. },
  223. match: [
  224. (_url, options) => {
  225. return (
  226. options.query?.field?.includes('tpm()') &&
  227. !options.query?.field?.includes('p95()')
  228. );
  229. },
  230. ],
  231. });
  232. // Events Mock count totals for histogram percentage calculations
  233. MockApiClient.addMockResponse({
  234. url: '/organizations/org-slug/events/',
  235. body: {
  236. meta: {
  237. fields: {
  238. 'count()': 'number',
  239. },
  240. },
  241. data: [
  242. {
  243. 'count()': 2,
  244. },
  245. ],
  246. },
  247. match: [
  248. (_url, options) => {
  249. return (
  250. options.query?.field?.includes('count()') &&
  251. !options.query?.field?.includes('p95()')
  252. );
  253. },
  254. ],
  255. });
  256. // Events Transaction list response
  257. MockApiClient.addMockResponse({
  258. url: '/organizations/org-slug/events/',
  259. headers: {
  260. Link:
  261. '<http://localhost/api/0/organizations/org-slug/events/?cursor=2:0:0>; rel="next"; results="true"; cursor="2:0:0",' +
  262. '<http://localhost/api/0/organizations/org-slug/events/?cursor=1:0:0>; rel="previous"; results="false"; cursor="1:0:0"',
  263. },
  264. body: {
  265. meta: {
  266. fields: {
  267. id: 'string',
  268. 'user.display': 'string',
  269. 'transaction.duration': 'duration',
  270. 'project.id': 'integer',
  271. timestamp: 'date',
  272. },
  273. },
  274. data: [
  275. {
  276. id: 'deadbeef',
  277. 'user.display': 'uhoh@example.com',
  278. 'transaction.duration': 400,
  279. 'project.id': 2,
  280. timestamp: '2020-05-21T15:31:18+00:00',
  281. },
  282. ],
  283. },
  284. match: [
  285. (_url, options) => {
  286. return options.query?.field?.includes('user.display');
  287. },
  288. ],
  289. });
  290. // Events Mock totals for status breakdown
  291. MockApiClient.addMockResponse({
  292. url: '/organizations/org-slug/events/',
  293. body: {
  294. meta: {
  295. fields: {
  296. 'transaction.status': 'string',
  297. 'count()': 'number',
  298. },
  299. },
  300. data: [
  301. {
  302. 'count()': 2,
  303. 'transaction.status': 'ok',
  304. },
  305. ],
  306. },
  307. match: [
  308. (_url, options) => {
  309. return options.query?.field?.includes('transaction.status');
  310. },
  311. ],
  312. });
  313. MockApiClient.addMockResponse({
  314. url: '/organizations/org-slug/events-facets/',
  315. body: [
  316. {
  317. key: 'release',
  318. topValues: [{count: 3, value: 'abcd123', name: 'abcd123'}],
  319. },
  320. {
  321. key: 'environment',
  322. topValues: [
  323. {count: 2, value: 'dev', name: 'dev'},
  324. {count: 1, value: 'prod', name: 'prod'},
  325. ],
  326. },
  327. {
  328. key: 'foo',
  329. topValues: [
  330. {count: 2, value: 'bar', name: 'bar'},
  331. {count: 1, value: 'baz', name: 'baz'},
  332. ],
  333. },
  334. {
  335. key: 'user',
  336. topValues: [
  337. {count: 2, value: 'id:100', name: '100'},
  338. {count: 1, value: 'id:101', name: '101'},
  339. ],
  340. },
  341. ],
  342. });
  343. MockApiClient.addMockResponse({
  344. url: '/organizations/org-slug/project-transaction-threshold-override/',
  345. method: 'GET',
  346. body: {
  347. threshold: '800',
  348. metric: 'lcp',
  349. },
  350. });
  351. MockApiClient.addMockResponse({
  352. url: '/organizations/org-slug/events-vitals/',
  353. body: {
  354. 'measurements.fcp': {
  355. poor: 3,
  356. meh: 100,
  357. good: 47,
  358. total: 150,
  359. p75: 1500,
  360. },
  361. 'measurements.lcp': {
  362. poor: 2,
  363. meh: 38,
  364. good: 40,
  365. total: 80,
  366. p75: 2750,
  367. },
  368. 'measurements.fid': {
  369. poor: 2,
  370. meh: 53,
  371. good: 5,
  372. total: 60,
  373. p75: 1000,
  374. },
  375. 'measurements.cls': {
  376. poor: 3,
  377. meh: 10,
  378. good: 4,
  379. total: 17,
  380. p75: 0.2,
  381. },
  382. },
  383. });
  384. MockApiClient.addMockResponse({
  385. method: 'GET',
  386. url: `/organizations/org-slug/key-transactions-list/`,
  387. body: teams.map(({id}) => ({
  388. team: id,
  389. count: 0,
  390. keyed: [],
  391. })),
  392. });
  393. MockApiClient.addMockResponse({
  394. url: '/organizations/org-slug/events-has-measurements/',
  395. body: {measurements: false},
  396. });
  397. MockApiClient.addMockResponse({
  398. url: '/organizations/org-slug/events-spans-performance/',
  399. body: [
  400. {
  401. op: 'ui.long-task',
  402. group: 'c777169faad84eb4',
  403. description: 'Main UI thread blocked',
  404. frequency: 713,
  405. count: 9040,
  406. avgOccurrences: null,
  407. sumExclusiveTime: 1743893.9822921753,
  408. p50ExclusiveTime: null,
  409. p75ExclusiveTime: 244.9998779296875,
  410. p95ExclusiveTime: null,
  411. p99ExclusiveTime: null,
  412. },
  413. ],
  414. });
  415. MockApiClient.addMockResponse({
  416. url: `/projects/org-slug/project-slug/profiling/functions/`,
  417. body: {functions: []},
  418. });
  419. jest.spyOn(MEPSetting, 'get').mockImplementation(() => MEPState.auto);
  420. });
  421. afterEach(function () {
  422. MockApiClient.clearMockResponses();
  423. ProjectsStore.reset();
  424. jest.clearAllMocks();
  425. jest.restoreAllMocks();
  426. });
  427. describe('with events', function () {
  428. it('renders basic UI elements', async function () {
  429. const {organization, router, routerContext} = initializeData();
  430. render(<TestComponent router={router} location={router.location} />, {
  431. context: routerContext,
  432. organization,
  433. });
  434. // It shows the header
  435. await screen.findByText('Transaction Summary');
  436. expect(screen.getByRole('heading', {name: '/performance'})).toBeInTheDocument();
  437. // It shows a chart
  438. expect(
  439. screen.getByRole('button', {name: 'Display Duration Breakdown'})
  440. ).toBeInTheDocument();
  441. // It shows a searchbar
  442. expect(screen.getByLabelText('Search events')).toBeInTheDocument();
  443. // It shows a table
  444. expect(screen.getByTestId('transactions-table')).toBeInTheDocument();
  445. // Ensure open in discover button exists.
  446. expect(screen.getByTestId('transaction-events-open')).toBeInTheDocument();
  447. // Ensure open issues button exists.
  448. expect(screen.getByRole('button', {name: 'Open in Issues'})).toBeInTheDocument();
  449. // Ensure transaction filter button exists
  450. expect(
  451. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  452. ).toBeInTheDocument();
  453. // Ensure create alert from discover is hidden without metric alert
  454. expect(
  455. screen.queryByRole('button', {name: 'Create Alert'})
  456. ).not.toBeInTheDocument();
  457. // Ensure status breakdown exists
  458. expect(screen.getByText('Status Breakdown')).toBeInTheDocument();
  459. });
  460. it('renders feature flagged UI elements', function () {
  461. const {organization, router, routerContext} = initializeData({
  462. features: ['incidents'],
  463. });
  464. render(<TestComponent router={router} location={router.location} />, {
  465. context: routerContext,
  466. organization,
  467. });
  468. // Ensure create alert from discover is shown with metric alerts
  469. expect(screen.getByRole('button', {name: 'Create Alert'})).toBeInTheDocument();
  470. });
  471. it('renders Web Vitals widget', async function () {
  472. const {organization, router, routerContext} = initializeData({
  473. project: TestStubs.Project({teams, platform: 'javascript'}),
  474. query: {
  475. query:
  476. 'transaction.duration:<15m transaction.op:pageload event.type:transaction transaction:/organizations/:orgId/issues/',
  477. },
  478. });
  479. render(<TestComponent router={router} location={router.location} />, {
  480. context: routerContext,
  481. organization,
  482. });
  483. // It renders the web vitals widget
  484. await screen.findByRole('heading', {name: 'Web Vitals'});
  485. const vitalStatues = screen.getAllByTestId('vital-status');
  486. expect(vitalStatues).toHaveLength(3);
  487. expect(vitalStatues[0]).toHaveTextContent('31%');
  488. expect(vitalStatues[1]).toHaveTextContent('65%');
  489. expect(vitalStatues[2]).toHaveTextContent('3%');
  490. });
  491. it('renders sidebar widgets', async function () {
  492. const {organization, router, routerContext} = initializeData({});
  493. render(<TestComponent router={router} location={router.location} />, {
  494. context: routerContext,
  495. organization,
  496. });
  497. // Renders Apdex widget
  498. await screen.findByRole('heading', {name: 'Apdex'});
  499. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  500. // Renders Failure Rate widget
  501. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  502. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  503. // Renders TPM widget
  504. expect(
  505. screen.getByRole('heading', {name: 'Percentage of Total Transactions'})
  506. ).toBeInTheDocument();
  507. expect(screen.getByTestId('count-percentage-summary-value')).toHaveTextContent(
  508. '100%'
  509. );
  510. });
  511. it('renders project picker modal when no url does not have project id', async function () {
  512. MockApiClient.addMockResponse({
  513. url: '/organizations/org-slug/events/',
  514. body: {
  515. meta: {
  516. fields: {
  517. project: 'string',
  518. 'count()': 'number',
  519. },
  520. },
  521. data: [
  522. {
  523. 'count()': 2,
  524. project: 'proj-slug-1',
  525. },
  526. {
  527. 'count()': 3,
  528. project: 'proj-slug-2',
  529. },
  530. ],
  531. },
  532. match: [
  533. (_url, options) => {
  534. return options.query?.field?.includes('project');
  535. },
  536. ],
  537. });
  538. const projects = [
  539. TestStubs.Project({
  540. slug: 'proj-slug-1',
  541. id: '1',
  542. name: 'Project Name 1',
  543. }),
  544. TestStubs.Project({
  545. slug: 'proj-slug-2',
  546. id: '2',
  547. name: 'Project Name 2',
  548. }),
  549. ];
  550. OrganizationStore.onUpdate(TestStubs.Organization({slug: 'org-slug'}), {
  551. replace: true,
  552. });
  553. const {organization, router, routerContext} = initializeData({projects});
  554. const spy = jest.spyOn(router, 'replace');
  555. // Ensure project id is not in path
  556. delete router.location.query.project;
  557. render(<TestComponent router={router} location={router.location} />, {
  558. context: routerContext,
  559. organization,
  560. projects,
  561. });
  562. renderGlobalModal();
  563. const firstProjectOption = await screen.findByText('proj-slug-1');
  564. expect(firstProjectOption).toBeInTheDocument();
  565. expect(screen.getByText('proj-slug-2')).toBeInTheDocument();
  566. expect(screen.getByText('My Projects')).toBeInTheDocument();
  567. await userEvent.click(firstProjectOption);
  568. expect(spy).toHaveBeenCalledWith(
  569. '/organizations/org-slug/performance/summary/?transaction=/performance&statsPeriod=14d&referrer=performance-transaction-summary&transactionCursor=1:0:0&project=1'
  570. );
  571. });
  572. it('fetches transaction threshold', function () {
  573. const {organization, router, routerContext} = initializeData();
  574. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  575. url: '/organizations/org-slug/project-transaction-threshold-override/',
  576. method: 'GET',
  577. body: {
  578. threshold: '800',
  579. metric: 'lcp',
  580. },
  581. });
  582. const getProjectThresholdMock = MockApiClient.addMockResponse({
  583. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  584. method: 'GET',
  585. body: {
  586. threshold: '200',
  587. metric: 'duration',
  588. },
  589. });
  590. render(<TestComponent router={router} location={router.location} />, {
  591. context: routerContext,
  592. organization,
  593. });
  594. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  595. expect(getProjectThresholdMock).not.toHaveBeenCalled();
  596. });
  597. it('fetches project transaction threshdold', async function () {
  598. const {organization, router, routerContext} = initializeData();
  599. const getTransactionThresholdMock = MockApiClient.addMockResponse({
  600. url: '/organizations/org-slug/project-transaction-threshold-override/',
  601. method: 'GET',
  602. statusCode: 404,
  603. });
  604. const getProjectThresholdMock = MockApiClient.addMockResponse({
  605. url: '/projects/org-slug/project-slug/transaction-threshold/configure/',
  606. method: 'GET',
  607. body: {
  608. threshold: '200',
  609. metric: 'duration',
  610. },
  611. });
  612. render(<TestComponent router={router} location={router.location} />, {
  613. context: routerContext,
  614. organization,
  615. });
  616. await screen.findByText('Transaction Summary');
  617. expect(getTransactionThresholdMock).toHaveBeenCalledTimes(1);
  618. expect(getProjectThresholdMock).toHaveBeenCalledTimes(1);
  619. });
  620. it('triggers a navigation on search', async function () {
  621. const {organization, router, routerContext} = initializeData();
  622. render(<TestComponent router={router} location={router.location} />, {
  623. context: routerContext,
  624. organization,
  625. });
  626. // Fill out the search box, and submit it.
  627. await userEvent.type(
  628. screen.getByLabelText('Search events'),
  629. 'user.email:uhoh*{enter}'
  630. );
  631. // Check the navigation.
  632. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  633. expect(browserHistory.push).toHaveBeenCalledWith({
  634. pathname: undefined,
  635. query: {
  636. transaction: '/performance',
  637. project: '2',
  638. statsPeriod: '14d',
  639. query: 'user.email:uhoh*',
  640. transactionCursor: '1:0:0',
  641. },
  642. });
  643. });
  644. it('can mark a transaction as key', async function () {
  645. const {organization, router, routerContext} = initializeData();
  646. render(<TestComponent router={router} location={router.location} />, {
  647. context: routerContext,
  648. organization,
  649. });
  650. const mockUpdate = MockApiClient.addMockResponse({
  651. url: `/organizations/org-slug/key-transactions/`,
  652. method: 'POST',
  653. body: {},
  654. });
  655. await screen.findByRole('button', {name: 'Star for Team'});
  656. // Click the key transaction button
  657. await userEvent.click(screen.getByRole('button', {name: 'Star for Team'}));
  658. await userEvent.click(screen.getByText('team1'));
  659. // Ensure request was made.
  660. expect(mockUpdate).toHaveBeenCalled();
  661. });
  662. it('triggers a navigation on transaction filter', 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('Transaction Summary');
  669. await waitFor(() => {
  670. expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
  671. });
  672. // Open the transaction filter dropdown
  673. await userEvent.click(
  674. screen.getByRole('button', {name: 'Filter Slow Transactions (p95)'})
  675. );
  676. await userEvent.click(screen.getAllByText('Slow Transactions (p95)')[1]);
  677. // Check the navigation.
  678. expect(browserHistory.push).toHaveBeenCalledWith({
  679. pathname: undefined,
  680. query: {
  681. transaction: '/performance',
  682. project: '2',
  683. showTransactions: 'slow',
  684. transactionCursor: undefined,
  685. },
  686. });
  687. });
  688. it('renders pagination buttons', async function () {
  689. const {organization, router, routerContext} = initializeData();
  690. render(<TestComponent router={router} location={router.location} />, {
  691. context: routerContext,
  692. organization,
  693. });
  694. await screen.findByText('Transaction Summary');
  695. expect(await screen.findByLabelText('Previous')).toBeInTheDocument();
  696. // Click the 'next' button
  697. await userEvent.click(screen.getByLabelText('Next'));
  698. // Check the navigation.
  699. expect(browserHistory.push).toHaveBeenCalledWith({
  700. pathname: undefined,
  701. query: {
  702. transaction: '/performance',
  703. project: '2',
  704. transactionCursor: '2:0:0',
  705. },
  706. });
  707. });
  708. it('forwards conditions to related issues', async function () {
  709. const issueGet = MockApiClient.addMockResponse({
  710. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  711. body: [],
  712. });
  713. const {organization, router, routerContext} = initializeData({
  714. query: {query: 'tag:value'},
  715. });
  716. render(<TestComponent router={router} location={router.location} />, {
  717. context: routerContext,
  718. organization,
  719. });
  720. await screen.findByText('Transaction Summary');
  721. expect(issueGet).toHaveBeenCalled();
  722. });
  723. it('does not forward event type to related issues', async function () {
  724. const issueGet = MockApiClient.addMockResponse({
  725. url: '/organizations/org-slug/issues/?limit=5&project=2&query=tag%3Avalue%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  726. body: [],
  727. match: [
  728. (_, options) => {
  729. // event.type must NOT be in the query params
  730. return !options.query?.query?.includes('event.type');
  731. },
  732. ],
  733. });
  734. const {organization, router, routerContext} = initializeData({
  735. query: {query: 'tag:value event.type:transaction'},
  736. });
  737. render(<TestComponent router={router} location={router.location} />, {
  738. context: routerContext,
  739. organization,
  740. });
  741. await screen.findByText('Transaction Summary');
  742. expect(issueGet).toHaveBeenCalled();
  743. });
  744. it('renders the suspect spans table if the feature is enabled', async function () {
  745. MockApiClient.addMockResponse({
  746. url: '/organizations/org-slug/events-spans-performance/',
  747. body: [],
  748. });
  749. const {organization, router, routerContext} = initializeData({
  750. features: ['performance-suspect-spans-view'],
  751. });
  752. render(<TestComponent router={router} location={router.location} />, {
  753. context: routerContext,
  754. organization,
  755. });
  756. expect(await screen.findByText('Suspect Spans')).toBeInTheDocument();
  757. });
  758. it('adds search condition on transaction status when clicking on status breakdown', async function () {
  759. const {organization, router, routerContext} = initializeData();
  760. render(<TestComponent router={router} location={router.location} />, {
  761. context: routerContext,
  762. organization,
  763. });
  764. await screen.findByTestId('status-ok');
  765. await userEvent.click(screen.getByTestId('status-ok'));
  766. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  767. expect(browserHistory.push).toHaveBeenCalledWith(
  768. expect.objectContaining({
  769. query: expect.objectContaining({
  770. query: expect.stringContaining('transaction.status:ok'),
  771. }),
  772. })
  773. );
  774. });
  775. it('appends tag value to existing query when clicked', async function () {
  776. const {organization, router, routerContext} = initializeData();
  777. render(<TestComponent router={router} location={router.location} />, {
  778. context: routerContext,
  779. organization,
  780. });
  781. await screen.findByText('Tag Summary');
  782. await userEvent.click(
  783. await screen.findByLabelText(
  784. 'environment, dev, 100% of all events. View events with this tag value.'
  785. )
  786. );
  787. await userEvent.click(
  788. await screen.findByLabelText(
  789. 'foo, bar, 100% of all events. View events with this tag value.'
  790. )
  791. );
  792. expect(router.push).toHaveBeenCalledTimes(2);
  793. expect(router.push).toHaveBeenNthCalledWith(1, {
  794. query: {
  795. project: '2',
  796. query: 'tags[environment]:dev',
  797. transaction: '/performance',
  798. transactionCursor: '1:0:0',
  799. },
  800. });
  801. expect(router.push).toHaveBeenNthCalledWith(2, {
  802. query: {
  803. project: '2',
  804. query: 'foo:bar',
  805. transaction: '/performance',
  806. transactionCursor: '1:0:0',
  807. },
  808. });
  809. });
  810. it('does not use MEP dataset for stats query without features', async function () {
  811. const {organization, router, routerContext} = initializeData({
  812. query: {query: 'transaction.op:pageload'}, // transaction.op is covered by the metrics dataset
  813. features: [''], // No 'dynamic-sampling' feature to indicate it can use metrics dataset or metrics enhanced.
  814. });
  815. render(<TestComponent router={router} location={router.location} />, {
  816. context: routerContext,
  817. organization,
  818. });
  819. await screen.findByText('Transaction Summary');
  820. await screen.findByRole('heading', {name: 'Apdex'});
  821. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.6');
  822. expect(eventStatsMock).toHaveBeenNthCalledWith(
  823. 1,
  824. expect.anything(),
  825. expect.objectContaining({
  826. query: expect.objectContaining({
  827. environment: [],
  828. interval: '30m',
  829. partial: '1',
  830. project: [2],
  831. query:
  832. 'transaction.op:pageload event.type:transaction transaction:/performance',
  833. referrer: 'api.performance.transaction-summary.duration-chart',
  834. statsPeriod: '14d',
  835. yAxis: ['p50()', 'p75()', 'p95()', 'p99()', 'p100()'],
  836. }),
  837. })
  838. );
  839. });
  840. it('uses MEP dataset for stats query', async function () {
  841. const {organization, router, routerContext} = initializeData({
  842. query: {query: 'transaction.op:pageload'}, // transaction.op is covered by the metrics dataset
  843. features: ['dynamic-sampling', 'mep-rollout-flag'],
  844. });
  845. render(<TestComponent router={router} location={router.location} />, {
  846. context: routerContext,
  847. organization,
  848. });
  849. await screen.findByText('Transaction Summary');
  850. // Renders Apdex widget
  851. await screen.findByRole('heading', {name: 'Apdex'});
  852. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.5');
  853. expect(eventStatsMock).toHaveBeenNthCalledWith(
  854. 1,
  855. expect.anything(),
  856. expect.objectContaining({
  857. query: expect.objectContaining({
  858. query:
  859. 'transaction.op:pageload event.type:transaction transaction:/performance',
  860. dataset: 'metricsEnhanced',
  861. }),
  862. })
  863. );
  864. // Renders Failure Rate widget
  865. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  866. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  867. // Renders TPM widget
  868. expect(
  869. screen.getByRole('heading', {name: 'Percentage of Total Transactions'})
  870. ).toBeInTheDocument();
  871. expect(screen.getByTestId('count-percentage-summary-value')).toHaveTextContent(
  872. '100%'
  873. );
  874. expect(
  875. screen.queryByTestId('search-metrics-fallback-warning')
  876. ).not.toBeInTheDocument();
  877. });
  878. it('uses MEP dataset for stats query and shows fallback warning', async function () {
  879. MockApiClient.addMockResponse({
  880. url: '/organizations/org-slug/issues/?limit=5&project=2&query=has%3Anot-compatible%20is%3Aunresolved%20transaction%3A%2Fperformance&sort=new&statsPeriod=14d',
  881. body: [],
  882. });
  883. MockApiClient.addMockResponse({
  884. url: '/organizations/org-slug/events/',
  885. body: {
  886. meta: {
  887. fields: {
  888. 'count()': 'number',
  889. 'apdex()': 'number',
  890. 'count_miserable_user()': 'number',
  891. 'user_misery()': 'number',
  892. 'count_unique_user()': 'number',
  893. 'p95()': 'number',
  894. 'failure_rate()': 'number',
  895. 'tpm()': 'number',
  896. project_threshold_config: 'string',
  897. },
  898. isMetricsData: false, // The total response is setting the metrics fallback behaviour.
  899. },
  900. data: [
  901. {
  902. 'count()': 200,
  903. 'apdex()': 0.5,
  904. 'count_miserable_user()': 120,
  905. 'user_misery()': 0.1,
  906. 'count_unique_user()': 100,
  907. 'p95()': 731.3132,
  908. 'failure_rate()': 1,
  909. 'tpm()': 100,
  910. project_threshold_config: ['duration', 300],
  911. },
  912. ],
  913. },
  914. match: [
  915. (_url, options) => {
  916. const isMetricsEnhanced =
  917. options.query?.dataset === DiscoverDatasets.METRICS_ENHANCED;
  918. return (
  919. options.query?.field?.includes('p95()') &&
  920. isMetricsEnhanced &&
  921. options.query?.query?.includes('not-compatible')
  922. );
  923. },
  924. ],
  925. });
  926. const {organization, router, routerContext} = initializeData({
  927. query: {query: 'transaction.op:pageload has:not-compatible'}, // Adds incompatible w/ metrics tag
  928. features: ['dynamic-sampling', 'mep-rollout-flag'],
  929. });
  930. render(<TestComponent router={router} location={router.location} />, {
  931. context: routerContext,
  932. organization,
  933. });
  934. await screen.findByText('Transaction Summary');
  935. // Renders Apdex widget
  936. await screen.findByRole('heading', {name: 'Apdex'});
  937. expect(await screen.findByTestId('apdex-summary-value')).toHaveTextContent('0.5');
  938. expect(eventStatsMock).toHaveBeenNthCalledWith(
  939. 1,
  940. expect.anything(),
  941. expect.objectContaining({
  942. query: expect.objectContaining({
  943. query:
  944. 'transaction.op:pageload has:not-compatible event.type:transaction transaction:/performance',
  945. dataset: 'metricsEnhanced',
  946. }),
  947. })
  948. );
  949. // Renders Failure Rate widget
  950. expect(screen.getByRole('heading', {name: 'Failure Rate'})).toBeInTheDocument();
  951. expect(screen.getByTestId('failure-rate-summary-value')).toHaveTextContent('100%');
  952. // Renders TPM widget
  953. expect(
  954. screen.getByRole('heading', {name: 'Percentage of Total Transactions'})
  955. ).toBeInTheDocument();
  956. expect(screen.getByTestId('count-percentage-summary-value')).toHaveTextContent(
  957. '100%'
  958. );
  959. expect(screen.getByTestId('search-metrics-fallback-warning')).toBeInTheDocument();
  960. });
  961. });
  962. });