index.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  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('sorts releases', async () => {
  272. render(<ReleasesList {...props} />, {
  273. router,
  274. organization,
  275. });
  276. await waitFor(() =>
  277. expect(endpointMock).toHaveBeenCalledWith(
  278. '/organizations/org-slug/releases/',
  279. expect.objectContaining({
  280. query: expect.objectContaining({
  281. sort: ReleasesSortOption.SESSIONS,
  282. }),
  283. })
  284. )
  285. );
  286. await userEvent.click(screen.getByText('Sort By'));
  287. const dateCreatedOption = screen.getByText('Date Created');
  288. expect(dateCreatedOption).toBeInTheDocument();
  289. await userEvent.click(dateCreatedOption);
  290. expect(router.push).toHaveBeenCalledWith(
  291. expect.objectContaining({
  292. query: expect.objectContaining({
  293. sort: ReleasesSortOption.DATE,
  294. }),
  295. })
  296. );
  297. });
  298. it('disables adoption sort when more than one environment is selected', async () => {
  299. const adoptionProps = {
  300. ...props,
  301. organization,
  302. };
  303. render(
  304. <ReleasesList
  305. {...adoptionProps}
  306. location={{...routerProps.location, query: {sort: ReleasesSortOption.ADOPTION}}}
  307. selection={{...props.selection, environments: ['a', 'b']}}
  308. />,
  309. {
  310. router,
  311. organization,
  312. }
  313. );
  314. const sortDropdown = await screen.findByText('Sort By');
  315. expect(sortDropdown.parentElement).toHaveTextContent('Sort ByDate Created');
  316. });
  317. it('display the right Crash Free column', async () => {
  318. render(<ReleasesList {...props} />, {
  319. router,
  320. organization,
  321. });
  322. // Find and click on the display menu's trigger button
  323. const statusTriggerButton = screen.getByRole('button', {
  324. name: 'Display Sessions',
  325. });
  326. expect(statusTriggerButton).toBeInTheDocument();
  327. await userEvent.click(statusTriggerButton);
  328. // Expect to have 2 options in the status dropdown
  329. const crashFreeSessionsOption = screen.getAllByText('Sessions')[1];
  330. const crashFreeUsersOption = screen.getByText('Users');
  331. expect(crashFreeSessionsOption).toBeInTheDocument();
  332. expect(crashFreeUsersOption).toBeInTheDocument();
  333. await userEvent.click(crashFreeUsersOption);
  334. expect(router.push).toHaveBeenCalledWith(
  335. expect.objectContaining({
  336. query: expect.objectContaining({
  337. display: ReleasesDisplayOption.USERS,
  338. }),
  339. })
  340. );
  341. });
  342. it('displays archived releases', async () => {
  343. render(
  344. <ReleasesList
  345. {...props}
  346. location={{
  347. ...routerProps.location,
  348. query: {status: ReleasesStatusOption.ARCHIVED},
  349. }}
  350. />,
  351. {
  352. router,
  353. organization,
  354. }
  355. );
  356. await waitFor(() =>
  357. expect(endpointMock).toHaveBeenLastCalledWith(
  358. '/organizations/org-slug/releases/',
  359. expect.objectContaining({
  360. query: expect.objectContaining({status: ReleasesStatusOption.ARCHIVED}),
  361. })
  362. )
  363. );
  364. expect(
  365. await screen.findByText('These releases have been archived.')
  366. ).toBeInTheDocument();
  367. // Find and click on the status menu's trigger button
  368. const statusTriggerButton = screen.getByRole('button', {
  369. name: 'Status Archived',
  370. });
  371. expect(statusTriggerButton).toBeInTheDocument();
  372. await userEvent.click(statusTriggerButton);
  373. // Expect to have 2 options in the status dropdown
  374. const statusActiveOption = screen.getByRole('option', {name: 'Active'});
  375. let statusArchivedOption = screen.getByRole('option', {name: 'Archived'});
  376. expect(statusActiveOption).toBeInTheDocument();
  377. expect(statusArchivedOption).toBeInTheDocument();
  378. await userEvent.click(statusActiveOption);
  379. expect(router.push).toHaveBeenLastCalledWith(
  380. expect.objectContaining({
  381. query: expect.objectContaining({
  382. status: ReleasesStatusOption.ACTIVE,
  383. }),
  384. })
  385. );
  386. await userEvent.click(statusTriggerButton);
  387. statusArchivedOption = screen.getByRole('option', {name: 'Archived'});
  388. await userEvent.click(statusArchivedOption);
  389. expect(router.push).toHaveBeenLastCalledWith(
  390. expect.objectContaining({
  391. query: expect.objectContaining({
  392. status: ReleasesStatusOption.ARCHIVED,
  393. }),
  394. })
  395. );
  396. });
  397. it('calls api with only explicitly permitted query params', async () => {
  398. render(<ReleasesList {...props} />, {
  399. router,
  400. organization,
  401. });
  402. await waitFor(() => {
  403. expect(endpointMock).toHaveBeenCalledWith(
  404. '/organizations/org-slug/releases/',
  405. expect.objectContaining({
  406. query: expect.not.objectContaining({
  407. somethingBad: 'XXX',
  408. }),
  409. })
  410. );
  411. });
  412. });
  413. it('calls session api for health data', async () => {
  414. render(<ReleasesList {...props} />, {
  415. router,
  416. organization,
  417. });
  418. await waitFor(() => expect(sessionApiMock).toHaveBeenCalledTimes(3));
  419. expect(sessionApiMock).toHaveBeenCalledWith(
  420. '/organizations/org-slug/sessions/',
  421. expect.objectContaining({
  422. query: expect.objectContaining({
  423. field: ['sum(session)'],
  424. groupBy: ['project', 'release', 'session.status'],
  425. interval: '1d',
  426. query: 'release:1.0.0 OR release:1.0.1 OR release:af4f231ec9a8',
  427. statsPeriod: '14d',
  428. }),
  429. })
  430. );
  431. expect(sessionApiMock).toHaveBeenCalledWith(
  432. '/organizations/org-slug/sessions/',
  433. expect.objectContaining({
  434. query: expect.objectContaining({
  435. field: ['sum(session)'],
  436. groupBy: ['project'],
  437. interval: '1h',
  438. query: undefined,
  439. statsPeriod: '24h',
  440. }),
  441. })
  442. );
  443. expect(sessionApiMock).toHaveBeenCalledWith(
  444. '/organizations/org-slug/sessions/',
  445. expect.objectContaining({
  446. query: expect.objectContaining({
  447. field: ['sum(session)'],
  448. groupBy: ['project', 'release'],
  449. interval: '1h',
  450. query: 'release:1.0.0 OR release:1.0.1 OR release:af4f231ec9a8',
  451. statsPeriod: '24h',
  452. }),
  453. })
  454. );
  455. });
  456. it('shows health rows only for selected projects in global header', async () => {
  457. MockApiClient.addMockResponse({
  458. url: '/organizations/org-slug/releases/',
  459. body: [
  460. {
  461. ...ReleaseFixture({version: '2.0.0'}),
  462. projects: [
  463. {
  464. id: 1,
  465. name: 'Test',
  466. slug: 'test',
  467. },
  468. {
  469. id: 2,
  470. name: 'Test2',
  471. slug: 'test2',
  472. },
  473. {
  474. id: 3,
  475. name: 'Test3',
  476. slug: 'test3',
  477. },
  478. ],
  479. },
  480. ],
  481. });
  482. render(<ReleasesList {...props} selection={{...props.selection, projects: [2]}} />, {
  483. router,
  484. organization,
  485. });
  486. const hiddenProjectsMessage = await screen.findByTestId('hidden-projects');
  487. expect(hiddenProjectsMessage).toHaveTextContent('2 hidden projects');
  488. expect(screen.getAllByTestId('release-card-project-row').length).toBe(1);
  489. expect(screen.getByTestId('badge-display-name')).toHaveTextContent('test2');
  490. });
  491. it('does not hide health rows when "All Projects" are selected in global header', async () => {
  492. MockApiClient.addMockResponse({
  493. url: '/organizations/org-slug/releases/',
  494. body: [ReleaseFixture({version: '2.0.0'})],
  495. });
  496. render(<ReleasesList {...props} selection={{...props.selection, projects: [-1]}} />, {
  497. router,
  498. organization,
  499. });
  500. expect(await screen.findByTestId('release-card-project-row')).toBeInTheDocument();
  501. expect(screen.queryByTestId('hidden-projects')).not.toBeInTheDocument();
  502. });
  503. it('autocompletes semver search tag', async () => {
  504. MockApiClient.addMockResponse({
  505. url: '/organizations/org-slug/tags/release.version/values/',
  506. body: [
  507. {
  508. count: null,
  509. firstSeen: null,
  510. key: 'release.version',
  511. lastSeen: null,
  512. name: 'sentry@0.5.3',
  513. value: 'sentry@0.5.3',
  514. },
  515. ],
  516. });
  517. MockApiClient.addMockResponse({
  518. url: '/organizations/org-slug/recent-searches/',
  519. method: 'POST',
  520. });
  521. render(<ReleasesList {...props} />, {
  522. router,
  523. organization,
  524. });
  525. const smartSearchBar = await screen.findByTestId('smart-search-input');
  526. await userEvent.clear(smartSearchBar);
  527. fireEvent.change(smartSearchBar, {target: {value: 'release'}});
  528. const autocompleteItems = await screen.findAllByTestId('search-autocomplete-item');
  529. expect(autocompleteItems.at(0)).toHaveTextContent('release');
  530. await userEvent.clear(smartSearchBar);
  531. fireEvent.change(smartSearchBar, {target: {value: 'release.version:'}});
  532. expect(await screen.findByText('sentry@0.5.3')).toBeInTheDocument();
  533. });
  534. });