index.spec.tsx 14 KB

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