diff options
Diffstat (limited to 'spec/frontend/runner')
16 files changed, 1715 insertions, 15 deletions
diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js new file mode 100644 index 00000000000..12651a82a0c --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -0,0 +1,201 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; +import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql'; +import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; +import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; + +const mockId = '1'; + +const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; + +describe('RunnerTypeCell', () => { + let wrapper; + let mutate; + + const findEditBtn = () => wrapper.findByTestId('edit-runner'); + const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); + const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); + + const createComponent = ({ active = true } = {}, options) => { + wrapper = extendedWrapper( + shallowMount(RunnerActionCell, { + propsData: { + runner: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + active, + }, + }, + mocks: { + $apollo: { + mutate, + }, + }, + ...options, + }), + ); + }; + + beforeEach(() => { + mutate = jest.fn(); + }); + + afterEach(() => { + mutate.mockReset(); + wrapper.destroy(); + }); + + it('Displays the runner edit link with the correct href', () => { + createComponent(); + + expect(findEditBtn().attributes('href')).toBe('/admin/runners/1'); + }); + + describe.each` + state | label | icon | isActive | newActiveValue + ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false} + ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true} + `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => { + beforeEach(() => { + mutate.mockResolvedValue({ + data: { + runnerUpdate: { + runner: { + id: `gid://gitlab/Ci::Runner/1`, + __typename: 'CiRunner', + }, + }, + }, + }); + + createComponent({ active: isActive }); + }); + + it(`Displays a ${icon} button`, () => { + expect(findToggleActiveBtn().props('loading')).toBe(false); + expect(findToggleActiveBtn().props('icon')).toBe(icon); + expect(findToggleActiveBtn().attributes('title')).toBe(label); + expect(findToggleActiveBtn().attributes('aria-label')).toBe(label); + }); + + it(`After clicking the ${icon} button, the button has a loading state`, async () => { + await findToggleActiveBtn().vm.$emit('click'); + + expect(findToggleActiveBtn().props('loading')).toBe(true); + }); + + it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => { + await findToggleActiveBtn().vm.$emit('click'); + + expect(findToggleActiveBtn().attributes('title')).toBe(''); + expect(findToggleActiveBtn().attributes('aria-label')).toBe(''); + }); + + describe(`When clicking on the ${icon} button`, () => { + beforeEach(async () => { + await findToggleActiveBtn().vm.$emit('click'); + await waitForPromises(); + }); + + it(`The apollo mutation to set active to ${newActiveValue} is called`, () => { + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + mutation: runnerUpdateMutation, + variables: { + input: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + active: newActiveValue, + }, + }, + }); + }); + + it('The button does not have a loading state', () => { + expect(findToggleActiveBtn().props('loading')).toBe(false); + }); + }); + }); + + describe('When the user clicks a runner', () => { + beforeEach(() => { + createComponent(); + + mutate.mockResolvedValue({ + data: { + runnerDelete: { + runner: { + id: `gid://gitlab/Ci::Runner/1`, + __typename: 'CiRunner', + }, + }, + }, + }); + + jest.spyOn(window, 'confirm'); + }); + + describe('When the user confirms deletion', () => { + beforeEach(async () => { + window.confirm.mockReturnValue(true); + await findDeleteBtn().vm.$emit('click'); + }); + + it('The user sees a confirmation alert', async () => { + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(window.confirm).toHaveBeenCalledWith(expect.any(String)); + }); + + it('The delete mutation is called correctly', () => { + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + mutation: deleteRunnerMutation, + variables: { + input: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + }, + }, + awaitRefetchQueries: true, + refetchQueries: [getRunnersQueryName], + }); + }); + + it('The delete button does not have a loading state', () => { + expect(findDeleteBtn().props('loading')).toBe(false); + expect(findDeleteBtn().attributes('title')).toBe('Remove'); + }); + + it('After the delete button is clicked, loading state is shown', async () => { + await findDeleteBtn().vm.$emit('click'); + + expect(findDeleteBtn().props('loading')).toBe(true); + }); + + it('After the delete button is clicked, stale tooltip is removed', async () => { + await findDeleteBtn().vm.$emit('click'); + + expect(findDeleteBtn().attributes('title')).toBe(''); + }); + }); + + describe('When the user does not confirm deletion', () => { + beforeEach(async () => { + window.confirm.mockReturnValue(false); + await findDeleteBtn().vm.$emit('click'); + }); + + it('The user sees a confirmation alert', () => { + expect(window.confirm).toHaveBeenCalledTimes(1); + }); + + it('The delete mutation is not called', () => { + expect(mutate).toHaveBeenCalledTimes(0); + }); + + it('The delete button does not have a loading state', () => { + expect(findDeleteBtn().props('loading')).toBe(false); + expect(findDeleteBtn().attributes('title')).toBe('Remove'); + }); + }); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_name_cell_spec.js b/spec/frontend/runner/components/cells/runner_name_cell_spec.js new file mode 100644 index 00000000000..26055fc0faf --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_name_cell_spec.js @@ -0,0 +1,42 @@ +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue'; + +const mockId = '1'; +const mockShortSha = '2P6oDVDm'; +const mockDescription = 'runner-1'; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = () => { + wrapper = mount(RunnerNameCell, { + propsData: { + runner: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + shortSha: mockShortSha, + description: mockDescription, + }, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays the runner link with id and short token', () => { + expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`); + expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`); + }); + + it('Displays the runner description', () => { + expect(wrapper.text()).toContain(mockDescription); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_type_cell_spec.js b/spec/frontend/runner/components/cells/runner_type_cell_spec.js new file mode 100644 index 00000000000..48958a282fc --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_type_cell_spec.js @@ -0,0 +1,48 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue'; +import { INSTANCE_TYPE } from '~/runner/constants'; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findBadges = () => wrapper.findAllComponents(GlBadge); + + const createComponent = ({ runner = {} } = {}) => { + wrapper = mount(RunnerTypeCell, { + propsData: { + runner: { + runnerType: INSTANCE_TYPE, + active: true, + locked: false, + ...runner, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays the runner type', () => { + createComponent(); + + expect(findBadges()).toHaveLength(1); + expect(findBadges().at(0).text()).toBe('shared'); + }); + + it('Displays locked and paused states', () => { + createComponent({ + runner: { + active: false, + locked: true, + }, + }); + + expect(findBadges()).toHaveLength(3); + expect(findBadges().at(0).text()).toBe('shared'); + expect(findBadges().at(1).text()).toBe('locked'); + expect(findBadges().at(2).text()).toBe('paused'); + }); +}); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js new file mode 100644 index 00000000000..61a8f821b30 --- /dev/null +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -0,0 +1,137 @@ +import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE } from '~/runner/constants'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +describe('RunnerList', () => { + let wrapper; + + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); + const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); + + const mockDefaultSort = 'CREATED_DESC'; + const mockOtherSort = 'CONTACTED_DESC'; + const mockFilters = [ + { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }, + { type: 'filtered-search-term', value: { data: '' } }, + ]; + + const createComponent = ({ props = {}, options = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(RunnerFilteredSearchBar, { + propsData: { + value: { + filters: [], + sort: mockDefaultSort, + }, + ...props, + }, + attrs: { namespace: 'runners' }, + stubs: { + FilteredSearch, + GlFilteredSearch, + GlDropdown, + GlDropdownItem, + }, + ...options, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('binds a namespace to the filtered search', () => { + expect(findFilteredSearch().props('namespace')).toBe('runners'); + }); + + it('sets sorting options', () => { + const SORT_OPTIONS_COUNT = 2; + + expect(findSortOptions()).toHaveLength(SORT_OPTIONS_COUNT); + expect(findSortOptions().at(0).text()).toBe('Created date'); + expect(findSortOptions().at(1).text()).toBe('Last contact'); + }); + + it('sets tokens', () => { + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + ]); + }); + + it('fails validation for v-model with the wrong shape', () => { + expect(() => { + createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } }); + }).toThrow('Invalid prop: custom validator check failed'); + + expect(() => { + createComponent({ props: { value: { sort: 'sort' } } }); + }).toThrow('Invalid prop: custom validator check failed'); + }); + + describe('when a search is preselected', () => { + beforeEach(() => { + createComponent({ + props: { + value: { + sort: mockOtherSort, + filters: mockFilters, + }, + }, + }); + }); + + it('filter values are shown', () => { + expect(findGlFilteredSearch().props('value')).toEqual(mockFilters); + }); + + it('sort option is selected', () => { + expect( + findSortOptions() + .filter((w) => w.props('isChecked')) + .at(0) + .text(), + ).toEqual('Last contact'); + }); + }); + + it('when the user sets a filter, the "search" is emitted with filters', () => { + findGlFilteredSearch().vm.$emit('input', mockFilters); + findGlFilteredSearch().vm.$emit('submit'); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + filters: mockFilters, + sort: mockDefaultSort, + pagination: { page: 1 }, + }, + ]); + }); + + it('when the user sets a sorting method, the "search" is emitted with the sort', () => { + findSortOptions().at(1).vm.$emit('click'); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + filters: [], + sort: mockOtherSort, + pagination: { page: 1 }, + }, + ]); + }); +}); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js new file mode 100644 index 00000000000..d88d7b3fbee --- /dev/null +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -0,0 +1,130 @@ +import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerList from '~/runner/components/runner_list.vue'; +import { runnersData } from '../mock_data'; + +const mockRunners = runnersData.data.runners.nodes; +const mockActiveRunnersCount = mockRunners.length; + +describe('RunnerList', () => { + let wrapper; + + const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findTable = () => wrapper.findComponent(GlTable); + const findHeaders = () => wrapper.findAll('th'); + const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]'); + const findCell = ({ row = 0, fieldKey }) => + extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + wrapper = extendedWrapper( + mountFn(RunnerList, { + propsData: { + runners: mockRunners, + activeRunnersCount: mockActiveRunnersCount, + ...props, + }, + }), + ); + }; + + beforeEach(() => { + createComponent({}, mount); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays active runner count', () => { + expect(findActiveRunnersMessage().text()).toBe( + `Runners currently online: ${mockActiveRunnersCount}`, + ); + }); + + it('Displays a large active runner count', () => { + createComponent({ props: { activeRunnersCount: 2000 } }); + + expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000'); + }); + + it('Displays headers', () => { + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + + expect(headerLabels).toEqual([ + 'Type/State', + 'Runner', + 'Version', + 'IP Address', + 'Projects', + 'Jobs', + 'Tags', + 'Last contact', + '', // actions has no label + ]); + }); + + it('Displays a list of runners', () => { + expect(findRows()).toHaveLength(3); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('Displays details of a runner', () => { + const { id, description, version, ipAddress, shortSha } = mockRunners[0]; + + // Badges + expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('specific paused'); + + // Runner identifier + expect(findCell({ fieldKey: 'name' }).text()).toContain( + `#${getIdFromGraphQLId(id)} (${shortSha})`, + ); + expect(findCell({ fieldKey: 'name' }).text()).toContain(description); + + // Other fields: some cells are empty in the first iteration + // See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features + expect(findCell({ fieldKey: 'version' }).text()).toBe(version); + expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); + expect(findCell({ fieldKey: 'projectCount' }).text()).toBe(''); + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe(''); + expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); + expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); + + // Actions + const actions = findCell({ fieldKey: 'actions' }); + + expect(actions.findByTestId('edit-runner').exists()).toBe(true); + expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); + }); + + it('Links to the runner page', () => { + const { id } = mockRunners[0]; + + expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe( + `/admin/runners/${getIdFromGraphQLId(id)}`, + ); + }); + + describe('When data is loading', () => { + it('shows a busy state', () => { + createComponent({ props: { runners: [], loading: true } }); + expect(findTable().attributes('busy')).toBeTruthy(); + }); + + it('when there are no runners, shows an skeleton loader', () => { + createComponent({ props: { runners: [], loading: true } }, mount); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('when there are runners, shows a busy indicator skeleton loader', () => { + createComponent({ props: { loading: true } }, mount); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js new file mode 100644 index 00000000000..ca5c88f6e28 --- /dev/null +++ b/spec/frontend/runner/components/runner_manual_setup_help_spec.js @@ -0,0 +1,84 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; + +const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/'; + +describe('RunnerManualSetupHelp', () => { + let wrapper; + let originalGon; + + const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions); + const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton); + const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title'); + const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url'); + const findRegistrationToken = () => wrapper.findByTestId('registration-token'); + const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(RunnerManualSetupHelp, { + provide: { + runnerInstallHelpPage: mockRunnerInstallHelpPage, + }, + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, + stubs: { + GlSprintf, + }, + }), + ); + }; + + beforeAll(() => { + originalGon = global.gon; + global.gon = { gitlab_url: TEST_HOST }; + }); + + afterAll(() => { + global.gon = originalGon; + }); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Title contains the default runner type', () => { + expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually'); + }); + + it('Title contains the group runner type', () => { + createComponent({ props: { typeName: 'group' } }); + + expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually'); + }); + + it('Runner Install Page link', () => { + expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage); + }); + + it('Displays the coordinator URL token', () => { + expect(findCoordinatorUrl().text()).toBe(TEST_HOST); + expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST); + }); + + it('Displays the registration token', () => { + expect(findRegistrationToken().text()).toBe(mockRegistrationToken); + expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken); + }); + + it('Displays the runner instructions', () => { + expect(findRunnerInstructions().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/runner/components/runner_pagination_spec.js b/spec/frontend/runner/components/runner_pagination_spec.js new file mode 100644 index 00000000000..59feb32dd2a --- /dev/null +++ b/spec/frontend/runner/components/runner_pagination_spec.js @@ -0,0 +1,160 @@ +import { GlPagination } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; + +const mockStartCursor = 'START_CURSOR'; +const mockEndCursor = 'END_CURSOR'; + +describe('RunnerPagination', () => { + let wrapper; + + const findPagination = () => wrapper.findComponent(GlPagination); + + const createComponent = ({ page = 1, hasPreviousPage = false, hasNextPage = true } = {}) => { + wrapper = mount(RunnerPagination, { + propsData: { + value: { + page, + }, + pageInfo: { + hasPreviousPage, + hasNextPage, + startCursor: mockStartCursor, + endCursor: mockEndCursor, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When on the first page', () => { + beforeEach(() => { + createComponent({ + page: 1, + hasPreviousPage: false, + hasNextPage: true, + }); + }); + + it('Contains the current page information', () => { + expect(findPagination().props('value')).toBe(1); + expect(findPagination().props('prevPage')).toBe(null); + expect(findPagination().props('nextPage')).toBe(2); + }); + + it('Shows prev page disabled', () => { + expect(findPagination().find('[aria-disabled]').text()).toBe('Prev'); + }); + + it('Shows next page link', () => { + expect(findPagination().find('a').text()).toBe('Next'); + }); + + it('Goes to the second page', () => { + findPagination().vm.$emit('input', 2); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + after: mockEndCursor, + page: 2, + }, + ]); + }); + }); + + describe('When in between pages', () => { + beforeEach(() => { + createComponent({ + page: 2, + hasPreviousPage: true, + hasNextPage: true, + }); + }); + + it('Contains the current page information', () => { + expect(findPagination().props('value')).toBe(2); + expect(findPagination().props('prevPage')).toBe(1); + expect(findPagination().props('nextPage')).toBe(3); + }); + + it('Shows the next and previous pages', () => { + const links = findPagination().findAll('a'); + + expect(links).toHaveLength(2); + expect(links.at(0).text()).toBe('Prev'); + expect(links.at(1).text()).toBe('Next'); + }); + + it('Goes to the last page', () => { + findPagination().vm.$emit('input', 3); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + after: mockEndCursor, + page: 3, + }, + ]); + }); + + it('Goes to the first page', () => { + findPagination().vm.$emit('input', 1); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + before: mockStartCursor, + page: 1, + }, + ]); + }); + }); + + describe('When in the last page', () => { + beforeEach(() => { + createComponent({ + page: 3, + hasPreviousPage: true, + hasNextPage: false, + }); + }); + + it('Contains the current page', () => { + expect(findPagination().props('value')).toBe(3); + expect(findPagination().props('prevPage')).toBe(2); + expect(findPagination().props('nextPage')).toBe(null); + }); + + it('Shows next page link', () => { + expect(findPagination().find('a').text()).toBe('Prev'); + }); + + it('Shows next page disabled', () => { + expect(findPagination().find('[aria-disabled]').text()).toBe('Next'); + }); + }); + + describe('When only one page', () => { + beforeEach(() => { + createComponent({ + page: 1, + hasPreviousPage: false, + hasNextPage: false, + }); + }); + + it('does not display pagination', () => { + expect(wrapper.html()).toBe(''); + }); + + it('Contains the current page', () => { + expect(findPagination().props('value')).toBe(1); + }); + + it('Shows no more page buttons', () => { + expect(findPagination().props('prevPage')).toBe(null); + expect(findPagination().props('nextPage')).toBe(null); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_tags_spec.js b/spec/frontend/runner/components/runner_tags_spec.js new file mode 100644 index 00000000000..7bb3f65e4ba --- /dev/null +++ b/spec/frontend/runner/components/runner_tags_spec.js @@ -0,0 +1,64 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTags from '~/runner/components/runner_tags.vue'; + +describe('RunnerTags', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTags, { + propsData: { + tagList: ['tag1', 'tag2'], + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays tags text', () => { + expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2'); + + expect(findBadgesAt(0).text()).toBe('tag1'); + expect(findBadgesAt(1).text()).toBe('tag2'); + }); + + it('Displays tags with correct style', () => { + expect(findBadge().props('size')).toBe('md'); + expect(findBadge().props('variant')).toBe('info'); + }); + + it('Displays tags with small size', () => { + createComponent({ + props: { size: 'sm' }, + }); + + expect(findBadge().props('size')).toBe('sm'); + }); + + it('Displays tags with a variant', () => { + createComponent({ + props: { variant: 'warning' }, + }); + + expect(findBadge().props('variant')).toBe('warning'); + }); + + it('Is empty when there are no tags', () => { + createComponent({ + props: { tagList: null }, + }); + + expect(wrapper.text()).toBe(''); + expect(findBadge().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js new file mode 100644 index 00000000000..5b136a77eeb --- /dev/null +++ b/spec/frontend/runner/components/runner_type_alert_spec.js @@ -0,0 +1,61 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeAlert from '~/runner/components/runner_type_alert.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +describe('RunnerTypeAlert', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTypeAlert, { + propsData: { + type: INSTANCE_TYPE, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + type | exampleText | anchor | variant + ${INSTANCE_TYPE} | ${'Shared runners are available to every project'} | ${'#shared-runners'} | ${'success'} + ${GROUP_TYPE} | ${'Use Group runners when you want all projects in a group'} | ${'#group-runners'} | ${'success'} + ${PROJECT_TYPE} | ${'You can set up a specific runner to be used by multiple projects'} | ${'#specific-runners'} | ${'info'} + `('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => { + beforeEach(() => { + createComponent({ props: { type } }); + }); + + it('Describes runner type', () => { + expect(wrapper.text()).toMatch(exampleText); + }); + + it(`Shows a ${variant} variant`, () => { + expect(findAlert().props('variant')).toBe(variant); + }); + + it(`Links to anchor "${anchor}"`, () => { + expect(findLink().attributes('href')).toBe(`/help/ci/runners/runners_scope${anchor}`); + }); + }); + + describe('When runner type is not correct', () => { + it('Does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); + + expect(wrapper.html()).toBe(''); + }); + + it('Validation fails for an incorrect type', () => { + expect(() => { + createComponent({ props: { type: 'NOT_A_TYPE' } }); + }).toThrow(); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js index 8e52d3398bd..ab5ccf6390f 100644 --- a/spec/frontend/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/runner/components/runner_type_badge_spec.js @@ -32,8 +32,14 @@ describe('RunnerTypeBadge', () => { expect(findBadge().props('variant')).toBe(variant); }); - it('does not display a badge when type is unknown', () => { - createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + it('validation fails for an incorrect type', () => { + expect(() => { + createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + }).toThrow(); + }); + + it('does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); expect(findBadge().exists()).toBe(false); }); diff --git a/spec/frontend/runner/components/runner_type_help_spec.js b/spec/frontend/runner/components/runner_type_help_spec.js new file mode 100644 index 00000000000..f0d03282f8e --- /dev/null +++ b/spec/frontend/runner/components/runner_type_help_spec.js @@ -0,0 +1,32 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; + +describe('RunnerTypeHelp', () => { + let wrapper; + + const findBadges = () => wrapper.findAllComponents(GlBadge); + + const createComponent = () => { + wrapper = mount(RunnerTypeHelp); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays each of the runner types', () => { + expect(findBadges().at(0).text()).toBe('shared'); + expect(findBadges().at(1).text()).toBe('group'); + expect(findBadges().at(2).text()).toBe('specific'); + }); + + it('Displays runner states', () => { + expect(findBadges().at(3).text()).toBe('locked'); + expect(findBadges().at(4).text()).toBe('paused'); + }); +}); diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js new file mode 100644 index 00000000000..6333ed7118a --- /dev/null +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -0,0 +1,263 @@ +import { GlForm } from '@gitlab/ui'; +import { createLocalVue, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import RunnerUpdateForm from '~/runner/components/runner_update_form.vue'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + ACCESS_LEVEL_REF_PROTECTED, + ACCESS_LEVEL_NOT_PROTECTED, +} from '~/runner/constants'; +import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; +import { runnerData } from '../mock_data'; + +jest.mock('~/flash'); + +const mockRunner = runnerData.data.runner; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerUpdateForm', () => { + let wrapper; + let runnerUpdateHandler; + + const findForm = () => wrapper.findComponent(GlForm); + const findPausedCheckbox = () => wrapper.findByTestId('runner-field-paused'); + const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected'); + const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged'); + const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked'); + + const findIpInput = () => wrapper.findByTestId('runner-field-ip-address').find('input'); + + const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input'); + const findMaxJobTimeoutInput = () => + wrapper.findByTestId('runner-field-max-timeout').find('input'); + const findTagsInput = () => wrapper.findByTestId('runner-field-tags').find('input'); + + const findSubmit = () => wrapper.find('[type="submit"]'); + const findSubmitDisabledAttr = () => findSubmit().attributes('disabled'); + const submitForm = () => findForm().trigger('submit'); + const submitFormAndWait = () => submitForm().then(waitForPromises); + + const getFieldsModel = () => ({ + active: !findPausedCheckbox().element.checked, + accessLevel: findProtectedCheckbox().element.checked + ? ACCESS_LEVEL_REF_PROTECTED + : ACCESS_LEVEL_NOT_PROTECTED, + runUntagged: findRunUntaggedCheckbox().element.checked, + locked: findLockedCheckbox().element.checked, + ipAddress: findIpInput().element.value, + maximumTimeout: findMaxJobTimeoutInput().element.value || null, + tagList: findTagsInput().element.value.split(',').filter(Boolean), + }); + + const createComponent = ({ props } = {}) => { + wrapper = extendedWrapper( + mount(RunnerUpdateForm, { + localVue, + propsData: { + runner: mockRunner, + ...props, + }, + apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]), + }), + ); + }; + + const expectToHaveSubmittedRunnerContaining = (submittedRunner) => { + expect(runnerUpdateHandler).toHaveBeenCalledTimes(1); + expect(runnerUpdateHandler).toHaveBeenCalledWith({ + input: expect.objectContaining(submittedRunner), + }); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: expect.stringContaining('saved'), + type: FLASH_TYPES.SUCCESS, + }); + + expect(findSubmitDisabledAttr()).toBeUndefined(); + }; + + beforeEach(() => { + runnerUpdateHandler = jest.fn().mockImplementation(({ input }) => { + return Promise.resolve({ + data: { + runnerUpdate: { + runner: { + ...mockRunner, + ...input, + }, + errors: [], + }, + }, + }); + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Form has a submit button', () => { + expect(findSubmit().exists()).toBe(true); + }); + + it('Form fields match data', () => { + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + + it('Form prevent multiple submissions', async () => { + await submitForm(); + + expect(findSubmitDisabledAttr()).toBe('disabled'); + }); + + it('Updates runner with no changes', async () => { + await submitFormAndWait(); + + // Some fields are not submitted + const { ipAddress, runnerType, ...submitted } = mockRunner; + + expectToHaveSubmittedRunnerContaining(submitted); + }); + + describe('When data is being loaded', () => { + beforeEach(() => { + createComponent({ props: { runner: null } }); + }); + + it('Form cannot be submitted', () => { + expect(findSubmit().props('loading')).toBe(true); + }); + + it('Form is updated when data loads', async () => { + wrapper.setProps({ + runner: mockRunner, + }); + + await nextTick(); + + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + }); + + it.each` + runnerType | attrDisabled | outcome + ${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'} + ${GROUP_TYPE} | ${'disabled'} | ${'disabled'} + ${PROJECT_TYPE} | ${undefined} | ${'enabled'} + `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => { + const runner = { ...mockRunner, runnerType }; + createComponent({ props: { runner } }); + + expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled); + }); + + describe('On submit, runner gets updated', () => { + it.each` + test | initialValue | findCheckbox | checked | submitted + ${'pauses'} | ${{ active: true }} | ${findPausedCheckbox} | ${true} | ${{ active: false }} + ${'activates'} | ${{ active: false }} | ${findPausedCheckbox} | ${false} | ${{ active: true }} + ${'unprotects'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} | ${findProtectedCheckbox} | ${true} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} + ${'protects'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} | ${findProtectedCheckbox} | ${false} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} + ${'"runs untagged jobs"'} | ${{ runUntagged: true }} | ${findRunUntaggedCheckbox} | ${false} | ${{ runUntagged: false }} + ${'"runs tagged jobs"'} | ${{ runUntagged: false }} | ${findRunUntaggedCheckbox} | ${true} | ${{ runUntagged: true }} + ${'locks'} | ${{ runnerType: PROJECT_TYPE, locked: true }} | ${findLockedCheckbox} | ${false} | ${{ locked: false }} + ${'unlocks'} | ${{ runnerType: PROJECT_TYPE, locked: false }} | ${findLockedCheckbox} | ${true} | ${{ locked: true }} + `('Checkbox $test runner', async ({ initialValue, findCheckbox, checked, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findCheckbox().setChecked(checked); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + test | initialValue | findInput | value | submitted + ${'description'} | ${{ description: 'Desc. 1' }} | ${findDescriptionInput} | ${'Desc. 2'} | ${{ description: 'Desc. 2' }} + ${'max timeout'} | ${{ maximumTimeout: 36000 }} | ${findMaxJobTimeoutInput} | ${'40000'} | ${{ maximumTimeout: 40000 }} + ${'tags'} | ${{ tagList: ['tag1'] }} | ${findTagsInput} | ${'tag2, tag3'} | ${{ tagList: ['tag2', 'tag3'] }} + `("Field updates runner's $test", async ({ initialValue, findInput, value, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + value | submitted + ${''} | ${{ tagList: [] }} + ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} + ${'with spaces'} | ${{ tagList: ['with spaces'] }} + ${',,,,, commas'} | ${{ tagList: ['commas'] }} + ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }} + ${' trimmed , trimmed2 '} | ${{ tagList: ['trimmed', 'trimmed2'] }} + `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => { + const runner = { ...mockRunner, tagList: ['tag1'] }; + createComponent({ props: { runner } }); + + await findTagsInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + }); + + describe('On error', () => { + beforeEach(() => { + createComponent(); + }); + + it('On network error, error message is shown', async () => { + runnerUpdateHandler.mockRejectedValue(new Error('Something went wrong')); + + await submitFormAndWait(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: 'Network error: Something went wrong', + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + }); + + it('On validation error, error message is shown', async () => { + runnerUpdateHandler.mockResolvedValue({ + data: { + runnerUpdate: { + runner: mockRunner, + errors: ['A value is invalid'], + }, + }, + }); + + await submitFormAndWait(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: 'A value is invalid', + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js new file mode 100644 index 00000000000..8f551feca6e --- /dev/null +++ b/spec/frontend/runner/mock_data.js @@ -0,0 +1,6 @@ +// Fixtures generated by: spec/frontend/fixtures/runner.rb +export const runnersData = getJSONFixture('graphql/runner/get_runners.query.graphql.json'); +export const runnersDataPaginated = getJSONFixture( + 'graphql/runner/get_runners.query.graphql.paginated.json', +); +export const runnerData = getJSONFixture('graphql/runner/get_runner.query.graphql.json'); diff --git a/spec/frontend/runner/runner_detail/runner_details_app_spec.js b/spec/frontend/runner/runner_detail/runner_details_app_spec.js index c61cb647ae6..d0bd701458d 100644 --- a/spec/frontend/runner/runner_detail/runner_details_app_spec.js +++ b/spec/frontend/runner/runner_detail/runner_details_app_spec.js @@ -3,12 +3,15 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; -import { INSTANCE_TYPE } from '~/runner/constants'; import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; -const mockRunnerId = '55'; +import { runnerData } from '../mock_data'; + +const mockRunnerGraphqlId = runnerData.data.runner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -35,15 +38,7 @@ describe('RunnerDetailsApp', () => { }; beforeEach(async () => { - mockRunnerQuery = jest.fn().mockResolvedValue({ - data: { - runner: { - id: `gid://gitlab/Ci::Runner/${mockRunnerId}`, - runnerType: INSTANCE_TYPE, - __typename: 'CiRunner', - }, - }, - }); + mockRunnerQuery = jest.fn().mockResolvedValue(runnerData); }); afterEach(() => { @@ -54,13 +49,13 @@ describe('RunnerDetailsApp', () => { it('expect GraphQL ID to be requested', async () => { await createComponentWithApollo(); - expect(mockRunnerQuery).toHaveBeenCalledWith({ id: `gid://gitlab/Ci::Runner/${mockRunnerId}` }); + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); it('displays the runner id', async () => { await createComponentWithApollo(); - expect(wrapper.text()).toContain('Runner #55'); + expect(wrapper.text()).toContain(`Runner #${mockRunnerId}`); }); it('displays the runner type', async () => { 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', + }); + }); + }); +}); |