index.spec.jsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. import {initializeOrg} from 'sentry-test/initializeOrg';
  2. import {
  3. act,
  4. fireEvent,
  5. render,
  6. screen,
  7. userEvent,
  8. waitFor,
  9. within,
  10. } from 'sentry-test/reactTestingLibrary';
  11. import ProjectsStore from 'sentry/stores/projectsStore';
  12. import ReleasesList from 'sentry/views/releases/list/';
  13. import {ReleasesDisplayOption} from 'sentry/views/releases/list/releasesDisplayOptions';
  14. import {ReleasesSortOption} from 'sentry/views/releases/list/releasesSortOptions';
  15. import {ReleasesStatusOption} from 'sentry/views/releases/list/releasesStatusOptions';
  16. import {RouteContext} from 'sentry/views/routeContext';
  17. describe('ReleasesList', () => {
  18. const {organization, routerContext, router} = initializeOrg();
  19. const props = {
  20. router,
  21. organization,
  22. selection: {
  23. projects: [],
  24. environments: [],
  25. datetime: {
  26. period: '14d',
  27. },
  28. },
  29. params: {orgId: organization.slug},
  30. location: {
  31. query: {
  32. query: 'derp',
  33. sort: ReleasesSortOption.SESSIONS,
  34. healthStatsPeriod: '24h',
  35. somethingBad: 'XXX',
  36. status: ReleasesStatusOption.ACTIVE,
  37. },
  38. },
  39. };
  40. let endpointMock, sessionApiMock;
  41. beforeEach(() => {
  42. act(() => ProjectsStore.loadInitialData(organization.projects));
  43. endpointMock = MockApiClient.addMockResponse({
  44. url: '/organizations/org-slug/releases/',
  45. body: [
  46. TestStubs.Release({version: '1.0.0'}),
  47. TestStubs.Release({version: '1.0.1'}),
  48. {
  49. ...TestStubs.Release({version: 'af4f231ec9a8'}),
  50. projects: [
  51. {
  52. id: 4383604,
  53. name: 'Sentry-IOS-Shop',
  54. slug: 'sentry-ios-shop',
  55. hasHealthData: false,
  56. },
  57. ],
  58. },
  59. ],
  60. });
  61. sessionApiMock = MockApiClient.addMockResponse({
  62. url: `/organizations/org-slug/sessions/`,
  63. body: null,
  64. });
  65. MockApiClient.addMockResponse({
  66. url: '/organizations/org-slug/projects/',
  67. body: [],
  68. });
  69. });
  70. afterEach(() => {
  71. act(() => ProjectsStore.reset());
  72. MockApiClient.clearMockResponses();
  73. });
  74. it('renders list', async () => {
  75. render(<ReleasesList {...props} />, {
  76. context: routerContext,
  77. organization,
  78. });
  79. const items = await screen.findAllByTestId('release-panel');
  80. expect(within(items.at(0)).getByText('1.0.0')).toBeInTheDocument();
  81. expect(within(items.at(0)).getByText('Adoption')).toBeInTheDocument();
  82. expect(within(items.at(1)).getByText('1.0.1')).toBeInTheDocument();
  83. expect(within(items.at(1)).getByText('0%')).toBeInTheDocument();
  84. expect(within(items.at(2)).getByText('af4f231ec9a8')).toBeInTheDocument();
  85. expect(within(items.at(2)).getByText('Project Name')).toBeInTheDocument();
  86. });
  87. it('displays the right empty state', async () => {
  88. let location;
  89. const project = TestStubs.Project({
  90. id: '3',
  91. slug: 'test-slug',
  92. name: 'test-name',
  93. features: ['releases'],
  94. });
  95. const projectWithouReleases = TestStubs.Project({
  96. id: '4',
  97. slug: 'test-slug-2',
  98. name: 'test-name-2',
  99. features: [],
  100. });
  101. const org = TestStubs.Organization({projects: [project, projectWithouReleases]});
  102. ProjectsStore.loadInitialData(org.projects);
  103. MockApiClient.addMockResponse({
  104. url: '/organizations/org-slug/releases/',
  105. body: [],
  106. });
  107. MockApiClient.addMockResponse({
  108. url: '/organizations/org-slug/sentry-apps/',
  109. body: [],
  110. });
  111. // does not have releases set up and no releases
  112. location = {query: {}};
  113. const {rerender} = render(
  114. <RouteContext.Provider value={routerContext}>
  115. <ReleasesList
  116. location={location}
  117. {...props}
  118. selection={{...props.selection, projects: [4]}}
  119. />
  120. </RouteContext.Provider>,
  121. {
  122. context: routerContext,
  123. organization,
  124. }
  125. );
  126. expect(await screen.findByText('Set up Releases')).toBeInTheDocument();
  127. expect(screen.queryByTestId('release-panel')).not.toBeInTheDocument();
  128. // has releases set up and no releases
  129. location = {query: {query: 'abc'}};
  130. rerender(
  131. <ReleasesList
  132. {...props}
  133. organization={org}
  134. location={location}
  135. selection={{...props.selection, projects: [3]}}
  136. />,
  137. {
  138. context: routerContext,
  139. organization,
  140. }
  141. );
  142. expect(
  143. screen.getByText("There are no releases that match: 'abc'.")
  144. ).toBeInTheDocument();
  145. location = {query: {sort: ReleasesSortOption.SESSIONS, statsPeriod: '7d'}};
  146. rerender(
  147. <ReleasesList
  148. {...props}
  149. organization={org}
  150. location={location}
  151. selection={{...props.selection, projects: [3]}}
  152. />,
  153. {
  154. context: routerContext,
  155. organization,
  156. }
  157. );
  158. expect(
  159. screen.getByText('There are no releases with data in the last 7 days.')
  160. ).toBeInTheDocument();
  161. location = {query: {sort: ReleasesSortOption.USERS_24_HOURS, statsPeriod: '7d'}};
  162. rerender(
  163. <ReleasesList
  164. {...props}
  165. organization={org}
  166. location={location}
  167. selection={{...props.selection, projects: [3]}}
  168. />,
  169. {
  170. context: routerContext,
  171. organization,
  172. }
  173. );
  174. expect(
  175. screen.getByText(
  176. 'There are no releases with active user data (users in the last 24 hours).'
  177. )
  178. ).toBeInTheDocument();
  179. location = {query: {sort: ReleasesSortOption.SESSIONS_24_HOURS, statsPeriod: '7d'}};
  180. rerender(
  181. <ReleasesList
  182. {...props}
  183. organization={org}
  184. location={location}
  185. selection={{...props.selection, projects: [3]}}
  186. />,
  187. {
  188. context: routerContext,
  189. organization,
  190. }
  191. );
  192. expect(
  193. screen.getByText(
  194. 'There are no releases with active session data (sessions in the last 24 hours).'
  195. )
  196. ).toBeInTheDocument();
  197. location = {query: {sort: ReleasesSortOption.BUILD}};
  198. rerender(
  199. <ReleasesList
  200. {...props}
  201. organization={org}
  202. location={location}
  203. selection={{...props.selection, projects: [3]}}
  204. />,
  205. {
  206. context: routerContext,
  207. organization,
  208. }
  209. );
  210. expect(
  211. screen.getByText('There are no releases with semantic versioning.')
  212. ).toBeInTheDocument();
  213. });
  214. it('displays request errors', async () => {
  215. const errorMessage = 'dumpster fire';
  216. MockApiClient.addMockResponse({
  217. url: '/organizations/org-slug/releases/',
  218. body: {
  219. detail: errorMessage,
  220. },
  221. statusCode: 400,
  222. });
  223. render(<ReleasesList {...props} />, {
  224. context: routerContext,
  225. organization,
  226. });
  227. expect(await screen.findByText(errorMessage)).toBeInTheDocument();
  228. // we want release header to be visible despite the error message
  229. expect(
  230. screen.getByPlaceholderText('Search by version, build, package, or stage')
  231. ).toBeInTheDocument();
  232. });
  233. it('searches for a release', async () => {
  234. MockApiClient.addMockResponse({
  235. url: '/organizations/org-slug/recent-searches/',
  236. method: 'POST',
  237. body: [],
  238. });
  239. render(<ReleasesList {...props} />, {
  240. context: routerContext,
  241. organization,
  242. });
  243. const input = await screen.findByRole('textbox');
  244. expect(input).toHaveValue('derp ');
  245. expect(endpointMock).toHaveBeenCalledWith(
  246. '/organizations/org-slug/releases/',
  247. expect.objectContaining({
  248. query: expect.objectContaining({query: 'derp'}),
  249. })
  250. );
  251. userEvent.clear(input);
  252. userEvent.type(input, 'a{enter}');
  253. expect(router.push).toHaveBeenCalledWith({
  254. query: expect.objectContaining({query: 'a'}),
  255. });
  256. });
  257. it('sorts releases', async () => {
  258. render(<ReleasesList {...props} />, {
  259. context: routerContext,
  260. organization,
  261. });
  262. await waitFor(() =>
  263. expect(endpointMock).toHaveBeenCalledWith(
  264. '/organizations/org-slug/releases/',
  265. expect.objectContaining({
  266. query: expect.objectContaining({
  267. sort: ReleasesSortOption.SESSIONS,
  268. }),
  269. })
  270. )
  271. );
  272. userEvent.click(screen.getByText('Sort By'));
  273. const dateCreatedOption = screen.getByText('Date Created');
  274. expect(dateCreatedOption).toBeInTheDocument();
  275. userEvent.click(dateCreatedOption);
  276. expect(router.push).toHaveBeenCalledWith({
  277. query: expect.objectContaining({
  278. sort: ReleasesSortOption.DATE,
  279. }),
  280. });
  281. });
  282. it('disables adoption sort when more than one environment is selected', async () => {
  283. const adoptionProps = {
  284. ...props,
  285. organization,
  286. };
  287. render(
  288. <ReleasesList
  289. {...adoptionProps}
  290. location={{query: {sort: ReleasesSortOption.ADOPTION}}}
  291. selection={{...props.selection, environments: ['a', 'b']}}
  292. />,
  293. {
  294. context: routerContext,
  295. organization,
  296. }
  297. );
  298. const sortDropdown = await screen.findByText('Sort By');
  299. expect(sortDropdown.parentElement).toHaveTextContent('Sort ByDate Created');
  300. });
  301. it('display the right Crash Free column', () => {
  302. render(<ReleasesList {...props} />, {
  303. context: routerContext,
  304. organization,
  305. });
  306. // Find and click on the display menu's trigger button
  307. const statusTriggerButton = screen.getByRole('button', {
  308. name: 'Display Sessions',
  309. });
  310. expect(statusTriggerButton).toBeInTheDocument();
  311. userEvent.click(statusTriggerButton);
  312. // Expect to have 2 options in the status dropdown
  313. const crashFreeSessionsOption = screen.getAllByText('Sessions')[1];
  314. const crashFreeUsersOption = screen.getByText('Users');
  315. expect(crashFreeSessionsOption).toBeInTheDocument();
  316. expect(crashFreeUsersOption).toBeInTheDocument();
  317. userEvent.click(crashFreeUsersOption);
  318. expect(router.push).toHaveBeenCalledWith({
  319. query: expect.objectContaining({
  320. display: ReleasesDisplayOption.USERS,
  321. }),
  322. });
  323. });
  324. it('displays archived releases', async () => {
  325. render(
  326. <ReleasesList
  327. {...props}
  328. location={{query: {status: ReleasesStatusOption.ARCHIVED}}}
  329. />,
  330. {
  331. context: routerContext,
  332. organization,
  333. }
  334. );
  335. await waitFor(() =>
  336. expect(endpointMock).toHaveBeenLastCalledWith(
  337. '/organizations/org-slug/releases/',
  338. expect.objectContaining({
  339. query: expect.objectContaining({status: ReleasesStatusOption.ARCHIVED}),
  340. })
  341. )
  342. );
  343. expect(
  344. await screen.findByText('These releases have been archived.')
  345. ).toBeInTheDocument();
  346. // Find and click on the status menu's trigger button
  347. const statusTriggerButton = screen.getByRole('button', {
  348. name: 'Status Archived',
  349. });
  350. expect(statusTriggerButton).toBeInTheDocument();
  351. userEvent.click(statusTriggerButton);
  352. // Expect to have 2 options in the status dropdown
  353. const statusActiveOption = screen.getByText('Active');
  354. let statusArchivedOption = screen.getAllByText('Archived')[1];
  355. expect(statusActiveOption).toBeInTheDocument();
  356. expect(statusArchivedOption).toBeInTheDocument();
  357. userEvent.click(statusActiveOption);
  358. expect(router.push).toHaveBeenLastCalledWith({
  359. query: expect.objectContaining({
  360. status: ReleasesStatusOption.ACTIVE,
  361. }),
  362. });
  363. userEvent.click(statusTriggerButton);
  364. statusArchivedOption = screen.getAllByText('Archived')[1];
  365. userEvent.click(statusArchivedOption);
  366. expect(router.push).toHaveBeenLastCalledWith({
  367. query: expect.objectContaining({
  368. status: ReleasesStatusOption.ARCHIVED,
  369. }),
  370. });
  371. });
  372. it('calls api with only explicitly permitted query params', () => {
  373. render(<ReleasesList {...props} />, {
  374. context: routerContext,
  375. organization,
  376. });
  377. expect(endpointMock).toHaveBeenCalledWith(
  378. '/organizations/org-slug/releases/',
  379. expect.objectContaining({
  380. query: expect.not.objectContaining({
  381. somethingBad: 'XXX',
  382. }),
  383. })
  384. );
  385. });
  386. it('calls session api for health data', async () => {
  387. render(<ReleasesList {...props} />, {
  388. context: routerContext,
  389. organization,
  390. });
  391. await waitFor(() => expect(sessionApiMock).toHaveBeenCalledTimes(3));
  392. expect(sessionApiMock).toHaveBeenCalledWith(
  393. '/organizations/org-slug/sessions/',
  394. expect.objectContaining({
  395. query: expect.objectContaining({
  396. field: ['sum(session)'],
  397. groupBy: ['project', 'release', 'session.status'],
  398. interval: '1d',
  399. query: 'release:1.0.0 OR release:1.0.1 OR release:af4f231ec9a8',
  400. statsPeriod: '14d',
  401. }),
  402. })
  403. );
  404. expect(sessionApiMock).toHaveBeenCalledWith(
  405. '/organizations/org-slug/sessions/',
  406. expect.objectContaining({
  407. query: expect.objectContaining({
  408. field: ['sum(session)'],
  409. groupBy: ['project'],
  410. interval: '1h',
  411. query: undefined,
  412. statsPeriod: '24h',
  413. }),
  414. })
  415. );
  416. expect(sessionApiMock).toHaveBeenCalledWith(
  417. '/organizations/org-slug/sessions/',
  418. expect.objectContaining({
  419. query: expect.objectContaining({
  420. field: ['sum(session)'],
  421. groupBy: ['project', 'release'],
  422. interval: '1h',
  423. query: 'release:1.0.0 OR release:1.0.1 OR release:af4f231ec9a8',
  424. statsPeriod: '24h',
  425. }),
  426. })
  427. );
  428. });
  429. it('shows health rows only for selected projects in global header', async () => {
  430. MockApiClient.addMockResponse({
  431. url: '/organizations/org-slug/releases/',
  432. body: [
  433. {
  434. ...TestStubs.Release({version: '2.0.0'}),
  435. projects: [
  436. {
  437. id: 1,
  438. name: 'Test',
  439. slug: 'test',
  440. },
  441. {
  442. id: 2,
  443. name: 'Test2',
  444. slug: 'test2',
  445. },
  446. {
  447. id: 3,
  448. name: 'Test3',
  449. slug: 'test3',
  450. },
  451. ],
  452. },
  453. ],
  454. });
  455. render(<ReleasesList {...props} selection={{...props.selection, projects: [2]}} />, {
  456. context: routerContext,
  457. organization,
  458. });
  459. const hiddenProjectsMessage = await screen.findByTestId('hidden-projects');
  460. expect(hiddenProjectsMessage).toHaveTextContent('2 hidden projects');
  461. expect(screen.getAllByTestId('release-card-project-row').length).toBe(1);
  462. expect(screen.getByTestId('badge-display-name')).toHaveTextContent('test2');
  463. });
  464. it('does not hide health rows when "All Projects" are selected in global header', async () => {
  465. MockApiClient.addMockResponse({
  466. url: '/organizations/org-slug/releases/',
  467. body: [TestStubs.Release({version: '2.0.0'})],
  468. });
  469. render(<ReleasesList {...props} selection={{...props.selection, projects: [-1]}} />, {
  470. context: routerContext,
  471. organization,
  472. });
  473. expect(await screen.findByTestId('release-card-project-row')).toBeInTheDocument();
  474. expect(screen.queryByTestId('hidden-projects')).not.toBeInTheDocument();
  475. });
  476. it('autocompletes semver search tag', async () => {
  477. MockApiClient.addMockResponse({
  478. url: '/organizations/org-slug/tags/release.version/values/',
  479. body: [
  480. {
  481. count: null,
  482. firstSeen: null,
  483. key: 'release.version',
  484. lastSeen: null,
  485. name: 'sentry@0.5.3',
  486. value: 'sentry@0.5.3',
  487. },
  488. ],
  489. });
  490. MockApiClient.addMockResponse({
  491. url: '/organizations/org-slug/recent-searches/',
  492. method: 'POST',
  493. });
  494. render(<ReleasesList {...props} />, {
  495. context: routerContext,
  496. organization,
  497. });
  498. const smartSearchBar = await screen.findByTestId('smart-search-input');
  499. userEvent.clear(smartSearchBar);
  500. fireEvent.change(smartSearchBar, {target: {value: 'release'}});
  501. const autocompleteItems = await screen.findAllByTestId('search-autocomplete-item');
  502. expect(autocompleteItems.at(0)).toHaveTextContent('release');
  503. userEvent.clear(smartSearchBar);
  504. fireEvent.change(smartSearchBar, {target: {value: 'release.version:'}});
  505. expect(await screen.findByText('sentry@0.5.3')).toBeInTheDocument();
  506. });
  507. });