index.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. import type {InjectedRouter} from 'react-router';
  2. import {MetricsFieldFixture} from 'sentry-fixture/metrics';
  3. import {OrganizationFixture} from 'sentry-fixture/organization';
  4. import {ProjectFixture} from 'sentry-fixture/project';
  5. import {initializeOrg} from 'sentry-test/initializeOrg';
  6. import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
  7. import {textWithMarkupMatcher} from 'sentry-test/utils';
  8. import ProjectsStore from 'sentry/stores/projectsStore';
  9. import TeamStore from 'sentry/stores/teamStore';
  10. import {browserHistory} from 'sentry/utils/browserHistory';
  11. import {WebVital} from 'sentry/utils/fields';
  12. import {Browser} from 'sentry/utils/performance/vitals/constants';
  13. import {DEFAULT_STATS_PERIOD} from 'sentry/views/performance/data';
  14. import VitalDetail from 'sentry/views/performance/vitalDetail';
  15. import {vitalSupportedBrowsers} from 'sentry/views/performance/vitalDetail/utils';
  16. const api = new MockApiClient();
  17. const organization = OrganizationFixture({
  18. features: ['discover-basic', 'performance-view'],
  19. projects: [ProjectFixture()],
  20. });
  21. const {organization: org, router} = initializeOrg({
  22. organization,
  23. router: {
  24. location: {
  25. query: {
  26. project: '1',
  27. },
  28. },
  29. },
  30. });
  31. function TestComponent(props: {router?: InjectedRouter} = {}) {
  32. return (
  33. <VitalDetail
  34. api={api}
  35. location={props.router?.location ?? router.location}
  36. router={props.router ?? router}
  37. params={{}}
  38. route={{}}
  39. routes={[]}
  40. routeParams={{}}
  41. />
  42. );
  43. }
  44. const testSupportedBrowserRendering = (webVital: WebVital) => {
  45. Object.values(Browser).forEach(browser => {
  46. const browserElement = screen.getByText(browser);
  47. expect(browserElement).toBeInTheDocument();
  48. const isSupported = vitalSupportedBrowsers[webVital]?.includes(browser);
  49. if (isSupported) {
  50. expect(within(browserElement).getByTestId('icon-check-mark')).toBeInTheDocument();
  51. } else {
  52. expect(within(browserElement).getByTestId('icon-close')).toBeInTheDocument();
  53. }
  54. });
  55. };
  56. describe('Performance > VitalDetail', function () {
  57. beforeEach(function () {
  58. TeamStore.loadInitialData([], false, null);
  59. ProjectsStore.loadInitialData(org.projects);
  60. browserHistory.push = jest.fn();
  61. MockApiClient.addMockResponse({
  62. url: `/organizations/${organization.slug}/projects/`,
  63. body: [],
  64. });
  65. MockApiClient.addMockResponse({
  66. url: `/organizations/${organization.slug}/tags/`,
  67. body: [],
  68. });
  69. MockApiClient.addMockResponse({
  70. url: `/organizations/${organization.slug}/events-stats/`,
  71. body: {data: [[123, []]]},
  72. });
  73. MockApiClient.addMockResponse({
  74. url: `/organizations/${organization.slug}/tags/user.email/values/`,
  75. body: [],
  76. });
  77. MockApiClient.addMockResponse({
  78. url: `/organizations/${organization.slug}/releases/stats/`,
  79. body: [],
  80. });
  81. MockApiClient.addMockResponse({
  82. url: `/organizations/${organization.slug}/users/`,
  83. body: [],
  84. });
  85. MockApiClient.addMockResponse({
  86. url: `/organizations/${organization.slug}/recent-searches/`,
  87. body: [],
  88. });
  89. MockApiClient.addMockResponse({
  90. url: `/organizations/${organization.slug}/recent-searches/`,
  91. method: 'POST',
  92. body: [],
  93. });
  94. MockApiClient.addMockResponse({
  95. url: `/organizations/${organization.slug}/events-vitals/`,
  96. body: {
  97. 'measurements.lcp': {
  98. poor: 1,
  99. meh: 2,
  100. good: 3,
  101. total: 6,
  102. p75: 4500,
  103. },
  104. },
  105. });
  106. MockApiClient.addMockResponse({
  107. url: `/organizations/${organization.slug}/events/`,
  108. body: {
  109. meta: {
  110. fields: {
  111. 'count()': 'integer',
  112. 'p95(measurements.lcp)': 'duration',
  113. transaction: 'string',
  114. 'p50(measurements.lcp)': 'duration',
  115. project: 'string',
  116. 'compare_numeric_aggregate(p75_measurements_lcp,greater,4000)': 'number',
  117. 'project.id': 'integer',
  118. 'count_unique_user()': 'integer',
  119. 'p75(measurements.lcp)': 'duration',
  120. },
  121. },
  122. data: [
  123. {
  124. 'count()': 100000,
  125. 'p95(measurements.lcp)': 5000,
  126. transaction: 'something',
  127. 'p50(measurements.lcp)': 3500,
  128. project: 'javascript',
  129. 'compare_numeric_aggregate(p75_measurements_lcp,greater,4000)': 1,
  130. 'count_unique_user()': 10000,
  131. 'p75(measurements.lcp)': 4500,
  132. },
  133. ],
  134. },
  135. match: [
  136. (_url, options) => {
  137. return options.query?.field?.find(f => f === 'p50(measurements.lcp)');
  138. },
  139. ],
  140. });
  141. MockApiClient.addMockResponse({
  142. url: `/organizations/${organization.slug}/events/`,
  143. body: {
  144. meta: {
  145. fields: {
  146. 'compare_numeric_aggregate(p75_measurements_cls,greater,0.1)': 'number',
  147. 'compare_numeric_aggregate(p75_measurements_cls,greater,0.25)': 'number',
  148. 'count()': 'integer',
  149. 'count_unique_user()': 'integer',
  150. team_key_transaction: 'boolean',
  151. 'p50(measurements.cls)': 'number',
  152. 'p75(measurements.cls)': 'number',
  153. 'p95(measurements.cls)': 'number',
  154. project: 'string',
  155. transaction: 'string',
  156. },
  157. },
  158. data: [
  159. {
  160. 'compare_numeric_aggregate(p75_measurements_cls,greater,0.1)': 1,
  161. 'compare_numeric_aggregate(p75_measurements_cls,greater,0.25)': 0,
  162. 'count()': 10000,
  163. 'count_unique_user()': 2740,
  164. team_key_transaction: 1,
  165. 'p50(measurements.cls)': 0.143,
  166. 'p75(measurements.cls)': 0.215,
  167. 'p95(measurements.cls)': 0.302,
  168. project: 'javascript',
  169. transaction: 'something',
  170. },
  171. ],
  172. },
  173. match: [
  174. (_url, options) => {
  175. return options.query?.field?.find(f => f === 'p50(measurements.cls)');
  176. },
  177. ],
  178. });
  179. MockApiClient.addMockResponse({
  180. method: 'GET',
  181. url: `/organizations/${organization.slug}/key-transactions-list/`,
  182. body: [],
  183. });
  184. // Metrics Requests
  185. MockApiClient.addMockResponse({
  186. method: 'GET',
  187. url: `/organizations/${organization.slug}/metrics/tags/`,
  188. body: [],
  189. });
  190. MockApiClient.addMockResponse({
  191. method: 'GET',
  192. url: `/organizations/${organization.slug}/metrics/data/`,
  193. body: MetricsFieldFixture('p75(sentry.transactions.measurements.lcp)'),
  194. match: [
  195. MockApiClient.matchQuery({
  196. field: ['p75(sentry.transactions.measurements.lcp)'],
  197. }),
  198. ],
  199. });
  200. });
  201. afterEach(function () {
  202. MockApiClient.clearMockResponses();
  203. ProjectsStore.reset();
  204. });
  205. it('renders basic UI elements', async function () {
  206. render(<TestComponent />, {
  207. router,
  208. organization: org,
  209. });
  210. // It shows a search bar
  211. expect(await screen.findByLabelText('Search events')).toBeInTheDocument();
  212. // It shows the vital card
  213. expect(
  214. screen.getByText(textWithMarkupMatcher('The p75 for all transactions is 4500ms'))
  215. ).toBeInTheDocument();
  216. expect(screen.getByText('Good 50%', {exact: false})).toBeInTheDocument();
  217. expect(screen.getByText('Meh 33%', {exact: false})).toBeInTheDocument();
  218. expect(screen.getByText('Poor 17%', {exact: false})).toBeInTheDocument();
  219. // It shows a chart
  220. expect(screen.getByText('Duration p75')).toBeInTheDocument();
  221. // It shows a table
  222. expect(screen.getByText('something').closest('td')).toBeInTheDocument();
  223. });
  224. it('triggers a navigation on search', async function () {
  225. render(<TestComponent />, {
  226. router,
  227. organization: org,
  228. });
  229. // Fill out the search box, and submit it.
  230. await userEvent.click(await screen.findByLabelText('Search events'));
  231. await userEvent.paste('user.email:uhoh*');
  232. await userEvent.keyboard('{enter}');
  233. // Check the navigation.
  234. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  235. expect(browserHistory.push).toHaveBeenCalledWith({
  236. pathname: undefined,
  237. query: {
  238. project: '1',
  239. statsPeriod: '14d',
  240. query: 'user.email:uhoh*',
  241. },
  242. });
  243. });
  244. it('applies conditions when linking to transaction summary', async function () {
  245. const newRouter = {
  246. ...router,
  247. location: {
  248. ...router.location,
  249. query: {
  250. query: 'sometag:value',
  251. },
  252. },
  253. };
  254. render(<TestComponent router={newRouter} />, {
  255. router: newRouter,
  256. organization: org,
  257. });
  258. expect(
  259. await screen.findByRole('heading', {name: 'Largest Contentful Paint'})
  260. ).toBeInTheDocument();
  261. await userEvent.click(
  262. screen.getByLabelText('See transaction summary of the transaction something')
  263. );
  264. expect(newRouter.push).toHaveBeenCalledWith({
  265. pathname: `/organizations/${organization.slug}/performance/summary/`,
  266. query: {
  267. transaction: 'something',
  268. project: undefined,
  269. environment: [],
  270. statsPeriod: DEFAULT_STATS_PERIOD,
  271. start: undefined,
  272. end: undefined,
  273. query: 'sometag:value has:measurements.lcp',
  274. referrer: 'performance-transaction-summary',
  275. unselectedSeries: ['p100()', 'avg()'],
  276. showTransactions: 'recent',
  277. display: 'vitals',
  278. trendFunction: undefined,
  279. trendColumn: undefined,
  280. },
  281. });
  282. });
  283. it('check CLS', async function () {
  284. const newRouter = {
  285. ...router,
  286. location: {
  287. ...router.location,
  288. query: {
  289. query: 'anothertag:value',
  290. vitalName: 'measurements.cls',
  291. },
  292. },
  293. };
  294. render(<TestComponent router={newRouter} />, {
  295. router: newRouter,
  296. organization: org,
  297. });
  298. expect(await screen.findByText('Cumulative Layout Shift')).toBeInTheDocument();
  299. await userEvent.click(
  300. screen.getByLabelText('See transaction summary of the transaction something')
  301. );
  302. expect(newRouter.push).toHaveBeenCalledWith({
  303. pathname: `/organizations/${organization.slug}/performance/summary/`,
  304. query: {
  305. transaction: 'something',
  306. project: undefined,
  307. environment: [],
  308. statsPeriod: DEFAULT_STATS_PERIOD,
  309. start: undefined,
  310. end: undefined,
  311. query: 'anothertag:value has:measurements.cls',
  312. referrer: 'performance-transaction-summary',
  313. unselectedSeries: ['p100()', 'avg()'],
  314. showTransactions: 'recent',
  315. display: 'vitals',
  316. trendFunction: undefined,
  317. trendColumn: undefined,
  318. },
  319. });
  320. // Check cells are not in ms
  321. expect(screen.getByText('0.215').closest('td')).toBeInTheDocument();
  322. });
  323. it('can switch vitals with dropdown menu', async function () {
  324. const newRouter = {
  325. ...router,
  326. location: {
  327. ...router.location,
  328. query: {
  329. project: 1,
  330. query: 'tag:value',
  331. },
  332. },
  333. };
  334. render(<TestComponent router={newRouter} />, {
  335. router: newRouter,
  336. organization: org,
  337. });
  338. const button = screen.getByRole('button', {name: /web vitals: lcp/i});
  339. expect(button).toBeInTheDocument();
  340. await userEvent.click(button);
  341. const menuItem = screen.getByRole('menuitemradio', {name: /fcp/i});
  342. expect(menuItem).toBeInTheDocument();
  343. await userEvent.click(menuItem);
  344. expect(browserHistory.push).toHaveBeenCalledTimes(1);
  345. expect(browserHistory.push).toHaveBeenCalledWith({
  346. pathname: undefined,
  347. query: {
  348. project: 1,
  349. query: 'tag:value',
  350. vitalName: 'measurements.fcp',
  351. },
  352. });
  353. });
  354. it('renders LCP vital correctly', async function () {
  355. render(<TestComponent />, {
  356. router,
  357. organization: org,
  358. });
  359. expect(await screen.findByText('Largest Contentful Paint')).toBeInTheDocument();
  360. expect(
  361. screen.getByText(textWithMarkupMatcher('The p75 for all transactions is 4500ms'))
  362. ).toBeInTheDocument();
  363. expect(screen.getByText('4.50s').closest('td')).toBeInTheDocument();
  364. });
  365. it('correctly renders which browsers support LCP', async function () {
  366. render(<TestComponent />, {
  367. router,
  368. organization: org,
  369. });
  370. expect(await screen.findAllByText(/Largest Contentful Paint/)).toHaveLength(2);
  371. testSupportedBrowserRendering(WebVital.LCP);
  372. });
  373. it('correctly renders which browsers support CLS', async function () {
  374. const newRouter = {
  375. ...router,
  376. location: {
  377. ...router.location,
  378. query: {
  379. vitalName: 'measurements.cls',
  380. },
  381. },
  382. };
  383. render(<TestComponent router={newRouter} />, {
  384. router,
  385. organization: org,
  386. });
  387. expect(await screen.findAllByText(/Cumulative Layout Shift/)).toHaveLength(2);
  388. testSupportedBrowserRendering(WebVital.CLS);
  389. });
  390. it('correctly renders which browsers support FCP', async function () {
  391. const newRouter = {
  392. ...router,
  393. location: {
  394. ...router.location,
  395. query: {
  396. vitalName: 'measurements.fcp',
  397. },
  398. },
  399. };
  400. MockApiClient.addMockResponse({
  401. url: `/organizations/${organization.slug}/events/`,
  402. body: [],
  403. });
  404. render(<TestComponent router={newRouter} />, {
  405. router,
  406. organization: org,
  407. });
  408. expect(await screen.findAllByText(/First Contentful Paint/)).toHaveLength(2);
  409. testSupportedBrowserRendering(WebVital.FCP);
  410. });
  411. it('correctly renders which browsers support FID', async function () {
  412. const newRouter = {
  413. ...router,
  414. location: {
  415. ...router.location,
  416. query: {
  417. vitalName: 'measurements.fid',
  418. },
  419. },
  420. };
  421. MockApiClient.addMockResponse({
  422. url: `/organizations/${organization.slug}/events/`,
  423. body: [],
  424. });
  425. render(<TestComponent router={newRouter} />, {
  426. router,
  427. organization: org,
  428. });
  429. expect(await screen.findAllByText(/First Input Delay/)).toHaveLength(2);
  430. testSupportedBrowserRendering(WebVital.FID);
  431. });
  432. });