index.spec.tsx 14 KB

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