import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';
import {ReleaseFixture} from 'sentry-fixture/release';
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, routerProps} = initializeOrg();
const semverVersionInfo = {
buildHash: null,
description: '1.2.3',
package: 'package',
version: {
raw: '1.2.3',
major: 1,
minor: 2,
patch: 3,
buildCode: null,
components: 3,
},
};
const props = {
...routerProps,
router,
organization,
selection: {
projects: [],
environments: [],
datetime: {
period: '14d',
start: null,
end: null,
utc: null,
},
},
params: {orgId: organization.slug},
location: {
...routerProps.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: [
ReleaseFixture({
version: '1.0.0',
versionInfo: {
...semverVersionInfo,
version: {...semverVersionInfo.version, raw: '1.0.0'},
},
}),
ReleaseFixture({
version: '1.0.1',
versionInfo: {
...semverVersionInfo,
version: {...semverVersionInfo.version, raw: '1.0.1'},
},
}),
{
...ReleaseFixture({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(items.length).toEqual(3);
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 = ProjectFixture({
id: '3',
slug: 'test-slug',
name: 'test-name',
features: ['releases'],
});
const projectWithouReleases = ProjectFixture({
id: '4',
slug: 'test-slug-2',
name: 'test-name-2',
features: [],
});
const org = OrganizationFixture({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 = {...routerProps.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(
);
expect(
await screen.findByText("There are no releases that match: 'abc'.")
).toBeInTheDocument();
location = {query: {sort: ReleasesSortOption.SESSIONS, statsPeriod: '7d'}};
rerender(
);
expect(
await screen.findByText('There are no releases with data in the last 7 days.')
).toBeInTheDocument();
location = {query: {sort: ReleasesSortOption.USERS_24_HOURS, statsPeriod: '7d'}};
rerender(
);
expect(
await screen.findByText(
'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(
);
expect(
await screen.findByText(
'There are no releases with active session data (sessions in the last 24 hours).'
)
).toBeInTheDocument();
location = {query: {sort: ReleasesSortOption.BUILD}};
rerender(
);
expect(
await screen.findByText('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.findByDisplayValue('derp');
expect(input).toBeInTheDocument();
expect(endpointMock).toHaveBeenCalledWith(
'/organizations/org-slug/releases/',
expect.objectContaining({
query: expect.objectContaining({query: 'derp'}),
})
);
await userEvent.clear(input);
await userEvent.type(input, 'a{enter}');
expect(router.push).toHaveBeenCalledWith(
expect.objectContaining({
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,
}),
})
)
);
await userEvent.click(screen.getByText('Sort By'));
const dateCreatedOption = screen.getByText('Date Created');
expect(dateCreatedOption).toBeInTheDocument();
await userEvent.click(dateCreatedOption);
expect(router.push).toHaveBeenCalledWith(
expect.objectContaining({
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', async () => {
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();
await 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();
await userEvent.click(crashFreeUsersOption);
expect(router.push).toHaveBeenCalledWith(
expect.objectContaining({
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();
await 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();
await userEvent.click(statusActiveOption);
expect(router.push).toHaveBeenLastCalledWith(
expect.objectContaining({
query: expect.objectContaining({
status: ReleasesStatusOption.ACTIVE,
}),
})
);
await userEvent.click(statusTriggerButton);
statusArchivedOption = screen.getByRole('option', {name: 'Archived'});
await userEvent.click(statusArchivedOption);
expect(router.push).toHaveBeenLastCalledWith(
expect.objectContaining({
query: expect.objectContaining({
status: ReleasesStatusOption.ARCHIVED,
}),
})
);
});
it('calls api with only explicitly permitted query params', async () => {
render(, {
context: routerContext,
organization,
});
await waitFor(() => {
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: [
{
...ReleaseFixture({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: [ReleaseFixture({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');
await userEvent.clear(smartSearchBar);
fireEvent.change(smartSearchBar, {target: {value: 'release'}});
const autocompleteItems = await screen.findAllByTestId('search-autocomplete-item');
expect(autocompleteItems.at(0)).toHaveTextContent('release');
await userEvent.clear(smartSearchBar);
fireEvent.change(smartSearchBar, {target: {value: 'release.version:'}});
expect(await screen.findByText('sentry@0.5.3')).toBeInTheDocument();
});
});