index.spec.tsx 11 KB

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