index.spec.jsx 16 KB

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