index.spec.tsx 18 KB

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