index.spec.tsx 11 KB

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