diff options
Diffstat (limited to 'spec/frontend/runner')
21 files changed, 589 insertions, 131 deletions
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index cdaec0a3a8b..2ef856c90ab 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -13,6 +13,7 @@ import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; +import { createLocalState } from '~/runner/graphql/list/local_state'; import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; @@ -30,9 +31,10 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG, - STATUS_ACTIVE, + STATUS_ONLINE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql'; @@ -40,9 +42,16 @@ import adminRunnersCountQuery from '~/runner/graphql/list/admin_runners_count.qu import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { runnersData, runnersCountData, runnersDataPaginated } from '../mock_data'; +import { + runnersData, + runnersCountData, + runnersDataPaginated, + onlineContactTimeoutSecs, + staleTimeoutSecs, +} from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockRunners = runnersData.data.runners.nodes; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -58,6 +67,8 @@ describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; let mockRunnersCountQuery; + let cacheConfig; + let localMutations; const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); @@ -69,18 +80,32 @@ describe('AdminRunnersApp', () => { const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + const createComponent = ({ + props = {}, + mountFn = shallowMountExtended, + provide, + ...options + } = {}) => { + ({ cacheConfig, localMutations } = createLocalState()); + const handlers = [ [adminRunnersQuery, mockRunnersQuery], [adminRunnersCountQuery, mockRunnersCountQuery], ]; wrapper = mountFn(AdminRunnersApp, { - apolloProvider: createMockApollo(handlers), + apolloProvider: createMockApollo(handlers, {}, cacheConfig), propsData: { registrationToken: mockRegistrationToken, ...props, }, + provide: { + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + ...provide, + }, + ...options, }); }; @@ -173,7 +198,7 @@ describe('AdminRunnersApp', () => { }); it('shows the runners list', () => { - expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); + expect(findRunnerList().props('runners')).toEqual(mockRunners); }); it('runner item links to the runner admin page', async () => { @@ -181,7 +206,7 @@ describe('AdminRunnersApp', () => { await waitForPromises(); - const { id, shortSha } = runnersData.data.runners.nodes[0]; + const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); @@ -197,7 +222,7 @@ describe('AdminRunnersApp', () => { const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell); - const runner = runnersData.data.runners.nodes[0]; + const runner = mockRunners[0]; expect(runnerActions.props()).toEqual({ runner, @@ -219,6 +244,10 @@ describe('AdminRunnersApp', () => { expect(findFilteredSearch().props('tokens')).toEqual([ expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), + expect.objectContaining({ type: PARAM_KEY_STATUS, options: expect.any(Array), }), @@ -232,12 +261,13 @@ describe('AdminRunnersApp', () => { describe('Single runner row', () => { let showToast; - const mockRunner = runnersData.data.runners.nodes[0]; - const { id: graphqlId, shortSha } = mockRunner; + const { id: graphqlId, shortSha } = mockRunners[0]; const id = getIdFromGraphQLId(graphqlId); + const COUNT_QUERIES = 7; // Smart queries that display a filtered count of runners + const FILTERED_COUNT_QUERIES = 4; // Smart queries that display a count of runners in tabs beforeEach(async () => { - mockRunnersQuery.mockClear(); + mockRunnersCountQuery.mockClear(); createComponent({ mountFn: mountExtended }); showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); @@ -252,12 +282,18 @@ describe('AdminRunnersApp', () => { expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); }); - it('When runner is deleted, data is refetched and a toast message is shown', async () => { - expect(mockRunnersQuery).toHaveBeenCalledTimes(1); + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + findRunnerActionsCell().vm.$emit('toggledPaused'); - expect(mockRunnersQuery).toHaveBeenCalledTimes(2); + expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES); + + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runner deleted'); @@ -266,7 +302,7 @@ describe('AdminRunnersApp', () => { describe('when a filter is preselected', () => { beforeEach(async () => { - setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); createComponent(); await waitForPromises(); @@ -276,7 +312,7 @@ describe('AdminRunnersApp', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, filters: [ - { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'tag', value: { data: 'tag1', operator: '=' } }, ], sort: 'CREATED_DESC', @@ -286,7 +322,7 @@ describe('AdminRunnersApp', () => { it('requests the runners with filter parameters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ - status: STATUS_ACTIVE, + status: STATUS_ONLINE, type: INSTANCE_TYPE, tagList: ['tag1'], sort: DEFAULT_SORT, @@ -299,7 +335,7 @@ describe('AdminRunnersApp', () => { beforeEach(() => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, - filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); }); @@ -307,13 +343,13 @@ describe('AdminRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/admin/runners?status[]=ACTIVE&sort=CREATED_ASC', + url: 'http://test.host/admin/runners?status[]=ONLINE&sort=CREATED_ASC', }); }); it('requests the runners with filters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ - status: STATUS_ACTIVE, + status: STATUS_ONLINE, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); @@ -325,6 +361,41 @@ describe('AdminRunnersApp', () => { expect(findRunnerList().props('loading')).toBe(true); }); + describe('when bulk delete is enabled', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { adminRunnersBulkDelete: true }, + }, + }); + }); + + it('runner list is checkable', () => { + expect(findRunnerList().props('checkable')).toBe(true); + }); + + it('responds to checked items by updating the local cache', () => { + const setRunnerCheckedMock = jest + .spyOn(localMutations, 'setRunnerChecked') + .mockImplementation(() => {}); + + const runner = mockRunners[0]; + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0); + + findRunnerList().vm.$emit('checked', { + runner, + isChecked: true, + }); + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); + expect(setRunnerCheckedMock).toHaveBeenCalledWith({ + runner, + isChecked: true, + }); + }); + }); + describe('when no runners are found', () => { beforeEach(async () => { mockRunnersQuery = jest.fn().mockResolvedValue({ diff --git a/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap new file mode 100644 index 00000000000..80a04401760 --- /dev/null +++ b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerStatusPopover renders complete text 1`] = `"Never contacted: Runner has never contacted GitLab (when you register a runner, use gitlab-runner run to bring it online) Online: Runner has contacted GitLab within the last 2 hours Offline: Runner has not contacted GitLab in more than 2 hours Stale: Runner has not contacted GitLab in more than 2 months"`; diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 0d579106860..7a949cb6505 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -92,6 +92,24 @@ describe('RunnerActionsCell', () => { expect(findDeleteBtn().props('compact')).toBe(true); }); + it('Passes runner data to delete button', () => { + createComponent({ + runner: mockRunner, + }); + + expect(findDeleteBtn().props('runner')).toEqual(mockRunner); + }); + + it('Emits toggledPaused events', () => { + createComponent(); + + expect(wrapper.emitted('toggledPaused')).toBe(undefined); + + findRunnerPauseBtn().vm.$emit('toggledPaused'); + + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); + it('Emits delete events', () => { const value = { name: 'Runner' }; @@ -104,7 +122,7 @@ describe('RunnerActionsCell', () => { expect(wrapper.emitted('deleted')).toEqual([[value]]); }); - it('Does not render the runner delete button when user cannot delete', () => { + it('Renders the runner delete disabled button when user cannot delete', () => { createComponent({ runner: { userPermissions: { @@ -114,7 +132,7 @@ describe('RunnerActionsCell', () => { }, }); - expect(findDeleteBtn().exists()).toBe(false); + expect(findDeleteBtn().props('disabled')).toBe(true); }); }); }); diff --git a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js index b6d957d27ea..b2e8c5a3ad9 100644 --- a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js @@ -5,6 +5,7 @@ import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants'; const mockId = '1'; const mockShortSha = '2P6oDVDm'; const mockDescription = 'runner-1'; +const mockIpAddress = '0.0.0.0'; describe('RunnerTypeCell', () => { let wrapper; @@ -18,6 +19,7 @@ describe('RunnerTypeCell', () => { id: `gid://gitlab/Ci::Runner/${mockId}`, shortSha: mockShortSha, description: mockDescription, + ipAddress: mockIpAddress, runnerType: INSTANCE_TYPE, ...runner, }, @@ -59,6 +61,10 @@ describe('RunnerTypeCell', () => { expect(wrapper.text()).toContain(mockDescription); }); + it('Displays the runner ip address', () => { + expect(wrapper.text()).toContain(mockIpAddress); + }); + it('Displays a custom slot', () => { const slotContent = 'My custom runner summary'; diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js index da8ef7c3af0..5cd93df9967 100644 --- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js @@ -8,6 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; +import RegistrationToken from '~/runner/components/registration/registration_token.vue'; import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; @@ -30,11 +31,11 @@ describe('RegistrationDropdown', () => { const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); + const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); + const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input'); const findTokenResetDropdownItem = () => wrapper.findComponent(RegistrationTokenResetDropdownItem); - const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); - const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { wrapper = extendedWrapper( mountFn(RegistrationDropdown, { @@ -134,9 +135,7 @@ describe('RegistrationDropdown', () => { it('Displays masked value by default', () => { createComponent({}, mount); - expect(findTokenDropdownItem().text()).toMatchInterpolatedText( - `Registration token ${maskToken}`, - ); + expect(findRegistrationTokenInput().element.value).toBe(maskToken); }); }); @@ -155,16 +154,14 @@ describe('RegistrationDropdown', () => { }); it('Updates the token when it gets reset', async () => { + const newToken = 'mock1'; createComponent({}, mount); - const newToken = 'mock1'; + expect(findRegistrationTokenInput().props('value')).not.toBe(newToken); findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); - findToggleMaskButton().vm.$emit('click', { stopPropagation: jest.fn() }); await nextTick(); - expect(findTokenDropdownItem().text()).toMatchInterpolatedText( - `Registration token ${newToken}`, - ); + expect(findRegistrationToken().props('value')).toBe(newToken); }); }); diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js index 6b9708cc525..cb42c7c8493 100644 --- a/spec/frontend/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_spec.js @@ -1,20 +1,17 @@ -import { nextTick } from 'vue'; import { GlToast } from '@gitlab/ui'; import { createLocalVue } from '@vue/test-utils'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RegistrationToken from '~/runner/components/registration/registration_token.vue'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; const mockToken = '01234567890'; const mockMasked = '***********'; describe('RegistrationToken', () => { let wrapper; - let stopPropagation; let showToast; - const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); - const findCopyButton = () => wrapper.findComponent(ModalCopyButton); + const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility); const vueWithGlToast = () => { const localVue = createLocalVue(); @@ -22,10 +19,14 @@ describe('RegistrationToken', () => { return localVue; }; - const createComponent = ({ props = {}, withGlToast = true } = {}) => { + const createComponent = ({ + props = {}, + withGlToast = true, + mountFn = shallowMountExtended, + } = {}) => { const localVue = withGlToast ? vueWithGlToast() : undefined; - wrapper = shallowMountExtended(RegistrationToken, { + wrapper = mountFn(RegistrationToken, { propsData: { value: mockToken, ...props, @@ -36,61 +37,33 @@ describe('RegistrationToken', () => { showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; }; - beforeEach(() => { - stopPropagation = jest.fn(); - - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - it('Displays masked value by default', () => { - expect(wrapper.text()).toBe(mockMasked); - }); + it('Displays value and copy button', () => { + createComponent(); - it('Displays button to reveal token', () => { - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); + expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken); + expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe( + 'Copy registration token', + ); }); - it('Can copy the original token value', () => { - expect(findCopyButton().props('text')).toBe(mockToken); + // Component integration test to ensure secure masking + it('Displays masked value by default', () => { + createComponent({ mountFn: mountExtended }); + + expect(wrapper.find('input').element.value).toBe(mockMasked); }); - describe('When the reveal icon is clicked', () => { + describe('When the copy to clipboard button is clicked', () => { beforeEach(() => { - findToggleMaskButton().vm.$emit('click', { stopPropagation }); - }); - - it('Click event is not propagated', async () => { - expect(stopPropagation).toHaveBeenCalledTimes(1); + createComponent(); }); - it('Displays the actual value', () => { - expect(wrapper.text()).toBe(mockToken); - }); - - it('Can copy the original token value', () => { - expect(findCopyButton().props('text')).toBe(mockToken); - }); - - it('Displays button to mask token', () => { - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to hide'); - }); - - it('When user clicks again, displays masked value', async () => { - findToggleMaskButton().vm.$emit('click', { stopPropagation }); - await nextTick(); - - expect(wrapper.text()).toBe(mockMasked); - expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); - }); - }); - - describe('When the copy to clipboard button is clicked', () => { it('shows a copied message', () => { - findCopyButton().vm.$emit('success'); + findInputCopyToggleVisibility().vm.$emit('copy'); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Registration token copied!'); @@ -98,7 +71,7 @@ describe('RegistrationToken', () => { it('does not fail when toast is not defined', () => { createComponent({ withGlToast: false }); - findCopyButton().vm.$emit('success'); + findInputCopyToggleVisibility().vm.$emit('copy'); // This block also tests for unhandled errors expect(showToast).toBeNull(); diff --git a/spec/frontend/runner/components/runner_assigned_item_spec.js b/spec/frontend/runner/components/runner_assigned_item_spec.js index c6156c16d4a..1ff6983fbe7 100644 --- a/spec/frontend/runner/components/runner_assigned_item_spec.js +++ b/spec/frontend/runner/components/runner_assigned_item_spec.js @@ -1,6 +1,7 @@ import { GlAvatar } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; const mockHref = '/group/project'; const mockName = 'Project'; @@ -40,7 +41,7 @@ describe('RunnerAssignedItem', () => { alt: mockName, entityName: mockName, src: mockAvatarUrl, - shape: 'rect', + shape: AVATAR_SHAPE_OPTION_RECT, size: 48, }); }); diff --git a/spec/frontend/runner/components/runner_bulk_delete_spec.js b/spec/frontend/runner/components/runner_bulk_delete_spec.js new file mode 100644 index 00000000000..f5b56396cf1 --- /dev/null +++ b/spec/frontend/runner/components/runner_bulk_delete_spec.js @@ -0,0 +1,103 @@ +import Vue from 'vue'; +import { GlSprintf } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createLocalState } from '~/runner/graphql/list/local_state'; +import waitForPromises from 'helpers/wait_for_promises'; + +Vue.use(VueApollo); + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +describe('RunnerBulkDelete', () => { + let wrapper; + let mockState; + let mockCheckedRunnerIds; + + const findClearBtn = () => wrapper.findByTestId('clear-btn'); + const findDeleteBtn = () => wrapper.findByTestId('delete-btn'); + + const createComponent = () => { + const { cacheConfig, localMutations } = mockState; + + wrapper = shallowMountExtended(RunnerBulkDelete, { + apolloProvider: createMockApollo(undefined, undefined, cacheConfig), + provide: { + localMutations, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + mockState = createLocalState(); + + jest + .spyOn(mockState.cacheConfig.typePolicies.Query.fields, 'checkedRunnerIds') + .mockImplementation(() => mockCheckedRunnerIds); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When no runners are checked', () => { + beforeEach(async () => { + mockCheckedRunnerIds = []; + + createComponent(); + + await waitForPromises(); + }); + + it('shows no contents', () => { + expect(wrapper.html()).toBe(''); + }); + }); + + describe.each` + count | ids | text + ${1} | ${['gid:Runner/1']} | ${'1 runner'} + ${2} | ${['gid:Runner/1', 'gid:Runner/2']} | ${'2 runners'} + `('When $count runner(s) are checked', ({ count, ids, text }) => { + beforeEach(() => { + mockCheckedRunnerIds = ids; + + createComponent(); + + jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {}); + }); + + it(`shows "${text}"`, () => { + expect(wrapper.text()).toContain(text); + }); + + it('clears selection', () => { + expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(0); + + findClearBtn().vm.$emit('click'); + + expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(1); + }); + + it('shows confirmation modal', () => { + expect(confirmAction).toHaveBeenCalledTimes(0); + + findDeleteBtn().vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalledTimes(1); + + const [, confirmOptions] = confirmAction.mock.calls[0]; + const { title, modalHtmlMessage, primaryBtnText } = confirmOptions; + + expect(title).toMatch(text); + expect(primaryBtnText).toMatch(text); + expect(modalHtmlMessage).toMatch(`<strong>${count}</strong>`); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js index 81c870f23cf..3eb257607b4 100644 --- a/spec/frontend/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/runner/components/runner_delete_button_spec.js @@ -9,7 +9,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; -import { I18N_DELETE_RUNNER } from '~/runner/constants'; +import { + I18N_DELETE_RUNNER, + I18N_DELETE_DISABLED_MANY_PROJECTS, + I18N_DELETE_DISABLED_UNKNOWN_REASON, +} from '~/runner/constants'; import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; @@ -25,26 +29,32 @@ jest.mock('~/runner/sentry_utils'); describe('RunnerDeleteButton', () => { let wrapper; + let apolloProvider; + let apolloCache; let runnerDeleteHandler; - const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; - const getModal = () => getBinding(wrapper.element, 'gl-modal').value; const findBtn = () => wrapper.findComponent(GlButton); const findModal = () => wrapper.findComponent(RunnerDeleteModal); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + const getModal = () => getBinding(findBtn().element, 'gl-modal').value; + const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { const { runner, ...propsData } = props; wrapper = mountFn(RunnerDeleteButton, { propsData: { runner: { + // We need typename so that cache.identify works + // eslint-disable-next-line no-underscore-dangle + __typename: mockRunner.__typename, id: mockRunner.id, shortSha: mockRunner.shortSha, ...runner, }, ...propsData, }, - apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]), + apolloProvider, directives: { GlTooltip: createMockDirective(), GlModal: createMockDirective(), @@ -67,6 +77,11 @@ describe('RunnerDeleteButton', () => { }, }); }); + apolloProvider = createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]); + apolloCache = apolloProvider.defaultClient.cache; + + jest.spyOn(apolloCache, 'evict'); + jest.spyOn(apolloCache, 'gc'); createComponent(); }); @@ -88,6 +103,10 @@ describe('RunnerDeleteButton', () => { expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`); }); + it('Does not have tabindex when button is enabled', () => { + expect(wrapper.attributes('tabindex')).toBeUndefined(); + }); + it('Displays a modal when clicked', () => { const modalId = `delete-runner-modal-${mockRunnerId}`; @@ -140,6 +159,13 @@ describe('RunnerDeleteButton', () => { expect(deleted[0][0].message).toMatch(`#${mockRunnerId}`); expect(deleted[0][0].message).toMatch(`${mockRunner.shortSha}`); }); + + it('evicts runner from apollo cache', () => { + expect(apolloCache.evict).toHaveBeenCalledWith({ + id: apolloCache.identify(mockRunner), + }); + expect(apolloCache.gc).toHaveBeenCalled(); + }); }); describe('When update fails', () => { @@ -190,6 +216,11 @@ describe('RunnerDeleteButton', () => { it('error is shown to the user', () => { expect(createAlert).toHaveBeenCalledTimes(1); }); + + it('does not evict runner from apollo cache', () => { + expect(apolloCache.evict).not.toHaveBeenCalled(); + expect(apolloCache.gc).not.toHaveBeenCalled(); + }); }); }); @@ -230,4 +261,29 @@ describe('RunnerDeleteButton', () => { }); }); }); + + describe.each` + reason | runner | tooltip + ${'runner belongs to more than 1 project'} | ${{ projectCount: 2 }} | ${I18N_DELETE_DISABLED_MANY_PROJECTS} + ${'unknown reason'} | ${{}} | ${I18N_DELETE_DISABLED_UNKNOWN_REASON} + `('When button is disabled because $reason', ({ runner, tooltip }) => { + beforeEach(() => { + createComponent({ + props: { + disabled: true, + runner, + }, + }); + }); + + it('Displays a disabled delete button', () => { + expect(findBtn().props('disabled')).toBe(true); + }); + + it(`Tooltip "${tooltip}" is shown`, () => { + // tabindex is required for a11y + expect(wrapper.attributes('tabindex')).toBe('0'); + expect(getTooltip()).toBe(tooltip); + }); + }); }); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index fda96e5918e..b1b436e5443 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -4,7 +4,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; -import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ACTIVE, INSTANCE_TYPE } from '~/runner/constants'; +import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, INSTANCE_TYPE } from '~/runner/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -18,7 +18,7 @@ describe('RunnerList', () => { const mockDefaultSort = 'CREATED_DESC'; const mockOtherSort = 'CONTACTED_DESC'; const mockFilters = [ - { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, ]; @@ -113,7 +113,7 @@ describe('RunnerList', () => { }); it('filter values are shown', () => { - expect(findGlFilteredSearch().props('value')).toEqual(mockFilters); + expect(findGlFilteredSearch().props('value')).toMatchObject(mockFilters); }); it('sort option is selected', () => { diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js index 9abb2861005..9e40e911448 100644 --- a/spec/frontend/runner/components/runner_jobs_spec.js +++ b/spec/frontend/runner/components/runner_jobs_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index a0f42738d2c..872394430ae 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -6,7 +6,8 @@ import { } 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'; +import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; +import { runnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; const mockRunners = runnersData.data.runners.nodes; const mockActiveRunnersCount = mockRunners.length; @@ -28,26 +29,38 @@ describe('RunnerList', () => { activeRunnersCount: mockActiveRunnersCount, ...props, }, + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + }, ...options, }); }; - beforeEach(() => { - createComponent({}, mountExtended); - }); - afterEach(() => { wrapper.destroy(); }); it('Displays headers', () => { + createComponent( + { + stubs: { + RunnerStatusPopover: { + template: '<div/>', + }, + }, + }, + mountExtended, + ); + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + expect(findHeaders().at(0).findComponent(RunnerStatusPopover).exists()).toBe(true); + expect(headerLabels).toEqual([ 'Status', 'Runner', 'Version', - 'IP', 'Jobs', 'Tags', 'Last contact', @@ -56,19 +69,23 @@ describe('RunnerList', () => { }); it('Sets runner id as a row key', () => { - createComponent({}); + createComponent(); expect(findTable().attributes('primary-key')).toBe('id'); }); it('Displays a list of runners', () => { + createComponent({}, mountExtended); + expect(findRows()).toHaveLength(4); expect(findSkeletonLoader().exists()).toBe(false); }); it('Displays details of a runner', () => { - const { id, description, version, ipAddress, shortSha } = mockRunners[0]; + const { id, description, version, shortSha } = mockRunners[0]; + + createComponent({}, mountExtended); // Badges expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText( @@ -83,7 +100,6 @@ describe('RunnerList', () => { // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); - expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0'); expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); @@ -92,6 +108,35 @@ describe('RunnerList', () => { expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true); }); + describe('When the list is checkable', () => { + beforeEach(() => { + createComponent( + { + props: { + checkable: true, + }, + }, + mountExtended, + ); + }); + + it('Displays a checkbox field', () => { + expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true); + }); + + it('Emits a checked event', () => { + const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); + + checkbox.setChecked(); + + expect(wrapper.emitted('checked')).toHaveLength(1); + expect(wrapper.emitted('checked')[0][0]).toEqual({ + isChecked: true, + runner: mockRunners[0], + }); + }); + }); + describe('Scoped cell slots', () => { it('Render #runner-name slot in "summary" cell', () => { createComponent( @@ -156,6 +201,8 @@ describe('RunnerList', () => { const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); + createComponent({}, mountExtended); + expect(findCell({ fieldKey: 'summary' }).text()).toContain(`#${numericId} (${shortSha})`); }); diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js index 3d9df03977e..9ebb30b6ed7 100644 --- a/spec/frontend/runner/components/runner_pause_button_spec.js +++ b/spec/frontend/runner/components/runner_pause_button_spec.js @@ -146,6 +146,10 @@ describe('RunnerPauseButton', () => { it('The button does not have a loading state', () => { expect(findBtn().props('loading')).toBe(false); }); + + it('The button emits toggledPaused', () => { + expect(wrapper.emitted('toggledPaused')).toHaveLength(1); + }); }); describe('When update fails', () => { diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js index 96de8d11bca..62ebc6539e2 100644 --- a/spec/frontend/runner/components/runner_projects_spec.js +++ b/spec/frontend/runner/components/runner_projects_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; diff --git a/spec/frontend/runner/components/runner_status_badge_spec.js b/spec/frontend/runner/components/runner_status_badge_spec.js index c470c6bb989..bb833bd7d5a 100644 --- a/spec/frontend/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/runner/components/runner_status_badge_spec.js @@ -7,6 +7,8 @@ import { STATUS_OFFLINE, STATUS_STALE, STATUS_NEVER_CONTACTED, + I18N_NEVER_CONTACTED_TOOLTIP, + I18N_STALE_NEVER_CONTACTED_TOOLTIP, } from '~/runner/constants'; describe('RunnerTypeBadge', () => { @@ -59,7 +61,7 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('never contacted'); expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toMatch('This runner has never contacted'); + expect(getTooltip().value).toBe(I18N_NEVER_CONTACTED_TOOLTIP); }); it('renders offline state', () => { @@ -72,9 +74,7 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('offline'); expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toBe( - 'No recent contact from this runner; last contact was 1 day ago', - ); + expect(getTooltip().value).toBe('Runner is offline; last contact was 1 day ago'); }); it('renders stale state', () => { @@ -87,7 +87,20 @@ describe('RunnerTypeBadge', () => { expect(wrapper.text()).toBe('stale'); expect(findBadge().props('variant')).toBe('warning'); - expect(getTooltip().value).toBe('No contact from this runner in over 3 months'); + expect(getTooltip().value).toBe('Runner is stale; last contact was 1 year ago'); + }); + + it('renders stale state with no contact time', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_STALE, + }, + }); + + expect(wrapper.text()).toBe('stale'); + expect(findBadge().props('variant')).toBe('warning'); + expect(getTooltip().value).toBe(I18N_STALE_NEVER_CONTACTED_TOOLTIP); }); describe('does not fail when data is missing', () => { @@ -100,7 +113,7 @@ describe('RunnerTypeBadge', () => { }); expect(wrapper.text()).toBe('online'); - expect(getTooltip().value).toBe('Runner is online; last contact was n/a'); + expect(getTooltip().value).toBe('Runner is online; last contact was never'); }); it('status is missing', () => { diff --git a/spec/frontend/runner/components/runner_status_popover_spec.js b/spec/frontend/runner/components/runner_status_popover_spec.js new file mode 100644 index 00000000000..789283d1245 --- /dev/null +++ b/spec/frontend/runner/components/runner_status_popover_spec.js @@ -0,0 +1,36 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; + +describe('RunnerStatusPopover', () => { + let wrapper; + + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMountExtended(RunnerStatusPopover, { + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + ...provide, + }, + stubs: { + GlSprintf, + }, + }); + }; + + const findHelpPopover = () => wrapper.findComponent(HelpPopover); + + it('renders popoover', () => { + createComponent(); + + expect(findHelpPopover().exists()).toBe(true); + }); + + it('renders complete text', () => { + createComponent(); + + expect(findHelpPopover().text()).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js new file mode 100644 index 00000000000..5c4302e4aa2 --- /dev/null +++ b/spec/frontend/runner/graphql/local_state_spec.js @@ -0,0 +1,72 @@ +import createApolloClient from '~/lib/graphql'; +import { createLocalState } from '~/runner/graphql/list/local_state'; +import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql'; + +describe('~/runner/graphql/list/local_state', () => { + let localState; + let apolloClient; + + const createSubject = () => { + if (apolloClient) { + throw new Error('test subject already exists!'); + } + + localState = createLocalState(); + + const { cacheConfig, typeDefs } = localState; + + apolloClient = createApolloClient({}, { cacheConfig, typeDefs }); + }; + + const queryCheckedRunnerIds = () => { + const { checkedRunnerIds } = apolloClient.readQuery({ + query: getCheckedRunnerIdsQuery, + }); + return checkedRunnerIds; + }; + + beforeEach(() => { + createSubject(); + }); + + afterEach(() => { + localState = null; + apolloClient = null; + }); + + describe('default', () => { + it('has empty checked list', () => { + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); + + describe.each` + inputs | expected + ${[['a', true], ['b', true], ['b', true]]} | ${['a', 'b']} + ${[['a', true], ['b', true], ['a', false]]} | ${['b']} + ${[['c', true], ['b', true], ['a', true], ['d', false]]} | ${['c', 'b', 'a']} + `('setRunnerChecked', ({ inputs, expected }) => { + beforeEach(() => { + inputs.forEach(([id, isChecked]) => { + localState.localMutations.setRunnerChecked({ runner: { id }, isChecked }); + }); + }); + it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => { + expect(queryCheckedRunnerIds()).toEqual(expected); + }); + }); + + describe('clearChecked', () => { + it('clears all checked items', () => { + ['a', 'b', 'c'].forEach((id) => { + localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true }); + }); + + expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']); + + localState.localMutations.clearChecked(); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 70e303e8626..02348bf737a 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -28,8 +28,9 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + PARAM_KEY_PAUSED, PARAM_KEY_STATUS, - STATUS_ACTIVE, + STATUS_ONLINE, RUNNER_PAGE_SIZE, I18N_EDIT, } from '~/runner/constants'; @@ -38,7 +39,13 @@ import getGroupRunnersCountQuery from '~/runner/graphql/list/group_runners_count import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue'; import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData } from '../mock_data'; +import { + groupRunnersData, + groupRunnersDataPaginated, + groupRunnersCountData, + onlineContactTimeoutSecs, + staleTimeoutSecs, +} from '../mock_data'; Vue.use(VueApollo); Vue.use(GlToast); @@ -90,6 +97,10 @@ describe('GroupRunnersApp', () => { groupRunnersLimitedCount: mockGroupRunnersLimitedCount, ...props, }, + provide: { + onlineContactTimeoutSecs, + staleTimeoutSecs, + }, }); }; @@ -178,13 +189,16 @@ describe('GroupRunnersApp', () => { const tokens = findFilteredSearch().props('tokens'); - expect(tokens).toHaveLength(1); - expect(tokens[0]).toEqual( + expect(tokens).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_PAUSED, + options: expect.any(Array), + }), expect.objectContaining({ type: PARAM_KEY_STATUS, options: expect.any(Array), }), - ); + ]); }); describe('Single runner row', () => { @@ -193,9 +207,11 @@ describe('GroupRunnersApp', () => { const { webUrl, editUrl, node } = mockGroupRunnersEdges[0]; const { id: graphqlId, shortSha } = node; const id = getIdFromGraphQLId(graphqlId); + const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners + const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs beforeEach(async () => { - mockGroupRunnersQuery.mockClear(); + mockGroupRunnersCountQuery.mockClear(); createComponent({ mountFn: mountExtended }); showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); @@ -219,12 +235,20 @@ describe('GroupRunnersApp', () => { }); }); - it('When runner is deleted, data is refetched and a toast is shown', async () => { - expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(1); + it('When runner is paused or unpaused, some data is refetched', async () => { + expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); + findRunnerActionsCell().vm.$emit('toggledPaused'); + + expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes( + COUNT_QUERIES + FILTERED_COUNT_QUERIES, + ); - expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(2); + expect(showToast).toHaveBeenCalledTimes(0); + }); + + it('When runner is deleted, data is refetched and a toast message is shown', async () => { + findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runner deleted'); @@ -233,7 +257,7 @@ describe('GroupRunnersApp', () => { describe('when a filter is preselected', () => { beforeEach(async () => { - setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); + setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`); createComponent(); await waitForPromises(); @@ -242,7 +266,7 @@ describe('GroupRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, - filters: [{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], sort: 'CREATED_DESC', pagination: { page: 1 }, }); @@ -251,7 +275,7 @@ describe('GroupRunnersApp', () => { it('requests the runners with filter parameters', () => { expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, - status: STATUS_ACTIVE, + status: STATUS_ONLINE, type: INSTANCE_TYPE, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, @@ -263,7 +287,7 @@ describe('GroupRunnersApp', () => { beforeEach(async () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, - filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -273,14 +297,14 @@ describe('GroupRunnersApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC', + url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&sort=CREATED_ASC', }); }); it('requests the runners with filters', () => { expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, - status: STATUS_ACTIVE, + status: STATUS_ONLINE, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 49c25039719..fbe8926124c 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -14,6 +14,10 @@ import runnerWithGroupData from 'test_fixtures/graphql/runner/details/runner.que import runnerProjectsData from 'test_fixtures/graphql/runner/details/runner_projects.query.graphql.json'; import runnerJobsData from 'test_fixtures/graphql/runner/details/runner_jobs.query.graphql.json'; +// Other mock data +export const onlineContactTimeoutSecs = 2 * 60 * 60; +export const staleTimeoutSecs = 5259492; // Ruby's `2.months` + export { runnersData, runnersCountData, diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index aff1ec882bb..7834e76fe48 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -181,6 +181,28 @@ describe('search_params.js', () => { first: RUNNER_PAGE_SIZE, }, }, + { + name: 'paused runners', + urlQuery: '?paused[]=true', + search: { + runnerType: null, + filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'active runners', + urlQuery: '?paused[]=false', + search: { + runnerType: null, + filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, ]; describe('searchValidator', () => { @@ -197,14 +219,18 @@ describe('search_params.js', () => { expect(updateOutdatedUrl('http://test.host/?a=b')).toBe(null); }); - it('returns updated url for updating NOT_CONNECTED to NEVER_CONTACTED', () => { - expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED')).toBe( - 'http://test.host/admin/runners?status[]=NEVER_CONTACTED', - ); + it.each` + query | updatedQuery + ${'status[]=NOT_CONNECTED'} | ${'status[]=NEVER_CONTACTED'} + ${'status[]=NOT_CONNECTED&a=b'} | ${'status[]=NEVER_CONTACTED&a=b'} + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'} + ${'status[]=ACTIVE'} | ${'paused[]=false'} + ${'status[]=PAUSED'} | ${'paused[]=true'} + `('updates "$query" to "$updatedQuery"', ({ query, updatedQuery }) => { + const mockUrl = 'http://test.host/admin/runners?'; - expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED&a=b')).toBe( - 'http://test.host/admin/runners?status[]=NEVER_CONTACTED&a=b', - ); + expect(updateOutdatedUrl(`${mockUrl}${query}`)).toBe(`${mockUrl}${updatedQuery}`); }); }); diff --git a/spec/frontend/runner/utils_spec.js b/spec/frontend/runner/utils_spec.js index 3fa9784ecdf..1db9815dfd8 100644 --- a/spec/frontend/runner/utils_spec.js +++ b/spec/frontend/runner/utils_spec.js @@ -44,6 +44,10 @@ describe('~/runner/utils', () => { thClass: expect.arrayContaining(mockClasses), }); }); + + it('a field with custom options', () => { + expect(tableField({ foo: 'bar' })).toMatchObject({ foo: 'bar' }); + }); }); describe('getPaginationVariables', () => { |