index.spec.tsx 14 KB

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