index.spec.tsx 16 KB

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