index.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import type {Query} from 'history';
  2. import {LocationFixture} from 'sentry-fixture/locationFixture';
  3. import {OrganizationFixture} from 'sentry-fixture/organization';
  4. import {ProjectFixture} from 'sentry-fixture/project';
  5. import {initializeOrg} from 'sentry-test/initializeOrg';
  6. import {
  7. act,
  8. render,
  9. screen,
  10. userEvent,
  11. waitForElementToBeRemoved,
  12. } from 'sentry-test/reactTestingLibrary';
  13. import ProjectsStore from 'sentry/stores/projectsStore';
  14. import type {Project} from 'sentry/types/project';
  15. import {useLocation} from 'sentry/utils/useLocation';
  16. import {useNavigate} from 'sentry/utils/useNavigate';
  17. import TransactionVitals from 'sentry/views/performance/transactionSummary/transactionVitals';
  18. import {
  19. VITAL_GROUPS,
  20. ZOOM_KEYS,
  21. } from 'sentry/views/performance/transactionSummary/transactionVitals/constants';
  22. jest.mock('sentry/utils/useLocation');
  23. const mockUseLocation = jest.mocked(useLocation);
  24. jest.mock('sentry/utils/useNavigate');
  25. const mockUseNavigate = jest.mocked(useNavigate);
  26. interface HistogramData {
  27. count: number;
  28. histogram: number;
  29. }
  30. function initialize({
  31. project,
  32. features,
  33. transaction,
  34. query,
  35. }: {
  36. features?: string[];
  37. project?: Project;
  38. query?: Query;
  39. transaction?: string;
  40. } = {}) {
  41. features = features || ['performance-view'];
  42. project = project || ProjectFixture();
  43. query = query || {};
  44. const data = initializeOrg({
  45. organization: OrganizationFixture({
  46. features,
  47. }),
  48. router: {
  49. location: {
  50. query: {
  51. transaction: transaction || '/',
  52. project: project?.id,
  53. ...query,
  54. },
  55. },
  56. },
  57. });
  58. act(() => ProjectsStore.loadInitialData(data.projects));
  59. return data;
  60. }
  61. /**
  62. * These values are what we expect to see on the page based on the
  63. * mocked api responses below.
  64. */
  65. const vitals = [
  66. {
  67. slug: 'fp',
  68. heading: 'First Paint (FP)',
  69. baseline: '4.57s',
  70. },
  71. {
  72. slug: 'fcp',
  73. heading: 'First Contentful Paint (FCP)',
  74. baseline: '1.46s',
  75. },
  76. {
  77. slug: 'lcp',
  78. heading: 'Largest Contentful Paint (LCP)',
  79. baseline: '1.34s',
  80. },
  81. {
  82. slug: 'fid',
  83. heading: 'First Input Delay (FID)',
  84. baseline: '987.00ms',
  85. },
  86. {
  87. slug: 'cls',
  88. heading: 'Cumulative Layout Shift (CLS)',
  89. baseline: '0.02',
  90. },
  91. ];
  92. describe('Performance > Web Vitals', function () {
  93. beforeEach(function () {
  94. mockUseLocation.mockReturnValue(
  95. LocationFixture({pathname: '/organizations/org-slug/performance/summary'})
  96. );
  97. jest.spyOn(console, 'error').mockImplementation(jest.fn());
  98. MockApiClient.addMockResponse({
  99. url: '/organizations/org-slug/projects/',
  100. body: [],
  101. });
  102. MockApiClient.addMockResponse({
  103. url: '/organizations/org-slug/events-has-measurements/',
  104. body: {measurements: true},
  105. });
  106. MockApiClient.addMockResponse({
  107. url: '/organizations/org-slug/project-transaction-threshold-override/',
  108. method: 'GET',
  109. body: {
  110. threshold: '800',
  111. metric: 'lcp',
  112. },
  113. });
  114. // Mock baseline measurements
  115. MockApiClient.addMockResponse({
  116. url: '/organizations/org-slug/events-vitals/',
  117. body: {
  118. 'measurements.fp': {poor: 1, meh: 2, good: 3, total: 6, p75: 4567},
  119. 'measurements.fcp': {poor: 1, meh: 2, good: 3, total: 6, p75: 1456},
  120. 'measurements.lcp': {poor: 1, meh: 2, good: 3, total: 6, p75: 1342},
  121. 'measurements.fid': {poor: 1, meh: 2, good: 3, total: 6, p75: 987},
  122. 'measurements.cls': {poor: 1, meh: 2, good: 3, total: 6, p75: 0.02},
  123. },
  124. });
  125. const histogramData: Record<string, HistogramData[]> = {};
  126. const webVitals = VITAL_GROUPS.reduce<string[]>(
  127. (vs, group) => vs.concat(group.vitals),
  128. []
  129. );
  130. for (const measurement of webVitals) {
  131. const data: HistogramData[] = [];
  132. for (let i = 0; i < 100; i++) {
  133. data.push({
  134. histogram: i,
  135. count: i,
  136. });
  137. }
  138. histogramData[`measurements.${measurement}`] = data;
  139. }
  140. MockApiClient.addMockResponse({
  141. url: '/organizations/org-slug/events-histogram/',
  142. body: histogramData,
  143. });
  144. MockApiClient.addMockResponse({
  145. method: 'GET',
  146. url: `/organizations/org-slug/key-transactions-list/`,
  147. body: [],
  148. });
  149. MockApiClient.addMockResponse({
  150. url: '/organizations/org-slug/prompts-activity/',
  151. body: {},
  152. });
  153. MockApiClient.addMockResponse({
  154. url: '/organizations/org-slug/sdk-updates/',
  155. body: [],
  156. });
  157. MockApiClient.addMockResponse({
  158. url: '/organizations/org-slug/replay-count/',
  159. body: {},
  160. });
  161. MockApiClient.addMockResponse({
  162. url: '/organizations/org-slug/recent-searches/',
  163. body: [],
  164. });
  165. });
  166. afterEach(() => {
  167. jest.clearAllMocks();
  168. });
  169. it('render no access without feature', function () {
  170. const {organization, router} = initialize({
  171. features: [],
  172. });
  173. render(<TransactionVitals organization={organization} location={router.location} />, {
  174. router,
  175. organization,
  176. });
  177. expect(screen.getByText("You don't have access to this feature")).toBeInTheDocument();
  178. });
  179. it('renders the basic UI components', function () {
  180. const {organization, router} = initialize({
  181. transaction: '/organizations/:orgId/',
  182. });
  183. render(<TransactionVitals organization={organization} location={router.location} />, {
  184. router,
  185. organization,
  186. });
  187. expect(screen.getByText('/organizations/:orgId/')).toBeInTheDocument();
  188. ['navigation', 'main'].forEach(role => {
  189. expect(screen.getByRole(role)).toBeInTheDocument();
  190. });
  191. });
  192. it('renders the correct bread crumbs', function () {
  193. const {organization, router} = initialize();
  194. render(<TransactionVitals organization={organization} location={router.location} />, {
  195. router,
  196. organization,
  197. });
  198. expect(screen.getByRole('navigation')).toHaveTextContent(
  199. 'PerformanceTransaction Summary'
  200. );
  201. });
  202. describe('renders all vitals cards correctly', function () {
  203. const {organization, router} = initialize();
  204. it.each(vitals)('Renders %s', async function (vital) {
  205. render(
  206. <TransactionVitals organization={organization} location={router.location} />,
  207. {router, organization}
  208. );
  209. expect(await screen.findByText(vital.heading)).toBeInTheDocument();
  210. expect(await screen.findByText(vital.baseline)).toBeInTheDocument();
  211. });
  212. });
  213. describe('reset view', function () {
  214. it('disables button on default view', function () {
  215. const {organization, router} = initialize();
  216. render(
  217. <TransactionVitals organization={organization} location={router.location} />,
  218. {router, organization}
  219. );
  220. expect(screen.getByRole('button', {name: 'Reset View'})).toBeDisabled();
  221. });
  222. it('enables button on left zoom', function () {
  223. const {organization, router} = initialize({
  224. query: {
  225. lcpStart: '20',
  226. },
  227. });
  228. render(
  229. <TransactionVitals organization={organization} location={router.location} />,
  230. {router, organization}
  231. );
  232. expect(screen.getByRole('button', {name: 'Reset View'})).toBeEnabled();
  233. });
  234. it('enables button on right zoom', function () {
  235. const {organization, router} = initialize({
  236. query: {
  237. fpEnd: '20',
  238. },
  239. });
  240. render(
  241. <TransactionVitals organization={organization} location={router.location} />,
  242. {router, organization}
  243. );
  244. expect(screen.getByRole('button', {name: 'Reset View'})).toBeEnabled();
  245. });
  246. it('enables button on left and right zoom', function () {
  247. const {organization, router} = initialize({
  248. query: {
  249. fcpStart: '20',
  250. fcpEnd: '20',
  251. },
  252. });
  253. render(
  254. <TransactionVitals organization={organization} location={router.location} />,
  255. {router, organization}
  256. );
  257. expect(screen.getByRole('button', {name: 'Reset View'})).toBeEnabled();
  258. });
  259. it('resets view properly', async function () {
  260. const mockNavigate = jest.fn();
  261. mockUseNavigate.mockReturnValue(mockNavigate);
  262. const {organization, router} = initialize({
  263. query: {
  264. fidStart: '20',
  265. lcpEnd: '20',
  266. },
  267. });
  268. render(
  269. <TransactionVitals organization={organization} location={router.location} />,
  270. {router, organization}
  271. );
  272. await userEvent.click(screen.getByRole('button', {name: 'Reset View'}));
  273. expect(mockNavigate).toHaveBeenCalledWith({
  274. query: expect.not.objectContaining(
  275. ZOOM_KEYS.reduce(
  276. (obj, key) => {
  277. obj[key] = expect.anything();
  278. return obj;
  279. },
  280. {} as Record<string, unknown>
  281. )
  282. ),
  283. });
  284. });
  285. it('renders an info alert when missing web vitals data', async function () {
  286. MockApiClient.addMockResponse({
  287. url: '/organizations/org-slug/events-vitals/',
  288. body: {
  289. 'measurements.fp': {poor: 1, meh: 2, good: 3, total: 6, p75: 4567},
  290. 'measurements.fcp': {poor: 1, meh: 2, good: 3, total: 6, p75: 1456},
  291. },
  292. });
  293. const {organization, router} = initialize({
  294. query: {
  295. lcpStart: '20',
  296. },
  297. });
  298. render(
  299. <TransactionVitals organization={organization} location={router.location} />,
  300. {router, organization}
  301. );
  302. await waitForElementToBeRemoved(() =>
  303. screen.queryAllByTestId('loading-placeholder')
  304. );
  305. expect(
  306. screen.getByText(
  307. 'If this page is looking a little bare, keep in mind not all browsers support these vitals.'
  308. )
  309. ).toBeInTheDocument();
  310. });
  311. it('does not render an info alert when data from all web vitals is present', async function () {
  312. const {organization, router} = initialize({
  313. query: {
  314. lcpStart: '20',
  315. },
  316. });
  317. render(
  318. <TransactionVitals organization={organization} location={router.location} />,
  319. {router, organization}
  320. );
  321. await waitForElementToBeRemoved(() =>
  322. screen.queryAllByTestId('loading-placeholder')
  323. );
  324. expect(
  325. screen.queryByText(
  326. 'If this page is looking a little bare, keep in mind not all browsers support these vitals.'
  327. )
  328. ).not.toBeInTheDocument();
  329. });
  330. });
  331. it('renders an info alert when some web vitals measurements has no data available', async function () {
  332. MockApiClient.addMockResponse({
  333. url: '/organizations/org-slug/events-vitals/',
  334. body: {
  335. 'measurements.cls': {poor: 1, meh: 2, good: 3, total: 6, p75: 4567},
  336. 'measurements.fcp': {poor: 1, meh: 2, good: 3, total: 6, p75: 4567},
  337. 'measurements.fid': {poor: 1, meh: 2, good: 3, total: 6, p75: 4567},
  338. 'measurements.fp': {poor: 1, meh: 2, good: 3, total: 6, p75: 1456},
  339. 'measurements.lcp': {poor: 0, meh: 0, good: 0, total: 0, p75: null},
  340. },
  341. });
  342. const {organization, router} = initialize({
  343. query: {
  344. lcpStart: '20',
  345. },
  346. });
  347. render(<TransactionVitals organization={organization} location={router.location} />, {
  348. router,
  349. organization,
  350. });
  351. await waitForElementToBeRemoved(() => screen.queryAllByTestId('loading-placeholder'));
  352. expect(
  353. screen.getByText(
  354. 'If this page is looking a little bare, keep in mind not all browsers support these vitals.'
  355. )
  356. ).toBeInTheDocument();
  357. });
  358. });