index.spec.tsx 18 KB

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