index.spec.tsx 15 KB

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