index.spec.tsx 17 KB

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