index.spec.tsx 16 KB

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