diff options
Diffstat (limited to 'spec/frontend/runner')
21 files changed, 632 insertions, 219 deletions
diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js index 7ab4aeee9bc..64f66d8f3ba 100644 --- a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -104,6 +104,10 @@ describe('AdminRunnerShowApp', () => { Platform darwin Configuration Runs untagged jobs Maximum job timeout None + Token expiry + Runner authentication token expiration + Runner authentication tokens will expire based on a set interval. + They will automatically rotate once expired. Learn more Never expires Tags None`.replace(/\s+/g, ' '); expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); 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 55a298e1695..7afde3bdc96 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -20,8 +20,6 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerStackedLayoutBanner from '~/runner/components/runner_stacked_layout_banner.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; -import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; -import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; @@ -45,6 +43,7 @@ import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, + DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql'; @@ -83,8 +82,6 @@ const COUNT_QUERIES = 7; // 4 tabs + 3 status queries describe('AdminRunnersApp', () => { let wrapper; - let cacheConfig; - let localMutations; let showToast; const findRunnerStackedLayoutBanner = () => wrapper.findComponent(RunnerStackedLayoutBanner); @@ -92,8 +89,6 @@ describe('AdminRunnersApp', () => { const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); - const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); - const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -106,7 +101,7 @@ describe('AdminRunnersApp', () => { provide, ...options } = {}) => { - ({ cacheConfig, localMutations } = createLocalState()); + const { cacheConfig, localMutations } = createLocalState(); const handlers = [ [allRunnersQuery, mockRunnersHandler], @@ -195,7 +190,7 @@ describe('AdminRunnersApp', () => { const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); - const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); expect(runnerLink.text()).toBe(`#${numericId} (${shortSha})`); expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`); @@ -204,7 +199,9 @@ describe('AdminRunnersApp', () => { it('renders runner actions for each runner', async () => { await createComponent({ mountFn: mountExtended }); - const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell); + const runnerActions = wrapper + .find('tr [data-testid="td-actions"]') + .findComponent(RunnerActionsCell); const runner = mockRunners[0]; expect(runnerActions.props()).toEqual({ @@ -219,6 +216,7 @@ describe('AdminRunnersApp', () => { expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: undefined, type: undefined, + membership: DEFAULT_MEMBERSHIP, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, }); @@ -255,7 +253,7 @@ describe('AdminRunnersApp', () => { }); it('Links to the runner page', async () => { - const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); expect(runnerLink.text()).toBe(`#${id} (${shortSha})`); expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); @@ -288,6 +286,7 @@ describe('AdminRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: PARAM_KEY_PAUSED, value: { data: 'true', operator: '=' } }, @@ -301,6 +300,7 @@ describe('AdminRunnersApp', () => { expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: STATUS_ONLINE, type: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, paused: true, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, @@ -310,6 +310,7 @@ describe('AdminRunnersApp', () => { it('fetches count results for requested status', () => { expect(mockRunnersCountHandler).toHaveBeenCalledWith({ type: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, status: STATUS_ONLINE, paused: true, }); @@ -322,6 +323,7 @@ describe('AdminRunnersApp', () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -339,6 +341,7 @@ describe('AdminRunnersApp', () => { it('requests the runners with filters', () => { expect(mockRunnersHandler).toHaveBeenLastCalledWith({ status: STATUS_ONLINE, + membership: DEFAULT_MEMBERSHIP, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); @@ -347,6 +350,7 @@ describe('AdminRunnersApp', () => { it('fetches count results for requested status', () => { expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE, + membership: DEFAULT_MEMBERSHIP, }); }); }); @@ -357,65 +361,26 @@ describe('AdminRunnersApp', () => { expect(findRunnerPagination().attributes('disabled')).toBe('true'); }); - describe('when bulk delete is enabled', () => { + describe('Bulk delete', () => { describe('Before runners are deleted', () => { beforeEach(async () => { - await createComponent({ - mountFn: mountExtended, - provide: { - glFeatures: { adminRunnersBulkDelete: true }, - }, - }); - }); - - it('runner bulk delete is available', () => { - expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners); - }); - - it('runner bulk delete checkbox is available', () => { - expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners); + await createComponent({ mountFn: mountExtended }); }); 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 runners are deleted', () => { beforeEach(async () => { - await createComponent({ - mountFn: mountExtended, - provide: { - glFeatures: { adminRunnersBulkDelete: true }, - }, - }); + await createComponent({ mountFn: mountExtended }); }); it('count data is refetched', async () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); - findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' }); + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); }); @@ -423,7 +388,7 @@ describe('AdminRunnersApp', () => { it('toast is shown', async () => { expect(showToast).toHaveBeenCalledTimes(0); - findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' }); + findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledWith('Runners deleted'); @@ -457,6 +422,7 @@ describe('AdminRunnersApp', () => { beforeEach(async () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -504,6 +470,7 @@ describe('AdminRunnersApp', () => { await findRunnerPaginationNext().trigger('click'); expect(mockRunnersHandler).toHaveBeenLastCalledWith({ + membership: DEFAULT_MEMBERSHIP, sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, after: pageInfo.endCursor, diff --git a/spec/frontend/runner/components/cells/link_cell_spec.js b/spec/frontend/runner/components/cells/link_cell_spec.js index a59a0eaa5d8..46ab1adb6b6 100644 --- a/spec/frontend/runner/components/cells/link_cell_spec.js +++ b/spec/frontend/runner/components/cells/link_cell_spec.js @@ -5,7 +5,7 @@ import LinkCell from '~/runner/components/cells/link_cell.vue'; describe('LinkCell', () => { let wrapper; - const findGlLink = () => wrapper.find(GlLink); + const findGlLink = () => wrapper.findComponent(GlLink); const findSpan = () => wrapper.find('span'); const createComponent = ({ props = {}, ...options } = {}) => { 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 ffd6f126627..58974d4f85f 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -122,7 +122,7 @@ describe('RunnerActionsCell', () => { expect(wrapper.emitted('deleted')).toEqual([[value]]); }); - it('Renders the runner delete disabled button when user cannot delete', () => { + it('Does not render the runner delete button when user cannot delete', () => { createComponent({ runner: { userPermissions: { @@ -132,7 +132,7 @@ describe('RunnerActionsCell', () => { }, }); - expect(findDeleteBtn().props('disabled')).toBe(true); + expect(findDeleteBtn().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/runner/components/cells/runner_owner_cell_spec.js b/spec/frontend/runner/components/cells/runner_owner_cell_spec.js new file mode 100644 index 00000000000..e9965d8855d --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_owner_cell_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import RunnerOwnerCell from '~/runner/components/cells/runner_owner_cell.vue'; + +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +describe('RunnerOwnerCell', () => { + let wrapper; + + const findLink = () => wrapper.findComponent(GlLink); + const getLinkTooltip = () => getBinding(findLink().element, 'gl-tooltip').value; + + const createComponent = ({ runner } = {}) => { + wrapper = shallowMount(RunnerOwnerCell, { + directives: { + GlTooltip: createMockDirective(), + }, + propsData: { + runner, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When its an instance runner', () => { + beforeEach(() => { + createComponent({ + runner: { + runnerType: INSTANCE_TYPE, + }, + }); + }); + + it('shows an administrator label', () => { + expect(findLink().exists()).toBe(false); + expect(wrapper.text()).toBe(s__('Runners|Administrator')); + }); + }); + + describe('When its a group runner', () => { + const mockName = 'Group 2'; + const mockFullName = 'Group 1 / Group 2'; + const mockWebUrl = '/group-1/group-2'; + + beforeEach(() => { + createComponent({ + runner: { + runnerType: GROUP_TYPE, + groups: { + nodes: [ + { + name: mockName, + fullName: mockFullName, + webUrl: mockWebUrl, + }, + ], + }, + }, + }); + }); + + it('Displays a group link', () => { + expect(findLink().attributes('href')).toBe(mockWebUrl); + expect(wrapper.text()).toBe(mockName); + expect(getLinkTooltip()).toBe(mockFullName); + }); + }); + + describe('When its a project runner', () => { + const mockName = 'Project 1'; + const mockNameWithNamespace = 'Group 1 / Project 1'; + const mockWebUrl = '/group-1/project-1'; + + beforeEach(() => { + createComponent({ + runner: { + runnerType: PROJECT_TYPE, + ownerProject: { + name: mockName, + nameWithNamespace: mockNameWithNamespace, + webUrl: mockWebUrl, + }, + }, + }); + }); + + it('Displays a project link', () => { + expect(findLink().attributes('href')).toBe(mockWebUrl); + expect(wrapper.text()).toBe(mockName); + expect(getLinkTooltip()).toBe(mockNameWithNamespace); + }); + }); + + describe('When its an empty runner', () => { + beforeEach(() => { + createComponent({ + runner: {}, + }); + }); + + it('shows no label', () => { + expect(wrapper.text()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js index 21ec9f61f37..e7cadefc140 100644 --- a/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js @@ -85,7 +85,7 @@ describe('RunnerTypeCell', () => { contactedAt: '2022-01-02', }); - expect(findRunnerSummaryField('clock').find(TimeAgo).props('time')).toBe('2022-01-02'); + expect(findRunnerSummaryField('clock').findComponent(TimeAgo).props('time')).toBe('2022-01-02'); }); it('Displays empty last contact', () => { @@ -93,7 +93,7 @@ describe('RunnerTypeCell', () => { contactedAt: null, }); - expect(findRunnerSummaryField('clock').find(TimeAgo).exists()).toBe(false); + expect(findRunnerSummaryField('clock').findComponent(TimeAgo).exists()).toBe(false); expect(findRunnerSummaryField('clock').text()).toContain(__('Never')); }); @@ -134,7 +134,7 @@ describe('RunnerTypeCell', () => { }); it('Displays created at', () => { - expect(findRunnerSummaryField('calendar').find(TimeAgo).props('time')).toBe( + expect(findRunnerSummaryField('calendar').findComponent(TimeAgo).props('time')).toBe( mockRunner.createdAt, ); }); diff --git a/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js b/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js index 0ac89e82314..424a4e61ccd 100644 --- a/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js +++ b/spec/frontend/runner/components/runner_bulk_delete_checkbox_spec.js @@ -5,11 +5,21 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createLocalState } from '~/runner/graphql/list/local_state'; -import { allRunnersData } from '../mock_data'; Vue.use(VueApollo); -jest.mock('~/flash'); +const makeRunner = (id, deleteRunner = true) => ({ + id, + userPermissions: { deleteRunner }, +}); + +// Multi-select checkbox possible states: +const stateToAttrs = { + unchecked: { disabled: undefined, checked: undefined, indeterminate: undefined }, + checked: { disabled: undefined, checked: 'true', indeterminate: undefined }, + indeterminate: { disabled: undefined, checked: undefined, indeterminate: 'true' }, + disabled: { disabled: 'true', checked: undefined, indeterminate: undefined }, +}; describe('RunnerBulkDeleteCheckbox', () => { let wrapper; @@ -18,12 +28,14 @@ describe('RunnerBulkDeleteCheckbox', () => { const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); - const mockRunners = allRunnersData.data.runners.nodes; - const mockIds = allRunnersData.data.runners.nodes.map(({ id }) => id); - const mockId = mockIds[0]; - const mockIdAnotherPage = 'RUNNER_IN_ANOTHER_PAGE_ID'; + const expectCheckboxToBe = (state) => { + const expected = stateToAttrs[state]; + expect(findCheckbox().attributes('disabled')).toBe(expected.disabled); + expect(findCheckbox().attributes('checked')).toBe(expected.checked); + expect(findCheckbox().attributes('indeterminate')).toBe(expected.indeterminate); + }; - const createComponent = ({ props = {} } = {}) => { + const createComponent = ({ runners = [] } = {}) => { const { cacheConfig, localMutations } = mockState; const apolloProvider = createMockApollo(undefined, undefined, cacheConfig); @@ -33,8 +45,7 @@ describe('RunnerBulkDeleteCheckbox', () => { localMutations, }, propsData: { - runners: mockRunners, - ...props, + runners, }, }); }; @@ -49,31 +60,61 @@ describe('RunnerBulkDeleteCheckbox', () => { jest.spyOn(mockState.localMutations, 'setRunnersChecked'); }); - describe.each` - case | is | checkedRunnerIds | disabled | checked | indeterminate - ${'no runners'} | ${'unchecked'} | ${[]} | ${undefined} | ${undefined} | ${undefined} - ${'no runners in this page'} | ${'unchecked'} | ${[mockIdAnotherPage]} | ${undefined} | ${undefined} | ${undefined} - ${'all runners'} | ${'checked'} | ${mockIds} | ${undefined} | ${'true'} | ${undefined} - ${'some runners'} | ${'indeterminate'} | ${[mockId]} | ${undefined} | ${undefined} | ${'true'} - ${'all plus other runners'} | ${'checked'} | ${[...mockIds, mockIdAnotherPage]} | ${undefined} | ${'true'} | ${undefined} - `('When $case are checked', ({ is, checkedRunnerIds, disabled, checked, indeterminate }) => { - beforeEach(async () => { + describe('when all runners can be deleted', () => { + const mockIds = ['1', '2', '3']; + const mockIdAnotherPage = '4'; + const mockRunners = mockIds.map((id) => makeRunner(id)); + + it.each` + case | checkedRunnerIds | state + ${'no runners'} | ${[]} | ${'unchecked'} + ${'no runners in this page'} | ${[mockIdAnotherPage]} | ${'unchecked'} + ${'all runners'} | ${mockIds} | ${'checked'} + ${'some runners'} | ${[mockIds[0]]} | ${'indeterminate'} + ${'all plus other runners'} | ${[...mockIds, mockIdAnotherPage]} | ${'checked'} + `('if $case are checked, checkbox is $state', ({ checkedRunnerIds, state }) => { mockCheckedRunnerIds = checkedRunnerIds; - createComponent(); + createComponent({ runners: mockRunners }); + expectCheckboxToBe(state); }); + }); + + describe('when some runners cannot be deleted', () => { + it('all allowed runners are selected, checkbox is checked', () => { + mockCheckedRunnerIds = ['a', 'b', 'c']; + createComponent({ + runners: [makeRunner('a'), makeRunner('b'), makeRunner('c', false)], + }); - it(`is ${is}`, () => { - expect(findCheckbox().attributes('disabled')).toBe(disabled); - expect(findCheckbox().attributes('checked')).toBe(checked); - expect(findCheckbox().attributes('indeterminate')).toBe(indeterminate); + expectCheckboxToBe('checked'); + }); + + it('some allowed runners are selected, checkbox is indeterminate', () => { + mockCheckedRunnerIds = ['a', 'b']; + createComponent({ + runners: [makeRunner('a'), makeRunner('b'), makeRunner('c')], + }); + + expectCheckboxToBe('indeterminate'); + }); + + it('no allowed runners are selected, checkbox is disabled', () => { + mockCheckedRunnerIds = ['a', 'b']; + createComponent({ + runners: [makeRunner('a', false), makeRunner('b', false)], + }); + + expectCheckboxToBe('disabled'); }); }); describe('When user selects', () => { + const mockRunners = [makeRunner('1'), makeRunner('2')]; + beforeEach(() => { - mockCheckedRunnerIds = mockIds; - createComponent(); + mockCheckedRunnerIds = ['1', '2']; + createComponent({ runners: mockRunners }); }); it.each([[true], [false]])('sets checked to %s', (checked) => { @@ -89,13 +130,11 @@ describe('RunnerBulkDeleteCheckbox', () => { describe('When runners are loading', () => { beforeEach(() => { - createComponent({ props: { runners: [] } }); + createComponent(); }); - it(`is disabled`, () => { - expect(findCheckbox().attributes('disabled')).toBe('true'); - expect(findCheckbox().attributes('checked')).toBe(undefined); - expect(findCheckbox().attributes('indeterminate')).toBe(undefined); + it('is disabled', () => { + expectCheckboxToBe('disabled'); }); }); }); diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js index 52fe803c536..c8fb7a69379 100644 --- a/spec/frontend/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/runner/components/runner_delete_button_spec.js @@ -9,11 +9,7 @@ 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, - I18N_DELETE_DISABLED_MANY_PROJECTS, - I18N_DELETE_DISABLED_UNKNOWN_REASON, -} from '~/runner/constants'; +import { I18N_DELETE_RUNNER } from '~/runner/constants'; import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; @@ -267,29 +263,4 @@ 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_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js index f2281223a25..e6cc936e260 100644 --- a/spec/frontend/runner/components/runner_details_spec.js +++ b/spec/frontend/runner/components/runner_details_spec.js @@ -25,12 +25,7 @@ describe('RunnerDetails', () => { const findDetailGroups = () => wrapper.findComponent(RunnerGroups); - const createComponent = ({ - props = {}, - stubs, - mountFn = shallowMountExtended, - enforceRunnerTokenExpiresAt = false, - } = {}) => { + const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RunnerDetails, { propsData: { ...props, @@ -39,9 +34,6 @@ describe('RunnerDetails', () => { RunnerDetail, ...stubs, }, - provide: { - glFeatures: { enforceRunnerTokenExpiresAt }, - }, }); }; @@ -82,7 +74,6 @@ describe('RunnerDetails', () => { ...runner, }, }, - enforceRunnerTokenExpiresAt: true, stubs: { GlIntersperse, GlSprintf, @@ -135,22 +126,5 @@ describe('RunnerDetails', () => { expect(findDetailGroups().props('runner')).toEqual(mockGroupRunner); }); }); - - describe('Token expiration field', () => { - it.each` - case | flag | shown - ${'is shown when feature flag is enabled'} | ${true} | ${true} - ${'is not shown when feature flag is disabled'} | ${false} | ${false} - `('$case', ({ flag, shown }) => { - createComponent({ - props: { - runner: mockGroupRunner, - }, - enforceRunnerTokenExpiresAt: flag, - }); - - expect(findDd('Token expiry', wrapper).exists()).toBe(shown); - }); - }); }); }); 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 e35bec3aa38..c92e19f9263 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -4,10 +4,26 @@ 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_ONLINE, INSTANCE_TYPE } from '~/runner/constants'; +import { + PARAM_KEY_STATUS, + PARAM_KEY_TAG, + STATUS_ONLINE, + INSTANCE_TYPE, + DEFAULT_MEMBERSHIP, + DEFAULT_SORT, + CONTACTED_DESC, +} 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'; +const mockSearch = { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: { page: 1 }, + sort: DEFAULT_SORT, +}; + describe('RunnerList', () => { let wrapper; @@ -15,8 +31,7 @@ describe('RunnerList', () => { const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); - const mockDefaultSort = 'CREATED_DESC'; - const mockOtherSort = 'CONTACTED_DESC'; + const mockOtherSort = CONTACTED_DESC; const mockFilters = [ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, @@ -32,11 +47,7 @@ describe('RunnerList', () => { propsData: { namespace: 'runners', tokens: [], - value: { - runnerType: null, - filters: [], - sort: mockDefaultSort, - }, + value: mockSearch, ...props, }, stubs: { @@ -115,6 +126,7 @@ describe('RunnerList', () => { props: { value: { runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, sort: mockOtherSort, filters: mockFilters, }, @@ -141,6 +153,7 @@ describe('RunnerList', () => { expectToHaveLastEmittedInput({ runnerType: INSTANCE_TYPE, + membership: DEFAULT_MEMBERSHIP, filters: mockFilters, sort: mockOtherSort, pagination: {}, @@ -154,8 +167,9 @@ describe('RunnerList', () => { expectToHaveLastEmittedInput({ runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: mockFilters, - sort: mockDefaultSort, + sort: DEFAULT_SORT, pagination: {}, }); }); @@ -165,6 +179,7 @@ describe('RunnerList', () => { expectToHaveLastEmittedInput({ runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [], sort: mockOtherSort, pagination: {}, diff --git a/spec/frontend/runner/components/runner_list_empty_state_spec.js b/spec/frontend/runner/components/runner_list_empty_state_spec.js index 59cff863106..038162b889e 100644 --- a/spec/frontend/runner/components/runner_list_empty_state_spec.js +++ b/spec/frontend/runner/components/runner_list_empty_state_spec.js @@ -8,6 +8,7 @@ import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vu const mockSvgPath = 'mock-svg-path.svg'; const mockFilteredSvgPath = 'mock-filtered-svg-path.svg'; +const mockRegistrationToken = 'REGISTRATION_TOKEN'; describe('RunnerListEmptyState', () => { let wrapper; @@ -21,6 +22,7 @@ describe('RunnerListEmptyState', () => { propsData: { svgPath: mockSvgPath, filteredSvgPath: mockFilteredSvgPath, + registrationToken: mockRegistrationToken, ...props, }, directives: { @@ -35,27 +37,52 @@ describe('RunnerListEmptyState', () => { }; describe('when search is not filtered', () => { - beforeEach(() => { - createComponent(); - }); + const title = s__('Runners|Get started with runners'); - it('renders an illustration', () => { - expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); - }); + describe('when there is a registration token', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + }); + + it('displays "no results" text with instructions', () => { + const desc = s__( + 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', + ); - it('displays "no results" text', () => { - const title = s__('Runners|Get started with runners'); - const desc = s__( - 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', - ); + expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + }); - expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + it('opens a runner registration instructions modal with a link', () => { + const { value } = getBinding(findLink().element, 'gl-modal'); + + expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + }); }); - it('opens a runner registration instructions modal with a link', () => { - const { value } = getBinding(findLink().element, 'gl-modal'); + describe('when there is no registration token', () => { + beforeEach(() => { + createComponent({ props: { registrationToken: null } }); + }); + + it('renders an illustration', () => { + expect(findEmptyState().props('svgPath')).toBe(mockSvgPath); + }); + + it('displays "no results" text', () => { + const desc = s__( + 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.', + ); + + expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`); + }); - expect(findRunnerInstructionsModal().props('modalId')).toEqual(value); + it('has no registration instructions link', () => { + expect(findLink().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 54a9e713721..a31990f8f7e 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -1,12 +1,19 @@ import { GlTableLite, GlSkeletonLoader } from '@gitlab/ui'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { extendedWrapper, shallowMountExtended, mountExtended, } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createLocalState } from '~/runner/graphql/list/local_state'; + import RunnerList from '~/runner/components/runner_list.vue'; -import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue'; +import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue'; + import { I18N_PROJECT_TYPE, I18N_STATUS_NEVER_CONTACTED } from '~/runner/constants'; import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data'; @@ -15,6 +22,8 @@ const mockActiveRunnersCount = mockRunners.length; describe('RunnerList', () => { let wrapper; + let cacheConfig; + let localMutations; const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findTable = () => wrapper.findComponent(GlTableLite); @@ -22,18 +31,24 @@ describe('RunnerList', () => { const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]'); const findCell = ({ row = 0, fieldKey }) => extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); + const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); + const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox); const createComponent = ( { props = {}, provide = {}, ...options } = {}, mountFn = shallowMountExtended, ) => { + ({ cacheConfig, localMutations } = createLocalState()); + wrapper = mountFn(RunnerList, { + apolloProvider: createMockApollo([], {}, cacheConfig), propsData: { runners: mockRunners, activeRunnersCount: mockActiveRunnersCount, ...props, }, provide: { + localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, ...provide, @@ -50,7 +65,7 @@ describe('RunnerList', () => { createComponent( { stubs: { - RunnerStatusPopover: { + HelpPopover: { template: '<div/>', }, }, @@ -60,11 +75,13 @@ describe('RunnerList', () => { const headerLabels = findHeaders().wrappers.map((w) => w.text()); - expect(findHeaders().at(0).findComponent(RunnerStatusPopover).exists()).toBe(true); + expect(findHeaders().at(0).findComponent(HelpPopover).exists()).toBe(true); + expect(findHeaders().at(2).findComponent(HelpPopover).exists()).toBe(true); expect(headerLabels).toEqual([ - 'Status', - 'Runner', + s__('Runners|Status'), + s__('Runners|Runner'), + s__('Runners|Owner'), '', // actions has no label ]); }); @@ -123,21 +140,40 @@ describe('RunnerList', () => { ); }); + it('runner bulk delete is available', () => { + expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners); + }); + + it('runner bulk delete checkbox is available', () => { + expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners); + }); + it('Displays a checkbox field', () => { expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true); }); - it('Emits a checked event', async () => { - const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); + it('Sets a runner as checked', async () => { + const runner = mockRunners[0]; + const setRunnerCheckedMock = jest + .spyOn(localMutations, 'setRunnerChecked') + .mockImplementation(() => {}); + const checkbox = findCell({ fieldKey: 'checkbox' }).find('input'); await checkbox.setChecked(); - expect(wrapper.emitted('checked')).toHaveLength(1); - expect(wrapper.emitted('checked')[0][0]).toEqual({ + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); + expect(setRunnerCheckedMock).toHaveBeenCalledWith({ + runner, isChecked: true, - runner: mockRunners[0], }); }); + + it('Emits a deleted event', async () => { + const event = { message: 'Deleted!' }; + findRunnerBulkDelete().vm.$emit('deleted', event); + + expect(wrapper.emitted('deleted')).toEqual([[event]]); + }); }); describe('Scoped cell slots', () => { diff --git a/spec/frontend/runner/components/runner_membership_toggle_spec.js b/spec/frontend/runner/components/runner_membership_toggle_spec.js new file mode 100644 index 00000000000..1a7ae22618a --- /dev/null +++ b/spec/frontend/runner/components/runner_membership_toggle_spec.js @@ -0,0 +1,57 @@ +import { GlToggle } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerMembershipToggle from '~/runner/components/runner_membership_toggle.vue'; +import { + I18N_SHOW_ONLY_INHERITED, + MEMBERSHIP_DESCENDANTS, + MEMBERSHIP_ALL_AVAILABLE, +} from '~/runner/constants'; + +describe('RunnerMembershipToggle', () => { + let wrapper; + + const findToggle = () => wrapper.findComponent(GlToggle); + + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerMembershipToggle, { + propsData: props, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays text', () => { + createComponent({ mountFn: mount }); + + expect(wrapper.text()).toBe(I18N_SHOW_ONLY_INHERITED); + }); + + it.each` + membershipValue | toggleValue + ${MEMBERSHIP_DESCENDANTS} | ${true} + ${MEMBERSHIP_ALL_AVAILABLE} | ${false} + `( + 'Displays a membership of $membershipValue as enabled=$toggleValue', + ({ membershipValue, toggleValue }) => { + createComponent({ props: { value: membershipValue } }); + + expect(findToggle().props('value')).toBe(toggleValue); + }, + ); + + it.each` + changeEvt | membershipValue + ${true} | ${MEMBERSHIP_DESCENDANTS} + ${false} | ${MEMBERSHIP_ALL_AVAILABLE} + `( + 'Emits $changeEvt when value is changed to $membershipValue', + ({ changeEvt, membershipValue }) => { + createComponent(); + findToggle().vm.$emit('change', changeEvt); + + expect(wrapper.emitted('input')).toStrictEqual([[membershipValue]]); + }, + ); +}); diff --git a/spec/frontend/runner/components/runner_stacked_layout_banner_spec.js b/spec/frontend/runner/components/runner_stacked_layout_banner_spec.js index 1a8aced9292..d1f04f0ee37 100644 --- a/spec/frontend/runner/components/runner_stacked_layout_banner_spec.js +++ b/spec/frontend/runner/components/runner_stacked_layout_banner_spec.js @@ -29,6 +29,8 @@ describe('RunnerStackedLayoutBanner', () => { }); it('Does not display a banner when dismissed', async () => { + createComponent(); + findLocalStorageSync().vm.$emit('input', true); await nextTick(); diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js index 45ab8684332..dde35533bc3 100644 --- a/spec/frontend/runner/components/runner_type_tabs_spec.js +++ b/spec/frontend/runner/components/runner_type_tabs_spec.js @@ -2,9 +2,21 @@ import { GlTab } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerCount from '~/runner/components/stat/runner_count.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; - -const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + DEFAULT_MEMBERSHIP, + DEFAULT_SORT, +} from '~/runner/constants'; + +const mockSearch = { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: { page: 1 }, + sort: DEFAULT_SORT, +}; const mockCount = (type, multiplier = 1) => { let count; @@ -113,7 +125,7 @@ describe('RunnerTypeTabs', () => { }); findTabs().wrappers.forEach((tab) => { - expect(tab.find(RunnerCount).props()).toEqual({ + expect(tab.findComponent(RunnerCount).props()).toEqual({ scope: INSTANCE_TYPE, skip: false, variables: expect.objectContaining(mockVariables), diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index 7b67a89f989..e12736216a0 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -145,7 +145,7 @@ describe('RunnerUpdateForm', () => { }); it('Form skeleton is shown', () => { - expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true); + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); expect(findFields()).toHaveLength(0); }); diff --git a/spec/frontend/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/runner/components/search_tokens/tag_token_spec.js index 22f0561ca5f..a7363eb11cd 100644 --- a/spec/frontend/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js @@ -77,7 +77,7 @@ describe('TagToken', () => { const findToken = () => wrapper.findComponent(GlToken); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - beforeEach(async () => { + beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(200, mockTags); @@ -86,9 +86,6 @@ describe('TagToken', () => { .reply(200, mockTagsFiltered); getRecentlyUsedSuggestions.mockReturnValue([]); - - createComponent(); - await waitForPromises(); }); afterEach(() => { @@ -97,11 +94,17 @@ describe('TagToken', () => { }); describe('when the tags token is displayed', () => { + beforeEach(() => { + createComponent(); + }); + it('requests tags suggestions', () => { expect(mock.history.get[0].params).toEqual({ search: '' }); }); - it('displays tags suggestions', () => { + it('displays tags suggestions', async () => { + await waitForPromises(); + mockTags.forEach(({ name }, i) => { expect(findGlFilteredSearchSuggestions().at(i).text()).toBe(name); }); @@ -132,13 +135,13 @@ describe('TagToken', () => { }); describe('when the users filters suggestions', () => { - beforeEach(async () => { + beforeEach(() => { + createComponent(); + findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm }); }); - it('requests filtered tags suggestions', async () => { - await waitForPromises(); - + it('requests filtered tags suggestions', () => { expect(mock.history.get[1].params).toEqual({ search: mockSearchTerm }); }); @@ -166,7 +169,7 @@ describe('TagToken', () => { await waitForPromises(); }); - it('error is shown', async () => { + it('error is shown', () => { expect(createAlert).toHaveBeenCalledTimes(1); expect(createAlert).toHaveBeenCalledWith({ message: expect.any(String) }); }); @@ -180,8 +183,26 @@ describe('TagToken', () => { await waitForPromises(); }); - it('selected tag is displayed', async () => { + it('selected tag is displayed', () => { expect(findToken().exists()).toBe(true); }); }); + + describe('when suggestions are disabled', () => { + beforeEach(async () => { + createComponent({ + config: { + ...mockTagTokenConfig, + suggestionsDisabled: true, + }, + }); + + await waitForPromises(); + }); + + it('displays no suggestions', () => { + expect(findGlFilteredSearchSuggestions()).toHaveLength(0); + expect(mock.history.get).toHaveLength(0); + }); + }); }); diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js index ae874fef00d..915170b53f9 100644 --- a/spec/frontend/runner/graphql/local_state_spec.js +++ b/spec/frontend/runner/graphql/local_state_spec.js @@ -4,6 +4,13 @@ import { createLocalState } from '~/runner/graphql/list/local_state'; import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql'; import { RUNNER_TYPENAME } from '~/runner/constants'; +const makeRunner = (id, deleteRunner = true) => ({ + id, + userPermissions: { + deleteRunner, + }, +}); + describe('~/runner/graphql/list/local_state', () => { let localState; let apolloClient; @@ -57,16 +64,21 @@ describe('~/runner/graphql/list/local_state', () => { }); it('returns checked runners that have a reference in the cache', () => { - addMockRunnerToCache('a'); - localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true }); + const id = 'a'; + + addMockRunnerToCache(id); + localState.localMutations.setRunnerChecked({ + runner: makeRunner(id), + isChecked: true, + }); expect(queryCheckedRunnerIds()).toEqual(['a']); }); it('return checked runners that are not dangling references', () => { addMockRunnerToCache('a'); // 'b' is missing from the cache, perhaps because it was deleted - localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true }); - localState.localMutations.setRunnerChecked({ runner: { id: 'b' }, isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner('a'), isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner('b'), isChecked: true }); expect(queryCheckedRunnerIds()).toEqual(['a']); }); @@ -81,7 +93,7 @@ describe('~/runner/graphql/list/local_state', () => { beforeEach(() => { inputs.forEach(([id, isChecked]) => { addMockRunnerToCache(id); - localState.localMutations.setRunnerChecked({ runner: { id }, isChecked }); + localState.localMutations.setRunnerChecked({ runner: makeRunner(id), isChecked }); }); }); it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => { @@ -102,7 +114,7 @@ describe('~/runner/graphql/list/local_state', () => { ids.forEach(addMockRunnerToCache); localState.localMutations.setRunnersChecked({ - runners: ids.map((id) => ({ id })), + runners: ids.map((id) => makeRunner(id)), isChecked, }); }); @@ -117,7 +129,7 @@ describe('~/runner/graphql/list/local_state', () => { it('clears all checked items', () => { ['a', 'b', 'c'].forEach((id) => { addMockRunnerToCache(id); - localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: makeRunner(id), isChecked: true }); }); expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']); @@ -127,4 +139,29 @@ describe('~/runner/graphql/list/local_state', () => { expect(queryCheckedRunnerIds()).toEqual([]); }); }); + + describe('when some runners cannot be deleted', () => { + beforeEach(() => { + addMockRunnerToCache('a'); + addMockRunnerToCache('b'); + }); + + it('setRunnerChecked does not check runner that cannot be deleted', () => { + localState.localMutations.setRunnerChecked({ + runner: makeRunner('a', false), + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + + it('setRunnersChecked does not check runner that cannot be deleted', () => { + localState.localMutations.setRunnersChecked({ + runners: [makeRunner('a', false), makeRunner('b', false)], + isChecked: true, + }); + + expect(queryCheckedRunnerIds()).toEqual([]); + }); + }); }); diff --git a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js index cee1d436942..a3b67674c94 100644 --- a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js @@ -101,6 +101,11 @@ describe('GroupRunnerShowApp', () => { Platform darwin Configuration Runs untagged jobs Maximum job timeout None + Token expiry + Runner authentication token expiration + Runner authentication tokens will expire based on a set interval. + They will automatically rotate once expired. Learn more + Never expires Tags None`.replace(/\s+/g, ' '); expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); 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 a17502c7eec..7482926e151 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -14,6 +14,7 @@ import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config'; +import { createLocalState } from '~/runner/graphql/list/local_state'; import RunnerStackedLayoutBanner from '~/runner/components/runner_stacked_layout_banner.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; @@ -24,6 +25,7 @@ import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; +import RunnerMembershipToggle from '~/runner/components/runner_membership_toggle.vue'; import { CREATED_ASC, @@ -36,9 +38,12 @@ import { GROUP_TYPE, PARAM_KEY_PAUSED, PARAM_KEY_STATUS, + PARAM_KEY_TAG, STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE, + MEMBERSHIP_ALL_AVAILABLE, + MEMBERSHIP_DESCENDANTS, RUNNER_PAGE_SIZE, I18N_EDIT, } from '~/runner/constants'; @@ -89,15 +94,23 @@ describe('GroupRunnersApp', () => { const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationNext = () => findRunnerPagination().findByText(s__('Pagination|Next')); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findRunnerMembershipToggle = () => wrapper.findComponent(RunnerMembershipToggle); + + const createComponent = ({ + props = {}, + provide = {}, + mountFn = shallowMountExtended, + ...options + } = {}) => { + const { cacheConfig, localMutations } = createLocalState(); - const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => { const handlers = [ [groupRunnersQuery, mockGroupRunnersHandler], [groupRunnersCountQuery, mockGroupRunnersCountHandler], ]; wrapper = mountFn(GroupRunnersApp, { - apolloProvider: createMockApollo(handlers), + apolloProvider: createMockApollo(handlers, {}, cacheConfig), propsData: { registrationToken: mockRegistrationToken, groupFullPath: mockGroupFullPath, @@ -105,10 +118,12 @@ describe('GroupRunnersApp', () => { ...props, }, provide: { + localMutations, onlineContactTimeoutSecs, staleTimeoutSecs, emptyStateSvgPath, emptyStateFilteredSvgPath, + ...provide, }, ...options, }); @@ -147,19 +162,50 @@ describe('GroupRunnersApp', () => { expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); }); + describe('show all available runners toggle', () => { + it('shows the membership toggle', () => { + createComponent(); + expect(findRunnerMembershipToggle().exists()).toBe(true); + }); + + it('sets the membership toggle', () => { + setWindowLocation(`?membership[]=${MEMBERSHIP_ALL_AVAILABLE}`); + + createComponent(); + + expect(findRunnerMembershipToggle().props('value')).toBe(MEMBERSHIP_ALL_AVAILABLE); + }); + + it('requests filter', async () => { + createComponent(); + findRunnerMembershipToggle().vm.$emit('input', MEMBERSHIP_ALL_AVAILABLE); + + await waitForPromises(); + + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + membership: MEMBERSHIP_ALL_AVAILABLE, + }), + ); + }); + }); + it('shows total runner counts', async () => { await createComponent({ mountFn: mountExtended }); expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, groupFullPath: mockGroupFullPath, }); expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_OFFLINE, + membership: MEMBERSHIP_DESCENDANTS, groupFullPath: mockGroupFullPath, }); expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_STALE, + membership: MEMBERSHIP_DESCENDANTS, groupFullPath: mockGroupFullPath, }); @@ -183,6 +229,7 @@ describe('GroupRunnersApp', () => { groupFullPath: mockGroupFullPath, status: undefined, type: undefined, + membership: MEMBERSHIP_DESCENDANTS, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, }); @@ -202,6 +249,10 @@ describe('GroupRunnersApp', () => { type: PARAM_KEY_STATUS, options: expect.any(Array), }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + suggestionsDisabled: true, + }), upgradeStatusTokenConfig, ]); }); @@ -213,7 +264,7 @@ describe('GroupRunnersApp', () => { 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 + const FILTERED_COUNT_QUERIES = 6; // Smart queries that display a count of runners in tabs and single stats beforeEach(async () => { await createComponent({ mountFn: mountExtended }); @@ -266,6 +317,7 @@ describe('GroupRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ runnerType: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], sort: 'CREATED_DESC', pagination: {}, @@ -277,6 +329,7 @@ describe('GroupRunnersApp', () => { groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, type: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, }); @@ -286,6 +339,7 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ groupFullPath: mockGroupFullPath, type: INSTANCE_TYPE, + membership: MEMBERSHIP_DESCENDANTS, status: STATUS_ONLINE, }); }); @@ -297,6 +351,7 @@ describe('GroupRunnersApp', () => { findRunnerFilteredSearchBar().vm.$emit('input', { runnerType: null, + membership: MEMBERSHIP_DESCENDANTS, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }], sort: CREATED_ASC, }); @@ -315,6 +370,7 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }); @@ -324,6 +380,7 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({ groupFullPath: mockGroupFullPath, status: STATUS_ONLINE, + membership: MEMBERSHIP_DESCENDANTS, }); }); }); @@ -334,6 +391,11 @@ describe('GroupRunnersApp', () => { expect(findRunnerPagination().attributes('disabled')).toBe('true'); }); + it('runners cannot be deleted in bulk', () => { + createComponent(); + expect(findRunnerList().props('checkable')).toBe(false); + }); + describe('when no runners are found', () => { beforeEach(async () => { mockGroupRunnersHandler.mockResolvedValue({ @@ -395,6 +457,7 @@ describe('GroupRunnersApp', () => { expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ groupFullPath: mockGroupFullPath, + membership: MEMBERSHIP_DESCENDANTS, sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, after: pageInfo.endCursor, diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 555ec40184f..da0c0433b3e 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -17,7 +17,7 @@ import groupRunnersData from 'test_fixtures/graphql/runner/list/group_runners.qu import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.paginated.json'; import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runners_count.query.graphql.json'; -import { RUNNER_PAGE_SIZE } from '~/runner/constants'; +import { DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE } from '~/runner/constants'; const emptyPageInfo = { __typename: 'PageInfo', @@ -34,8 +34,18 @@ export const mockSearchExamples = [ { name: 'a default query', urlQuery: '', - search: { runnerType: null, filters: [], pagination: {}, sort: 'CREATED_DESC' }, - graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + search: { + runnerType: null, + membership: DEFAULT_MEMBERSHIP, + filters: [], + pagination: {}, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, isDefault: true, }, { @@ -43,17 +53,24 @@ export const mockSearchExamples = [ urlQuery: '?status[]=ACTIVE', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + status: 'ACTIVE', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'a single term text search', urlQuery: '?search=something', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'filtered-search-term', @@ -63,13 +80,19 @@ export const mockSearchExamples = [ pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + search: 'something', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'a two terms text search', urlQuery: '?search=something+else', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'filtered-search-term', @@ -83,24 +106,36 @@ export const mockSearchExamples = [ pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + search: 'something else', + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'single instance type', urlQuery: '?runner_type[]=INSTANCE_TYPE', search: { runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, filters: [], pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'multiple runner status', urlQuery: '?status[]=ACTIVE&status[]=PAUSED', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'status', value: { data: 'PAUSED', operator: '=' } }, @@ -108,13 +143,19 @@ export const mockSearchExamples = [ pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + status: 'ACTIVE', + membership: DEFAULT_MEMBERSHIP, + 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: { runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], pagination: {}, sort: 'CREATED_ASC', @@ -122,6 +163,7 @@ export const mockSearchExamples = [ graphqlVariables: { status: 'ACTIVE', type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, sort: 'CREATED_ASC', first: RUNNER_PAGE_SIZE, }, @@ -131,11 +173,13 @@ export const mockSearchExamples = [ urlQuery: '?tag[]=tag-1', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], pagination: {}, sort: 'CREATED_DESC', }, graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1'], first: 20, sort: 'CREATED_DESC', @@ -146,6 +190,7 @@ export const mockSearchExamples = [ urlQuery: '?tag[]=tag-1&tag[]=tag-2', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } }, @@ -154,6 +199,7 @@ export const mockSearchExamples = [ sort: 'CREATED_DESC', }, graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1', 'tag-2'], first: 20, sort: 'CREATED_DESC', @@ -164,22 +210,34 @@ export const mockSearchExamples = [ urlQuery: '?after=AFTER_CURSOR', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [], pagination: { after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC', }, - graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + after: 'AFTER_CURSOR', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'the previous page', urlQuery: '?before=BEFORE_CURSOR', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [], pagination: { before: 'BEFORE_CURSOR' }, sort: 'CREATED_DESC', }, - graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE }, + graphqlVariables: { + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + before: 'BEFORE_CURSOR', + last: RUNNER_PAGE_SIZE, + }, }, { name: 'the next page filtered by a status, an instance type, tags and a non default sort', @@ -187,6 +245,7 @@ export const mockSearchExamples = [ '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR', search: { runnerType: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'tag', value: { data: 'tag-1', operator: '=' } }, @@ -198,6 +257,7 @@ export const mockSearchExamples = [ graphqlVariables: { status: 'ACTIVE', type: 'INSTANCE_TYPE', + membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1', 'tag-2'], sort: 'CREATED_ASC', after: 'AFTER_CURSOR', @@ -209,22 +269,34 @@ export const mockSearchExamples = [ urlQuery: '?paused[]=true', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + paused: true, + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, { name: 'active runners', urlQuery: '?paused[]=false', search: { runnerType: null, + membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], pagination: {}, sort: 'CREATED_DESC', }, - graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + graphqlVariables: { + paused: false, + membership: DEFAULT_MEMBERSHIP, + sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + }, }, ]; |