import {initializeOrg} from 'sentry-test/initializeOrg'; import { act, fireEvent, render, screen, userEvent, waitFor, within, } from 'sentry-test/reactTestingLibrary'; import ProjectsStore from 'sentry/stores/projectsStore'; import ReleasesList from 'sentry/views/releases/list/'; import {ReleasesDisplayOption} from 'sentry/views/releases/list/releasesDisplayOptions'; import {ReleasesSortOption} from 'sentry/views/releases/list/releasesSortOptions'; import {ReleasesStatusOption} from 'sentry/views/releases/list/releasesStatusOptions'; describe('ReleasesList', () => { const {organization, routerContext, router} = initializeOrg(); const props = { router, organization, selection: { projects: [], environments: [], datetime: { period: '14d', }, }, params: {orgId: organization.slug}, location: { query: { query: 'derp', sort: ReleasesSortOption.SESSIONS, healthStatsPeriod: '24h', somethingBad: 'XXX', status: ReleasesStatusOption.ACTIVE, }, }, }; let endpointMock, sessionApiMock; beforeEach(() => { act(() => ProjectsStore.loadInitialData(organization.projects)); endpointMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ TestStubs.Release({version: '1.0.0'}), TestStubs.Release({version: '1.0.1'}), { ...TestStubs.Release({version: 'af4f231ec9a8'}), projects: [ { id: 4383604, name: 'Sentry-IOS-Shop', slug: 'sentry-ios-shop', hasHealthData: false, }, ], }, ], }); sessionApiMock = MockApiClient.addMockResponse({ url: `/organizations/org-slug/sessions/`, body: null, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/', body: [], }); }); afterEach(() => { act(() => ProjectsStore.reset()); MockApiClient.clearMockResponses(); }); it('renders list', async () => { render(, { context: routerContext, organization, }); const items = await screen.findAllByTestId('release-panel'); expect(within(items.at(0)).getByText('1.0.0')).toBeInTheDocument(); expect(within(items.at(0)).getByText('Adoption')).toBeInTheDocument(); expect(within(items.at(1)).getByText('1.0.1')).toBeInTheDocument(); expect(within(items.at(1)).getByText('0%')).toBeInTheDocument(); expect(within(items.at(2)).getByText('af4f231ec9a8')).toBeInTheDocument(); expect(within(items.at(2)).getByText('Project Name')).toBeInTheDocument(); }); it('displays the right empty state', async () => { let location; const project = TestStubs.Project({ id: '3', slug: 'test-slug', name: 'test-name', features: ['releases'], }); const projectWithouReleases = TestStubs.Project({ id: '4', slug: 'test-slug-2', name: 'test-name-2', features: [], }); const org = TestStubs.Organization({projects: [project, projectWithouReleases]}); ProjectsStore.loadInitialData(org.projects); MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/sentry-apps/', body: [], }); // does not have releases set up and no releases location = {query: {}}; const {rerender} = render( , { context: routerContext, organization, } ); expect(await screen.findByText('Set up Releases')).toBeInTheDocument(); expect(screen.queryByTestId('release-panel')).not.toBeInTheDocument(); // has releases set up and no releases location = {query: {query: 'abc'}}; rerender( , { context: routerContext, organization, } ); expect( screen.getByText("There are no releases that match: 'abc'.") ).toBeInTheDocument(); location = {query: {sort: ReleasesSortOption.SESSIONS, statsPeriod: '7d'}}; rerender( , { context: routerContext, organization, } ); expect( screen.getByText('There are no releases with data in the last 7 days.') ).toBeInTheDocument(); location = {query: {sort: ReleasesSortOption.USERS_24_HOURS, statsPeriod: '7d'}}; rerender( , { context: routerContext, organization, } ); expect( screen.getByText( 'There are no releases with active user data (users in the last 24 hours).' ) ).toBeInTheDocument(); location = {query: {sort: ReleasesSortOption.SESSIONS_24_HOURS, statsPeriod: '7d'}}; rerender( , { context: routerContext, organization, } ); expect( screen.getByText( 'There are no releases with active session data (sessions in the last 24 hours).' ) ).toBeInTheDocument(); location = {query: {sort: ReleasesSortOption.BUILD}}; rerender( , { context: routerContext, organization, } ); expect( screen.getByText('There are no releases with semantic versioning.') ).toBeInTheDocument(); }); it('displays request errors', async () => { const errorMessage = 'dumpster fire'; MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: { detail: errorMessage, }, statusCode: 400, }); render(, { context: routerContext, organization, }); expect(await screen.findByText(errorMessage)).toBeInTheDocument(); // we want release header to be visible despite the error message expect( screen.getByPlaceholderText('Search by version, build, package, or stage') ).toBeInTheDocument(); }); it('searches for a release', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/recent-searches/', method: 'POST', body: [], }); render(, { context: routerContext, organization, }); const input = await screen.findByRole('textbox'); expect(input).toHaveValue('derp '); expect(endpointMock).toHaveBeenCalledWith( '/organizations/org-slug/releases/', expect.objectContaining({ query: expect.objectContaining({query: 'derp'}), }) ); userEvent.clear(input); userEvent.type(input, 'a{enter}'); expect(router.push).toHaveBeenCalledWith({ query: expect.objectContaining({query: 'a'}), }); }); it('sorts releases', async () => { render(, { context: routerContext, organization, }); await waitFor(() => expect(endpointMock).toHaveBeenCalledWith( '/organizations/org-slug/releases/', expect.objectContaining({ query: expect.objectContaining({ sort: ReleasesSortOption.SESSIONS, }), }) ) ); userEvent.click(screen.getByText('Sort By')); const dateCreatedOption = screen.getByText('Date Created'); expect(dateCreatedOption).toBeInTheDocument(); userEvent.click(dateCreatedOption); expect(router.push).toHaveBeenCalledWith({ query: expect.objectContaining({ sort: ReleasesSortOption.DATE, }), }); }); it('disables adoption sort when more than one environment is selected', async () => { const adoptionProps = { ...props, organization, }; render( , { context: routerContext, organization, } ); const sortDropdown = await screen.findByText('Sort By'); expect(sortDropdown.parentElement).toHaveTextContent('Sort ByDate Created'); }); it('display the right Crash Free column', () => { render(, { context: routerContext, organization, }); // Find and click on the display menu's trigger button const statusTriggerButton = screen.getByRole('button', { name: 'Display Sessions', }); expect(statusTriggerButton).toBeInTheDocument(); userEvent.click(statusTriggerButton); // Expect to have 2 options in the status dropdown const crashFreeSessionsOption = screen.getAllByText('Sessions')[1]; const crashFreeUsersOption = screen.getByText('Users'); expect(crashFreeSessionsOption).toBeInTheDocument(); expect(crashFreeUsersOption).toBeInTheDocument(); userEvent.click(crashFreeUsersOption); expect(router.push).toHaveBeenCalledWith({ query: expect.objectContaining({ display: ReleasesDisplayOption.USERS, }), }); }); it('displays archived releases', async () => { render( , { context: routerContext, organization, } ); await waitFor(() => expect(endpointMock).toHaveBeenLastCalledWith( '/organizations/org-slug/releases/', expect.objectContaining({ query: expect.objectContaining({status: ReleasesStatusOption.ARCHIVED}), }) ) ); expect( await screen.findByText('These releases have been archived.') ).toBeInTheDocument(); // Find and click on the status menu's trigger button const statusTriggerButton = screen.getByRole('button', { name: 'Status Archived', }); expect(statusTriggerButton).toBeInTheDocument(); userEvent.click(statusTriggerButton); // Expect to have 2 options in the status dropdown const statusActiveOption = screen.getByRole('option', {name: 'Active'}); let statusArchivedOption = screen.getByRole('option', {name: 'Archived'}); expect(statusActiveOption).toBeInTheDocument(); expect(statusArchivedOption).toBeInTheDocument(); userEvent.click(statusActiveOption); expect(router.push).toHaveBeenLastCalledWith({ query: expect.objectContaining({ status: ReleasesStatusOption.ACTIVE, }), }); userEvent.click(statusTriggerButton); statusArchivedOption = screen.getByRole('option', {name: 'Archived'}); userEvent.click(statusArchivedOption); expect(router.push).toHaveBeenLastCalledWith({ query: expect.objectContaining({ status: ReleasesStatusOption.ARCHIVED, }), }); }); it('calls api with only explicitly permitted query params', () => { render(, { context: routerContext, organization, }); expect(endpointMock).toHaveBeenCalledWith( '/organizations/org-slug/releases/', expect.objectContaining({ query: expect.not.objectContaining({ somethingBad: 'XXX', }), }) ); }); it('calls session api for health data', async () => { render(, { context: routerContext, organization, }); await waitFor(() => expect(sessionApiMock).toHaveBeenCalledTimes(3)); expect(sessionApiMock).toHaveBeenCalledWith( '/organizations/org-slug/sessions/', expect.objectContaining({ query: expect.objectContaining({ field: ['sum(session)'], groupBy: ['project', 'release', 'session.status'], interval: '1d', query: 'release:1.0.0 OR release:1.0.1 OR release:af4f231ec9a8', statsPeriod: '14d', }), }) ); expect(sessionApiMock).toHaveBeenCalledWith( '/organizations/org-slug/sessions/', expect.objectContaining({ query: expect.objectContaining({ field: ['sum(session)'], groupBy: ['project'], interval: '1h', query: undefined, statsPeriod: '24h', }), }) ); expect(sessionApiMock).toHaveBeenCalledWith( '/organizations/org-slug/sessions/', expect.objectContaining({ query: expect.objectContaining({ field: ['sum(session)'], groupBy: ['project', 'release'], interval: '1h', query: 'release:1.0.0 OR release:1.0.1 OR release:af4f231ec9a8', statsPeriod: '24h', }), }) ); }); it('shows health rows only for selected projects in global header', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ { ...TestStubs.Release({version: '2.0.0'}), projects: [ { id: 1, name: 'Test', slug: 'test', }, { id: 2, name: 'Test2', slug: 'test2', }, { id: 3, name: 'Test3', slug: 'test3', }, ], }, ], }); render(, { context: routerContext, organization, }); const hiddenProjectsMessage = await screen.findByTestId('hidden-projects'); expect(hiddenProjectsMessage).toHaveTextContent('2 hidden projects'); expect(screen.getAllByTestId('release-card-project-row').length).toBe(1); expect(screen.getByTestId('badge-display-name')).toHaveTextContent('test2'); }); it('does not hide health rows when "All Projects" are selected in global header', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [TestStubs.Release({version: '2.0.0'})], }); render(, { context: routerContext, organization, }); expect(await screen.findByTestId('release-card-project-row')).toBeInTheDocument(); expect(screen.queryByTestId('hidden-projects')).not.toBeInTheDocument(); }); it('autocompletes semver search tag', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/tags/release.version/values/', body: [ { count: null, firstSeen: null, key: 'release.version', lastSeen: null, name: 'sentry@0.5.3', value: 'sentry@0.5.3', }, ], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/recent-searches/', method: 'POST', }); render(, { context: routerContext, organization, }); const smartSearchBar = await screen.findByTestId('smart-search-input'); userEvent.clear(smartSearchBar); fireEvent.change(smartSearchBar, {target: {value: 'release'}}); const autocompleteItems = await screen.findAllByTestId('search-autocomplete-item'); expect(autocompleteItems.at(0)).toHaveTextContent('release'); userEvent.clear(smartSearchBar); fireEvent.change(smartSearchBar, {target: {value: 'release.version:'}}); expect(await screen.findByText('sentry@0.5.3')).toBeInTheDocument(); }); });