index.spec.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import {mountWithTheme} from 'sentry-test/enzyme';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import ProjectsStore from 'app/stores/projectsStore';
  4. import ReleasesList from 'app/views/releases/list/';
  5. import {DisplayOption, SortOption, StatusOption} from 'app/views/releases/list/utils';
  6. describe('ReleasesList', function () {
  7. const {organization, routerContext, router} = initializeOrg();
  8. const props = {
  9. router,
  10. organization,
  11. selection: {
  12. projects: [],
  13. environments: [],
  14. datetime: {
  15. period: '14d',
  16. },
  17. },
  18. params: {orgId: organization.slug},
  19. location: {
  20. query: {
  21. query: 'derp',
  22. sort: SortOption.SESSIONS,
  23. healthStatsPeriod: '24h',
  24. somethingBad: 'XXX',
  25. status: StatusOption.ACTIVE,
  26. },
  27. },
  28. };
  29. let wrapper, endpointMock, sessionApiMock;
  30. beforeEach(async function () {
  31. ProjectsStore.loadInitialData(organization.projects);
  32. endpointMock = MockApiClient.addMockResponse({
  33. url: '/organizations/org-slug/releases/',
  34. body: [
  35. TestStubs.Release({version: '1.0.0'}),
  36. TestStubs.Release({version: '1.0.1'}),
  37. {
  38. ...TestStubs.Release({version: 'af4f231ec9a8'}),
  39. projects: [
  40. {
  41. id: 4383604,
  42. name: 'Sentry-IOS-Shop',
  43. slug: 'sentry-ios-shop',
  44. hasHealthData: false,
  45. },
  46. ],
  47. },
  48. ],
  49. });
  50. sessionApiMock = MockApiClient.addMockResponse({
  51. url: `/organizations/org-slug/sessions/`,
  52. body: null,
  53. });
  54. MockApiClient.addMockResponse({
  55. url: '/organizations/org-slug/projects/',
  56. body: [],
  57. });
  58. wrapper = mountWithTheme(<ReleasesList {...props} />, routerContext);
  59. await tick();
  60. wrapper.update();
  61. });
  62. afterEach(function () {
  63. wrapper.unmount();
  64. ProjectsStore.reset();
  65. MockApiClient.clearMockResponses();
  66. });
  67. it('renders list', function () {
  68. const items = wrapper.find('StyledPanel');
  69. expect(items).toHaveLength(3);
  70. expect(items.at(0).text()).toContain('1.0.0');
  71. expect(items.at(0).text()).toContain('Adoption');
  72. expect(items.at(1).text()).toContain('1.0.1');
  73. expect(items.at(1).find('AdoptionColumn').at(1).text()).toContain('\u2014');
  74. expect(items.at(2).text()).toContain('af4f231ec9a8');
  75. expect(items.at(2).find('Header').text()).toContain('Project');
  76. });
  77. it('displays the right empty state', function () {
  78. let location;
  79. MockApiClient.addMockResponse({
  80. url: '/organizations/org-slug/releases/',
  81. body: [],
  82. });
  83. location = {query: {}};
  84. wrapper = mountWithTheme(
  85. <ReleasesList {...props} location={location} />,
  86. routerContext
  87. );
  88. expect(wrapper.find('StyledPanel')).toHaveLength(0);
  89. expect(wrapper.find('ReleasePromo').text()).toContain('Demystify Releases');
  90. location = {query: {statsPeriod: '30d'}};
  91. wrapper = mountWithTheme(
  92. <ReleasesList {...props} location={location} />,
  93. routerContext
  94. );
  95. expect(wrapper.find('StyledPanel')).toHaveLength(0);
  96. expect(wrapper.find('ReleasePromo').text()).toContain('Demystify Releases');
  97. location = {query: {query: 'abc'}};
  98. wrapper = mountWithTheme(
  99. <ReleasesList {...props} location={location} />,
  100. routerContext
  101. );
  102. expect(wrapper.find('EmptyMessage').text()).toEqual(
  103. "There are no releases that match: 'abc'."
  104. );
  105. location = {query: {sort: SortOption.SESSIONS, statsPeriod: '7d'}};
  106. wrapper = mountWithTheme(
  107. <ReleasesList {...props} location={location} />,
  108. routerContext
  109. );
  110. expect(wrapper.find('EmptyMessage').text()).toEqual(
  111. 'There are no releases with data in the last 7 days.'
  112. );
  113. location = {query: {sort: SortOption.USERS_24_HOURS, statsPeriod: '7d'}};
  114. wrapper = mountWithTheme(
  115. <ReleasesList {...props} location={location} />,
  116. routerContext
  117. );
  118. expect(wrapper.find('EmptyMessage').text()).toEqual(
  119. 'There are no releases with active user data (users in the last 24 hours).'
  120. );
  121. location = {query: {sort: SortOption.SESSIONS_24_HOURS, statsPeriod: '7d'}};
  122. wrapper = mountWithTheme(
  123. <ReleasesList {...props} location={location} />,
  124. routerContext
  125. );
  126. expect(wrapper.find('EmptyMessage').text()).toEqual(
  127. 'There are no releases with active session data (sessions in the last 24 hours).'
  128. );
  129. location = {query: {sort: SortOption.BUILD}};
  130. wrapper = mountWithTheme(
  131. <ReleasesList {...props} location={location} />,
  132. routerContext
  133. );
  134. expect(wrapper.find('EmptyMessage').text()).toEqual(
  135. 'There are no releases with semantic versioning.'
  136. );
  137. });
  138. it('displays request errors', function () {
  139. const errorMessage = 'dumpster fire';
  140. MockApiClient.addMockResponse({
  141. url: '/organizations/org-slug/releases/',
  142. body: {
  143. detail: errorMessage,
  144. },
  145. statusCode: 400,
  146. });
  147. wrapper = mountWithTheme(<ReleasesList {...props} />, routerContext);
  148. expect(wrapper.find('LoadingError').text()).toBe(errorMessage);
  149. // we want release header to be visible despite the error message
  150. expect(wrapper.find('SortAndFilterWrapper').exists()).toBeTruthy();
  151. });
  152. it('searches for a release', function () {
  153. const input = wrapper.find('input');
  154. expect(endpointMock).toHaveBeenCalledWith(
  155. '/organizations/org-slug/releases/',
  156. expect.objectContaining({
  157. query: expect.objectContaining({query: 'derp'}),
  158. })
  159. );
  160. expect(input.prop('value')).toBe('derp');
  161. input.simulate('change', {target: {value: 'a'}}).simulate('submit');
  162. expect(router.push).toHaveBeenCalledWith({
  163. query: expect.objectContaining({query: 'a'}),
  164. });
  165. });
  166. it('sorts releases', function () {
  167. expect(endpointMock).toHaveBeenCalledWith(
  168. '/organizations/org-slug/releases/',
  169. expect.objectContaining({
  170. query: expect.objectContaining({
  171. sort: SortOption.SESSIONS,
  172. }),
  173. })
  174. );
  175. const sortDropdown = wrapper.find('ReleaseListSortOptions');
  176. const sortByOptions = sortDropdown.find('DropdownItem span');
  177. const dateCreatedOption = sortByOptions.at(0);
  178. expect(sortByOptions).toHaveLength(4);
  179. expect(dateCreatedOption.text()).toEqual('Date Created');
  180. const healthStatsControls = wrapper.find('AdoptionColumn span').first();
  181. expect(healthStatsControls.text()).toEqual('Adoption');
  182. dateCreatedOption.simulate('click');
  183. expect(router.push).toHaveBeenCalledWith({
  184. query: expect.objectContaining({
  185. sort: SortOption.DATE,
  186. }),
  187. });
  188. });
  189. it('disables adoption sort when more than one environment is selected', function () {
  190. wrapper.unmount();
  191. const adoptionProps = {
  192. ...props,
  193. organization: {...organization, features: ['release-adoption-stage']},
  194. };
  195. wrapper = mountWithTheme(
  196. <ReleasesList
  197. {...adoptionProps}
  198. location={{query: {sort: SortOption.ADOPTION}}}
  199. selection={{...props.selection, environments: ['a', 'b']}}
  200. />,
  201. routerContext
  202. );
  203. const sortDropdown = wrapper.find('ReleaseListSortOptions');
  204. expect(sortDropdown.find('ButtonLabel').text()).toBe('Sort ByDate Created');
  205. });
  206. it('display the right Crash Free column', async function () {
  207. const displayDropdown = wrapper.find('ReleaseListDisplayOptions');
  208. const activeDisplay = displayDropdown.find('DropdownButton button');
  209. expect(activeDisplay.text()).toEqual('DisplaySessions');
  210. const displayOptions = displayDropdown.find('DropdownItem');
  211. expect(displayOptions).toHaveLength(2);
  212. const crashFreeSessionsOption = displayOptions.at(0);
  213. expect(crashFreeSessionsOption.props().isActive).toEqual(true);
  214. expect(crashFreeSessionsOption.text()).toEqual('Sessions');
  215. const crashFreeUsersOption = displayOptions.at(1);
  216. expect(crashFreeUsersOption.text()).toEqual('Users');
  217. expect(crashFreeUsersOption.props().isActive).toEqual(false);
  218. crashFreeUsersOption.find('span').simulate('click');
  219. expect(router.push).toHaveBeenCalledWith({
  220. query: expect.objectContaining({
  221. display: DisplayOption.USERS,
  222. }),
  223. });
  224. });
  225. it('displays archived releases', function () {
  226. const archivedWrapper = mountWithTheme(
  227. <ReleasesList {...props} location={{query: {status: StatusOption.ARCHIVED}}} />,
  228. routerContext
  229. );
  230. expect(endpointMock).toHaveBeenLastCalledWith(
  231. '/organizations/org-slug/releases/',
  232. expect.objectContaining({
  233. query: expect.objectContaining({status: StatusOption.ARCHIVED}),
  234. })
  235. );
  236. expect(archivedWrapper.find('ReleaseArchivedNotice').exists()).toBeTruthy();
  237. const statusOptions = archivedWrapper
  238. .find('ReleaseListStatusOptions')
  239. .first()
  240. .find('DropdownItem span');
  241. const statusActiveOption = statusOptions.at(0);
  242. const statusArchivedOption = statusOptions.at(1);
  243. expect(statusOptions).toHaveLength(2);
  244. expect(statusActiveOption.text()).toEqual('Active');
  245. expect(statusArchivedOption.text()).toEqual('Archived');
  246. statusActiveOption.simulate('click');
  247. expect(router.push).toHaveBeenLastCalledWith({
  248. query: expect.objectContaining({
  249. status: StatusOption.ACTIVE,
  250. }),
  251. });
  252. expect(wrapper.find('ReleaseArchivedNotice').exists()).toBeFalsy();
  253. statusArchivedOption.simulate('click');
  254. expect(router.push).toHaveBeenLastCalledWith({
  255. query: expect.objectContaining({
  256. status: StatusOption.ARCHIVED,
  257. }),
  258. });
  259. });
  260. it('calls api with only explicitly permitted query params', function () {
  261. expect(endpointMock).toHaveBeenCalledWith(
  262. '/organizations/org-slug/releases/',
  263. expect.objectContaining({
  264. query: expect.not.objectContaining({
  265. somethingBad: 'XXX',
  266. }),
  267. })
  268. );
  269. });
  270. it('calls session api for health data', async function () {
  271. expect(sessionApiMock).toHaveBeenCalledTimes(3);
  272. expect(sessionApiMock).toHaveBeenCalledWith(
  273. '/organizations/org-slug/sessions/',
  274. expect.objectContaining({
  275. query: expect.objectContaining({
  276. field: ['sum(session)'],
  277. groupBy: ['project', 'release', 'session.status'],
  278. interval: '1d',
  279. query: 'release:1.0.0 OR release:1.0.1 OR release:af4f231ec9a8',
  280. statsPeriod: '14d',
  281. }),
  282. })
  283. );
  284. expect(sessionApiMock).toHaveBeenCalledWith(
  285. '/organizations/org-slug/sessions/',
  286. expect.objectContaining({
  287. query: expect.objectContaining({
  288. field: ['sum(session)'],
  289. groupBy: ['project'],
  290. interval: '1h',
  291. query: undefined,
  292. statsPeriod: '24h',
  293. }),
  294. })
  295. );
  296. expect(sessionApiMock).toHaveBeenCalledWith(
  297. '/organizations/org-slug/sessions/',
  298. expect.objectContaining({
  299. query: expect.objectContaining({
  300. field: ['sum(session)'],
  301. groupBy: ['project', 'release'],
  302. interval: '1h',
  303. query: 'release:1.0.0 OR release:1.0.1 OR release:af4f231ec9a8',
  304. statsPeriod: '24h',
  305. }),
  306. })
  307. );
  308. });
  309. it('shows health rows only for selected projects in global header', function () {
  310. MockApiClient.addMockResponse({
  311. url: '/organizations/org-slug/releases/',
  312. body: [
  313. {
  314. ...TestStubs.Release({version: '2.0.0'}),
  315. projects: [
  316. {
  317. id: 1,
  318. name: 'Test',
  319. slug: 'test',
  320. },
  321. {
  322. id: 2,
  323. name: 'Test2',
  324. slug: 'test2',
  325. },
  326. {
  327. id: 3,
  328. name: 'Test3',
  329. slug: 'test3',
  330. },
  331. ],
  332. },
  333. ],
  334. });
  335. const healthSection = mountWithTheme(
  336. <ReleasesList {...props} selection={{...props.selection, projects: [2]}} />,
  337. routerContext
  338. ).find('ReleaseHealth');
  339. const hiddenProjectsMessage = healthSection.find('HiddenProjectsMessage');
  340. expect(hiddenProjectsMessage.text()).toBe('2 hidden projects');
  341. expect(hiddenProjectsMessage.find('Tooltip').prop('title')).toBe('test, test3');
  342. expect(healthSection.find('ProjectRow').length).toBe(1);
  343. expect(healthSection.find('ProjectBadge').text()).toBe('test2');
  344. });
  345. it('does not hide health rows when "All Projects" are selected in global header', function () {
  346. MockApiClient.addMockResponse({
  347. url: '/organizations/org-slug/releases/',
  348. body: [TestStubs.Release({version: '2.0.0'})],
  349. });
  350. const healthSection = mountWithTheme(
  351. <ReleasesList {...props} selection={{...props.selection, projects: [-1]}} />,
  352. routerContext
  353. ).find('ReleaseHealth');
  354. expect(healthSection.find('HiddenProjectsMessage').exists()).toBeFalsy();
  355. expect(healthSection.find('ProjectRow').length).toBe(1);
  356. });
  357. it('autocompletes semver search tag', async function () {
  358. MockApiClient.addMockResponse({
  359. url: '/organizations/org-slug/tags/release.version/values/',
  360. body: [
  361. {
  362. count: null,
  363. firstSeen: null,
  364. key: 'release.version',
  365. lastSeen: null,
  366. name: 'sentry@0.5.3',
  367. value: 'sentry@0.5.3',
  368. },
  369. ],
  370. });
  371. const semverOrg = {...organization, features: ['semver']};
  372. wrapper.setProps({...props, organization: semverOrg});
  373. wrapper.find('SmartSearchBar textarea').simulate('click');
  374. wrapper
  375. .find('SmartSearchBar textarea')
  376. .simulate('change', {target: {value: 'sentry.semv'}});
  377. await tick();
  378. wrapper.update();
  379. expect(wrapper.find('[data-test-id="search-autocomplete-item"]').at(0).text()).toBe(
  380. 'release.version:'
  381. );
  382. wrapper.find('SmartSearchBar textarea').simulate('focus');
  383. wrapper
  384. .find('SmartSearchBar textarea')
  385. .simulate('change', {target: {value: 'release.version:'}});
  386. await tick();
  387. wrapper.update();
  388. expect(wrapper.find('[data-test-id="search-autocomplete-item"]').at(4).text()).toBe(
  389. 'sentry@0.5.3'
  390. );
  391. });
  392. });