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