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