diff options
Diffstat (limited to 'spec/frontend/runner/runner_list')
-rw-r--r-- | spec/frontend/runner/runner_list/runner_list_app_spec.js | 232 | ||||
-rw-r--r-- | spec/frontend/runner/runner_list/runner_search_utils_spec.js | 239 |
2 files changed, 471 insertions, 0 deletions
diff --git a/spec/frontend/runner/runner_list/runner_list_app_spec.js b/spec/frontend/runner/runner_list/runner_list_app_spec.js new file mode 100644 index 00000000000..dd913df7143 --- /dev/null +++ b/spec/frontend/runner/runner_list/runner_list_app_spec.js @@ -0,0 +1,232 @@ +import * as Sentry from '@sentry/browser'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import { updateHistory } from '~/lib/utils/url_utility'; + +import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; +import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; + +import { + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + INSTANCE_TYPE, + PARAM_KEY_STATUS, + STATUS_ACTIVE, + RUNNER_PAGE_SIZE, +} from '~/runner/constants'; +import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; +import RunnerListApp from '~/runner/runner_list/runner_list_app.vue'; + +import { runnersData, runnersDataPaginated } from '../mock_data'; + +const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockActiveRunnersCount = 2; + +jest.mock('@sentry/browser'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerListApp', () => { + let wrapper; + let mockRunnersQuery; + let originalLocation; + + const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); + const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + + const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getRunnersQuery, mockRunnersQuery]]; + + wrapper = mountFn(RunnerListApp, { + localVue, + apolloProvider: createMockApollo(handlers), + propsData: { + activeRunnersCount: mockActiveRunnersCount, + registrationToken: mockRegistrationToken, + ...props, + }, + }); + }; + + const setQuery = (query) => { + window.location.href = `${TEST_HOST}/admin/runners/${query}`; + window.location.search = query; + }; + + beforeAll(() => { + originalLocation = window.location; + Object.defineProperty(window, 'location', { writable: true, value: { href: '', search: '' } }); + }); + + afterAll(() => { + window.location = originalLocation; + }); + + beforeEach(async () => { + setQuery(''); + + Sentry.withScope.mockImplementation((fn) => { + const scope = { setTag: jest.fn() }; + fn(scope); + }); + + mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); + createComponentWithApollo(); + await waitForPromises(); + }); + + afterEach(() => { + mockRunnersQuery.mockReset(); + wrapper.destroy(); + }); + + it('shows the runners list', () => { + expect(runnersData.data.runners.nodes).toMatchObject(findRunnerList().props('runners')); + }); + + it('requests the runners with no filters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: undefined, + type: undefined, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('shows the runner type help', () => { + expect(findRunnerTypeHelp().exists()).toBe(true); + }); + + it('shows the runner setup instructions', () => { + expect(findRunnerManualSetupHelp().exists()).toBe(true); + expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + window.location.search = `?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`; + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + filters: [ + { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, + ], + sort: 'CREATED_DESC', + pagination: { page: 1 }, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: STATUS_ACTIVE, + type: INSTANCE_TYPE, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(() => { + findRunnerFilteredSearchBar().vm.$emit('input', { + filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }], + sort: CREATED_ASC, + }); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: 'http://test.host/admin/runners/?status[]=ACTIVE&sort=CREATED_ASC', + }); + }); + + it('requests the runners with filters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: STATUS_ACTIVE, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } }); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('shows a message for no results', async () => { + expect(wrapper.text()).toContain('No runners found'); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponentWithApollo(); + expect(findRunnerList().props('loading')).toBe(true); + }); + + describe('when runners query fails', () => { + beforeEach(async () => { + mockRunnersQuery = jest.fn().mockRejectedValue(new Error()); + createComponentWithApollo(); + + await waitForPromises(); + }); + + it('error is reported to sentry', async () => { + expect(Sentry.withScope).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); + + createComponentWithApollo({ mountFn: mount }); + }); + + it('more pages can be selected', () => { + expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next'); + }); + + it('cannot navigate to the previous page', () => { + expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev'); + }); + + it('navigates to the next page', async () => { + const nextPageBtn = findRunnerPagination().find('a'); + expect(nextPageBtn.text()).toBe('Next'); + + await nextPageBtn.trigger('click'); + + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: runnersDataPaginated.data.runners.pageInfo.endCursor, + }); + }); + }); +}); diff --git a/spec/frontend/runner/runner_list/runner_search_utils_spec.js b/spec/frontend/runner/runner_list/runner_search_utils_spec.js new file mode 100644 index 00000000000..a1f33e9c880 --- /dev/null +++ b/spec/frontend/runner/runner_list/runner_search_utils_spec.js @@ -0,0 +1,239 @@ +import { RUNNER_PAGE_SIZE } from '~/runner/constants'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, +} from '~/runner/runner_list/runner_search_utils'; + +describe('search_params.js', () => { + const examples = [ + { + name: 'a default query', + urlQuery: '', + search: { filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, + graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a single status', + urlQuery: '?status[]=ACTIVE', + search: { + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a single term text search', + urlQuery: '?search=something', + search: { + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a two terms text search', + urlQuery: '?search=something+else', + search: { + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: 'else' }, + }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'single instance type', + urlQuery: '?runner_type[]=INSTANCE_TYPE', + search: { + filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'multiple runner status', + urlQuery: '?status[]=ACTIVE&status[]=PAUSED', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'status', value: { data: 'PAUSED', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'multiple status, a single instance type and a non default sort', + urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + sort: 'CREATED_ASC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'the next page', + urlQuery: '?page=2&after=AFTER_CURSOR', + search: { filters: [], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC' }, + graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'the previous page', + urlQuery: '?page=2&before=BEFORE_CURSOR', + search: { + filters: [], + pagination: { page: 2, before: 'BEFORE_CURSOR' }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE }, + }, + { + name: + 'the next page filtered by multiple status, a single instance type and a non default sort', + urlQuery: + '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, + ], + pagination: { page: 2, after: 'AFTER_CURSOR' }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + sort: 'CREATED_ASC', + after: 'AFTER_CURSOR', + first: RUNNER_PAGE_SIZE, + }, + }, + ]; + + describe('fromUrlQueryToSearch', () => { + examples.forEach(({ name, urlQuery, search }) => { + it(`Converts ${name} to a search object`, () => { + expect(fromUrlQueryToSearch(urlQuery)).toEqual(search); + }); + }); + + it('When search params appear as array, they are concatenated', () => { + expect(fromUrlQueryToSearch('?search[]=my&search[]=text').filters).toEqual([ + { type: 'filtered-search-term', value: { data: 'my' } }, + { type: 'filtered-search-term', value: { data: 'text' } }, + ]); + }); + + it('When a page cannot be parsed as a number, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=NONSENSE&after=AFTER_CURSOR').pagination).toEqual({ + page: 1, + }); + }); + + it('When a page is less than 1, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=0&after=AFTER_CURSOR').pagination).toEqual({ + page: 1, + }); + }); + + it('When a page with no cursor is given, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=2').pagination).toEqual({ + page: 1, + }); + }); + }); + + describe('fromSearchToUrl', () => { + examples.forEach(({ name, urlQuery, search }) => { + it(`Converts ${name} to a url`, () => { + expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`); + }); + }); + + it.each([ + 'http://test.host/?status[]=ACTIVE', + 'http://test.host/?runner_type[]=INSTANCE_TYPE', + 'http://test.host/?search=my_text', + ])('When a filter is removed, it is removed from the URL', (initalUrl) => { + const search = { filters: [], sort: 'CREATED_DESC' }; + const expectedUrl = `http://test.host/`; + + expect(fromSearchToUrl(search, initalUrl)).toEqual(expectedUrl); + }); + + it('When unrelated search parameter is present, it does not get removed', () => { + const initialUrl = `http://test.host/?unrelated=UNRELATED&status[]=ACTIVE`; + const search = { filters: [], sort: 'CREATED_DESC' }; + const expectedUrl = `http://test.host/?unrelated=UNRELATED`; + + expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl); + }); + }); + + describe('fromSearchToVariables', () => { + examples.forEach(({ name, graphqlVariables, search }) => { + it(`Converts ${name} to a GraphQL query variables object`, () => { + expect(fromSearchToVariables(search)).toEqual(graphqlVariables); + }); + }); + + it('When a search param is empty, it gets removed', () => { + expect( + fromSearchToVariables({ + filters: [ + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }), + ).toMatchObject({ + search: '', + }); + + expect( + fromSearchToVariables({ + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }), + ).toMatchObject({ + search: 'something', + }); + }); + }); +}); |