diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 13:16:36 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 13:16:36 +0000 |
commit | 311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch) | |
tree | 07e7870bca8aed6d61fdcc810731c50d2c40af47 /spec/frontend/runner | |
parent | 27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff) | |
download | gitlab-ce-311b0269b4eb9839fa63f80c8d7a58f32b8138a0.tar.gz |
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'spec/frontend/runner')
22 files changed, 782 insertions, 454 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 33e9c122080..7eda9aa2850 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -10,9 +10,10 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; 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'; import RunnerList from '~/runner/components/runner_list.vue'; -import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; import { @@ -22,7 +23,6 @@ import { DEFAULT_SORT, INSTANCE_TYPE, PARAM_KEY_STATUS, - PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG, STATUS_ACTIVE, RUNNER_PAGE_SIZE, @@ -34,7 +34,11 @@ import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered import { runnersData, runnersDataPaginated } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; -const mockActiveRunnersCount = 2; +const mockActiveRunnersCount = '2'; +const mockAllRunnersCount = '6'; +const mockInstanceRunnersCount = '3'; +const mockGroupRunnersCount = '2'; +const mockProjectRunnersCount = '1'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -50,7 +54,8 @@ describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; - const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationPrev = () => @@ -66,8 +71,12 @@ describe('AdminRunnersApp', () => { localVue, apolloProvider: createMockApollo(handlers), propsData: { - activeRunnersCount: mockActiveRunnersCount, registrationToken: mockRegistrationToken, + activeRunnersCount: mockActiveRunnersCount, + allRunnersCount: mockAllRunnersCount, + instanceRunnersCount: mockInstanceRunnersCount, + groupRunnersCount: mockGroupRunnersCount, + projectRunnersCount: mockProjectRunnersCount, ...props, }, }); @@ -86,8 +95,19 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); + it('shows the runner tabs with a runner count', async () => { + createComponent({ mountFn: mount }); + + await waitForPromises(); + + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + `All ${mockAllRunnersCount} Instance ${mockInstanceRunnersCount} Group ${mockGroupRunnersCount} Project ${mockProjectRunnersCount}`, + ); + }); + it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE); }); it('shows the runners list', () => { @@ -126,10 +146,6 @@ describe('AdminRunnersApp', () => { options: expect.any(Array), }), expect.objectContaining({ - type: PARAM_KEY_RUNNER_TYPE, - options: expect.any(Array), - }), - expect.objectContaining({ type: PARAM_KEY_TAG, recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, }), @@ -154,9 +170,9 @@ describe('AdminRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + runnerType: INSTANCE_TYPE, filters: [ { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, - { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, { type: 'tag', value: { data: 'tag1', operator: '=' } }, ], sort: 'CREATED_DESC', @@ -178,6 +194,7 @@ describe('AdminRunnersApp', () => { describe('when a filter is selected by the user', () => { beforeEach(() => { findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], sort: CREATED_ASC, }); 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 5aa3879ac3e..2874bdbe280 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -8,12 +8,11 @@ import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue' import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; +import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; -import { runnersData, runnerData } from '../../mock_data'; +import { runnersData } from '../../mock_data'; const mockRunner = runnersData.data.runners.nodes[0]; -const mockRunnerDetails = runnerData.data.runner; const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value; @@ -27,7 +26,7 @@ jest.mock('~/runner/sentry_utils'); describe('RunnerTypeCell', () => { let wrapper; const runnerDeleteMutationHandler = jest.fn(); - const runnerUpdateMutationHandler = jest.fn(); + const runnerActionsUpdateMutationHandler = jest.fn(); const findEditBtn = () => wrapper.findByTestId('edit-runner'); const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); @@ -46,7 +45,7 @@ describe('RunnerTypeCell', () => { localVue, apolloProvider: createMockApollo([ [runnerDeleteMutation, runnerDeleteMutationHandler], - [runnerUpdateMutation, runnerUpdateMutationHandler], + [runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler], ]), ...options, }), @@ -62,10 +61,10 @@ describe('RunnerTypeCell', () => { }, }); - runnerUpdateMutationHandler.mockResolvedValue({ + runnerActionsUpdateMutationHandler.mockResolvedValue({ data: { runnerUpdate: { - runner: mockRunnerDetails, + runner: mockRunner, errors: [], }, }, @@ -74,7 +73,7 @@ describe('RunnerTypeCell', () => { afterEach(() => { runnerDeleteMutationHandler.mockReset(); - runnerUpdateMutationHandler.mockReset(); + runnerActionsUpdateMutationHandler.mockReset(); wrapper.destroy(); }); @@ -116,12 +115,12 @@ describe('RunnerTypeCell', () => { describe(`When clicking on the ${icon} button`, () => { it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => { - expect(runnerUpdateMutationHandler).toHaveBeenCalledTimes(0); + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0); await findToggleActiveBtn().vm.$emit('click'); - expect(runnerUpdateMutationHandler).toHaveBeenCalledTimes(1); - expect(runnerUpdateMutationHandler).toHaveBeenCalledWith({ + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1); + expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({ input: { id: mockRunner.id, active: newActiveValue, @@ -145,7 +144,7 @@ describe('RunnerTypeCell', () => { const mockErrorMsg = 'Update error!'; beforeEach(async () => { - runnerUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); await findToggleActiveBtn().vm.$emit('click'); }); @@ -167,10 +166,10 @@ describe('RunnerTypeCell', () => { const mockErrorMsg2 = 'User not allowed!'; beforeEach(async () => { - runnerUpdateMutationHandler.mockResolvedValue({ + runnerActionsUpdateMutationHandler.mockResolvedValue({ data: { runnerUpdate: { - runner: runnerData.data.runner, + runner: mockRunner, errors: [mockErrorMsg, mockErrorMsg2], }, }, diff --git a/spec/frontend/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/runner/components/cells/runner_status_cell_spec.js new file mode 100644 index 00000000000..20a1cdf7236 --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_status_cell_spec.js @@ -0,0 +1,69 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerStatusCell from '~/runner/components/cells/runner_status_cell.vue'; +import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE } from '~/runner/constants'; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findBadgeAt = (i) => wrapper.findAllComponents(GlBadge).at(i); + + const createComponent = ({ runner = {} } = {}) => { + wrapper = mount(RunnerStatusCell, { + propsData: { + runner: { + runnerType: INSTANCE_TYPE, + active: true, + status: STATUS_ONLINE, + ...runner, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays online status', () => { + createComponent(); + + expect(wrapper.text()).toMatchInterpolatedText('online'); + expect(findBadgeAt(0).text()).toBe('online'); + }); + + it('Displays offline status', () => { + createComponent({ + runner: { + status: STATUS_OFFLINE, + }, + }); + + expect(wrapper.text()).toMatchInterpolatedText('offline'); + expect(findBadgeAt(0).text()).toBe('offline'); + }); + + it('Displays paused status', () => { + createComponent({ + runner: { + active: false, + status: STATUS_ONLINE, + }, + }); + + expect(wrapper.text()).toMatchInterpolatedText('online paused'); + + expect(findBadgeAt(0).text()).toBe('online'); + expect(findBadgeAt(1).text()).toBe('paused'); + }); + + it('Is empty when data is missing', () => { + createComponent({ + runner: { + status: null, + }, + }); + + expect(wrapper.text()).toBe(''); + }); +}); 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 1c9282e0acd..b6d957d27ea 100644 --- a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js @@ -1,5 +1,6 @@ -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import RunnerSummaryCell from '~/runner/components/cells/runner_summary_cell.vue'; +import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants'; const mockId = '1'; const mockShortSha = '2P6oDVDm'; @@ -8,13 +9,17 @@ const mockDescription = 'runner-1'; describe('RunnerTypeCell', () => { let wrapper; - const createComponent = (options) => { - wrapper = mount(RunnerSummaryCell, { + const findLockIcon = () => wrapper.findByTestId('lock-icon'); + + const createComponent = (runner, options) => { + wrapper = mountExtended(RunnerSummaryCell, { propsData: { runner: { id: `gid://gitlab/Ci::Runner/${mockId}`, shortSha: mockShortSha, description: mockDescription, + runnerType: INSTANCE_TYPE, + ...runner, }, }, ...options, @@ -33,6 +38,23 @@ describe('RunnerTypeCell', () => { expect(wrapper.text()).toContain(`#${mockId} (${mockShortSha})`); }); + it('Displays the runner type', () => { + expect(wrapper.text()).toContain('shared'); + }); + + it('Does not display the locked icon', () => { + expect(findLockIcon().exists()).toBe(false); + }); + + it('Displays the locked icon for locked runners', () => { + createComponent({ + runnerType: PROJECT_TYPE, + locked: true, + }); + + expect(findLockIcon().exists()).toBe(true); + }); + it('Displays the runner description', () => { expect(wrapper.text()).toContain(mockDescription); }); @@ -40,11 +62,14 @@ describe('RunnerTypeCell', () => { it('Displays a custom slot', () => { const slotContent = 'My custom runner summary'; - createComponent({ - slots: { - 'runner-name': slotContent, + createComponent( + {}, + { + slots: { + 'runner-name': slotContent, + }, }, - }); + ); expect(wrapper.text()).toContain(slotContent); }); diff --git a/spec/frontend/runner/components/cells/runner_type_cell_spec.js b/spec/frontend/runner/components/cells/runner_type_cell_spec.js deleted file mode 100644 index 48958a282fc..00000000000 --- a/spec/frontend/runner/components/cells/runner_type_cell_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue'; -import { INSTANCE_TYPE } from '~/runner/constants'; - -describe('RunnerTypeCell', () => { - let wrapper; - - const findBadges = () => wrapper.findAllComponents(GlBadge); - - const createComponent = ({ runner = {} } = {}) => { - wrapper = mount(RunnerTypeCell, { - propsData: { - runner: { - runnerType: INSTANCE_TYPE, - active: true, - locked: false, - ...runner, - }, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('Displays the runner type', () => { - createComponent(); - - expect(findBadges()).toHaveLength(1); - expect(findBadges().at(0).text()).toBe('shared'); - }); - - it('Displays locked and paused states', () => { - createComponent({ - runner: { - active: false, - locked: true, - }, - }); - - expect(findBadges()).toHaveLength(3); - expect(findBadges().at(0).text()).toBe('shared'); - expect(findBadges().at(1).text()).toBe('locked'); - expect(findBadges().at(2).text()).toBe('paused'); - }); -}); diff --git a/spec/frontend/runner/components/helpers/masked_value_spec.js b/spec/frontend/runner/components/helpers/masked_value_spec.js deleted file mode 100644 index f87315057ec..00000000000 --- a/spec/frontend/runner/components/helpers/masked_value_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MaskedValue from '~/runner/components/helpers/masked_value.vue'; - -const mockSecret = '01234567890'; -const mockMasked = '***********'; - -describe('MaskedValue', () => { - let wrapper; - - const findButton = () => wrapper.findComponent(GlButton); - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(MaskedValue, { - propsData: { - value: mockSecret, - ...props, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('Displays masked value by default', () => { - expect(wrapper.text()).toBe(mockMasked); - }); - - describe('When the icon is clicked', () => { - beforeEach(() => { - findButton().vm.$emit('click'); - }); - - it('Displays the actual value', () => { - expect(wrapper.text()).toBe(mockSecret); - expect(wrapper.text()).not.toBe(mockMasked); - }); - - it('When user clicks again, displays masked value', async () => { - await findButton().vm.$emit('click'); - - expect(wrapper.text()).toBe(mockMasked); - expect(wrapper.text()).not.toBe(mockSecret); - }); - }); -}); diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js new file mode 100644 index 00000000000..d18d2bec18e --- /dev/null +++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js @@ -0,0 +1,169 @@ +import { GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount, createWrapper } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; +import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; + +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; + +import { + mockGraphqlRunnerPlatforms, + mockGraphqlInstructions, +} from 'jest/vue_shared/components/runner_instructions/mock_data'; + +const mockToken = '0123456789'; +const maskToken = '**********'; + +describe('RegistrationDropdown', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + + const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); + const findTokenResetDropdownItem = () => + wrapper.findComponent(RegistrationTokenResetDropdownItem); + + const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); + + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { + wrapper = extendedWrapper( + mountFn(RegistrationDropdown, { + propsData: { + registrationToken: mockToken, + type: INSTANCE_TYPE, + ...props, + }, + ...options, + }), + ); + }; + + it.each` + type | text + ${INSTANCE_TYPE} | ${'Register an instance runner'} + ${GROUP_TYPE} | ${'Register a group runner'} + ${PROJECT_TYPE} | ${'Register a project runner'} + `('Dropdown text for type $type is "$text"', () => { + createComponent({ props: { type: INSTANCE_TYPE } }, mount); + + expect(wrapper.text()).toContain('Register an instance runner'); + }); + + it('Passes attributes to the dropdown component', () => { + createComponent({ attrs: { right: true } }); + + expect(findDropdown().attributes()).toMatchObject({ right: 'true' }); + }); + + describe('Instructions dropdown item', () => { + it('Displays "Show runner" dropdown item', () => { + createComponent(); + + expect(findRegistrationInstructionsDropdownItem().text()).toBe( + 'Show runner installation and registration instructions', + ); + }); + + describe('When the dropdown item is clicked', () => { + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const requestHandlers = [ + [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], + [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + ]; + + const findModalInBody = () => + createWrapper(document.body).find('[data-testid="runner-instructions-modal"]'); + + beforeEach(() => { + createComponent( + { + localVue, + // Mock load modal contents from API + apolloProvider: createMockApollo(requestHandlers), + // Use `attachTo` to find the modal + attachTo: document.body, + }, + mount, + ); + + findRegistrationInstructionsDropdownItem().trigger('click'); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('opens the modal with contents', () => { + const modalText = findModalInBody() + .text() + .replace(/[\n\t\s]+/g, ' '); + + expect(modalText).toContain('Install a runner'); + + // Environment selector + expect(modalText).toContain('Environment'); + expect(modalText).toContain('Linux macOS Windows Docker Kubernetes'); + + // Architecture selector + expect(modalText).toContain('Architecture'); + expect(modalText).toContain('amd64 amd64 386 arm arm64'); + + expect(modalText).toContain('Download and install binary'); + }); + }); + }); + + describe('Registration token', () => { + it('Displays dropdown form for the registration token', () => { + createComponent(); + + expect(findTokenDropdownItem().exists()).toBe(true); + }); + + it('Displays masked value by default', () => { + createComponent({}, mount); + + expect(findTokenDropdownItem().text()).toMatchInterpolatedText( + `Registration token ${maskToken}`, + ); + }); + }); + + describe('Reset token item', () => { + it('Displays registration token reset item', () => { + createComponent(); + + expect(findTokenResetDropdownItem().exists()).toBe(true); + }); + + it.each([INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE])('Set up token reset for %s', (type) => { + createComponent({ props: { type } }); + + expect(findTokenResetDropdownItem().props('type')).toBe(type); + }); + }); + + it('Updates the token when it gets reset', async () => { + createComponent({}, mount); + + const newToken = 'mock1'; + + findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); + findToggleMaskButton().vm.$emit('click', { stopPropagation: jest.fn() }); + await nextTick(); + + expect(findTokenDropdownItem().text()).toMatchInterpolatedText( + `Registration token ${newToken}`, + ); + }); +}); diff --git a/spec/frontend/runner/components/runner_registration_token_reset_spec.js b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js index 8b360b88417..0d002c272b4 100644 --- a/spec/frontend/runner/components/runner_registration_token_reset_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -1,11 +1,11 @@ -import { GlButton } from '@gitlab/ui'; +import { GlDropdownItem, GlLoadingIcon, GlToast } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash, { FLASH_TYPES } from '~/flash'; -import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; +import createFlash from '~/flash'; +import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; @@ -15,17 +15,20 @@ jest.mock('~/runner/sentry_utils'); const localVue = createLocalVue(); localVue.use(VueApollo); +localVue.use(GlToast); const mockNewToken = 'NEW_TOKEN'; -describe('RunnerRegistrationTokenReset', () => { +describe('RegistrationTokenResetDropdownItem', () => { let wrapper; let runnersRegistrationTokenResetMutationHandler; + let showToast; - const findButton = () => wrapper.findComponent(GlButton); + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const createComponent = ({ props, provide = {} } = {}) => { - wrapper = shallowMount(RunnerRegistrationTokenReset, { + wrapper = shallowMount(RegistrationTokenResetDropdownItem, { localVue, provide, propsData: { @@ -36,6 +39,8 @@ describe('RunnerRegistrationTokenReset', () => { [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], ]), }); + + showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; }; beforeEach(() => { @@ -58,7 +63,7 @@ describe('RunnerRegistrationTokenReset', () => { }); it('Displays reset button', () => { - expect(findButton().exists()).toBe(true); + expect(findDropdownItem().exists()).toBe(true); }); describe('On click and confirmation', () => { @@ -78,7 +83,8 @@ describe('RunnerRegistrationTokenReset', () => { }); window.confirm.mockReturnValueOnce(true); - findButton().vm.$emit('click'); + + findDropdownItem().trigger('click'); await waitForPromises(); }); @@ -95,14 +101,13 @@ describe('RunnerRegistrationTokenReset', () => { }); it('does not show a loading state', () => { - expect(findButton().props('loading')).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); }); it('shows confirmation', () => { - expect(createFlash).toHaveBeenLastCalledWith({ - message: expect.stringContaining('registration token generated'), - type: FLASH_TYPES.SUCCESS, - }); + expect(showToast).toHaveBeenLastCalledWith( + expect.stringContaining('registration token generated'), + ); }); }); }); @@ -110,7 +115,7 @@ describe('RunnerRegistrationTokenReset', () => { describe('On click without confirmation', () => { beforeEach(async () => { window.confirm.mockReturnValueOnce(false); - findButton().vm.$emit('click'); + findDropdownItem().vm.$emit('click'); await waitForPromises(); }); @@ -123,11 +128,11 @@ describe('RunnerRegistrationTokenReset', () => { }); it('does not show a loading state', () => { - expect(findButton().props('loading')).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); }); it('does not shows confirmation', () => { - expect(createFlash).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); }); }); @@ -138,7 +143,7 @@ describe('RunnerRegistrationTokenReset', () => { runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); window.confirm.mockReturnValueOnce(true); - findButton().vm.$emit('click'); + findDropdownItem().trigger('click'); await waitForPromises(); expect(createFlash).toHaveBeenLastCalledWith({ @@ -164,7 +169,7 @@ describe('RunnerRegistrationTokenReset', () => { }); window.confirm.mockReturnValueOnce(true); - findButton().vm.$emit('click'); + findDropdownItem().trigger('click'); await waitForPromises(); expect(createFlash).toHaveBeenLastCalledWith({ @@ -180,10 +185,10 @@ describe('RunnerRegistrationTokenReset', () => { describe('Immediately after click', () => { it('shows loading state', async () => { window.confirm.mockReturnValue(true); - findButton().vm.$emit('click'); + findDropdownItem().trigger('click'); await nextTick(); - expect(findButton().props('loading')).toBe(true); + expect(findLoadingIcon().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js new file mode 100644 index 00000000000..f53ae165344 --- /dev/null +++ b/spec/frontend/runner/components/registration/registration_token_spec.js @@ -0,0 +1,109 @@ +import { nextTick } from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { extendedWrapper } 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'; + +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 vueWithGlToast = () => { + const localVue = createLocalVue(); + localVue.use(GlToast); + return localVue; + }; + + const createComponent = ({ props = {}, withGlToast = true } = {}) => { + const localVue = withGlToast ? vueWithGlToast() : undefined; + + wrapper = extendedWrapper( + shallowMount(RegistrationToken, { + propsData: { + value: mockToken, + ...props, + }, + localVue, + }), + ); + + 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 button to reveal token', () => { + expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); + }); + + it('Can copy the original token value', () => { + expect(findCopyButton().props('text')).toBe(mockToken); + }); + + describe('When the reveal icon is clicked', () => { + beforeEach(() => { + findToggleMaskButton().vm.$emit('click', { stopPropagation }); + }); + + it('Click event is not propagated', async () => { + expect(stopPropagation).toHaveBeenCalledTimes(1); + }); + + 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'); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Registration token copied!'); + }); + + it('does not fail when toast is not defined', () => { + createComponent({ withGlToast: false }); + findCopyButton().vm.$emit('success'); + + // This block also tests for unhandled errors + expect(showToast).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_contacted_state_badge_spec.js b/spec/frontend/runner/components/runner_contacted_state_badge_spec.js new file mode 100644 index 00000000000..57a27f39826 --- /dev/null +++ b/spec/frontend/runner/components/runner_contacted_state_badge_spec.js @@ -0,0 +1,86 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerContactedStateBadge from '~/runner/components/runner_contacted_state_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_NOT_CONNECTED } from '~/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ runner = {} } = {}) => { + wrapper = shallowMount(RunnerContactedStateBadge, { + propsData: { + runner: { + contactedAt: '2021-01-01T00:00:00Z', + status: STATUS_ONLINE, + ...runner, + }, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useFakeTimers('legacy'); + + wrapper.destroy(); + }); + + it('renders online state', () => { + jest.setSystemTime(new Date('2021-01-01T00:01:00Z')); + + createComponent(); + + expect(wrapper.text()).toBe('online'); + expect(findBadge().props('variant')).toBe('success'); + expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago'); + }); + + it('renders offline state', () => { + jest.setSystemTime(new Date('2021-01-02T00:00:00Z')); + + createComponent({ + runner: { + status: STATUS_OFFLINE, + }, + }); + + 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', + ); + }); + + it('renders not connected state', () => { + createComponent({ + runner: { + contactedAt: null, + status: STATUS_NOT_CONNECTED, + }, + }); + + expect(wrapper.text()).toBe('not connected'); + expect(findBadge().props('variant')).toBe('muted'); + expect(getTooltip().value).toMatch('This runner has never connected'); + }); + + it('does not fail when data is missing', () => { + createComponent({ + runner: { + status: null, + }, + }); + + expect(wrapper.text()).toBe(''); + }); +}); 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 46948af1f28..9ea0955f2a1 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -5,13 +5,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 { typeTokenConfig } from '~/runner/components/search_tokens/type_token_config'; -import { - PARAM_KEY_STATUS, - PARAM_KEY_RUNNER_TYPE, - PARAM_KEY_TAG, - STATUS_ACTIVE, -} from '~/runner/constants'; +import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ACTIVE, 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'; @@ -31,6 +25,11 @@ describe('RunnerList', () => { ]; const mockActiveRunnersCount = 2; + const expectToHaveLastEmittedInput = (value) => { + const inputs = wrapper.emitted('input'); + expect(inputs[inputs.length - 1][0]).toEqual(value); + }; + const createComponent = ({ props = {}, options = {} } = {}) => { wrapper = extendedWrapper( shallowMount(RunnerFilteredSearchBar, { @@ -38,6 +37,7 @@ describe('RunnerList', () => { namespace: 'runners', tokens: [], value: { + runnerType: null, filters: [], sort: mockDefaultSort, }, @@ -86,7 +86,7 @@ describe('RunnerList', () => { it('sets tokens to the filtered search', () => { createComponent({ props: { - tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig], + tokens: [statusTokenConfig, tagTokenConfig], }, }); @@ -97,11 +97,6 @@ describe('RunnerList', () => { options: expect.any(Array), }), expect.objectContaining({ - type: PARAM_KEY_RUNNER_TYPE, - token: BaseToken, - options: expect.any(Array), - }), - expect.objectContaining({ type: PARAM_KEY_TAG, token: TagToken, }), @@ -123,6 +118,7 @@ describe('RunnerList', () => { createComponent({ props: { value: { + runnerType: INSTANCE_TYPE, sort: mockOtherSort, filters: mockFilters, }, @@ -142,30 +138,40 @@ describe('RunnerList', () => { .text(), ).toEqual('Last contact'); }); + + it('when the user sets a filter, the "search" preserves the other filters', () => { + findGlFilteredSearch().vm.$emit('input', mockFilters); + findGlFilteredSearch().vm.$emit('submit'); + + expectToHaveLastEmittedInput({ + runnerType: INSTANCE_TYPE, + filters: mockFilters, + sort: mockOtherSort, + pagination: { page: 1 }, + }); + }); }); it('when the user sets a filter, the "search" is emitted with filters', () => { findGlFilteredSearch().vm.$emit('input', mockFilters); findGlFilteredSearch().vm.$emit('submit'); - expect(wrapper.emitted('input')[0]).toEqual([ - { - filters: mockFilters, - sort: mockDefaultSort, - pagination: { page: 1 }, - }, - ]); + expectToHaveLastEmittedInput({ + runnerType: null, + filters: mockFilters, + sort: mockDefaultSort, + pagination: { page: 1 }, + }); }); it('when the user sets a sorting method, the "search" is emitted with the sort', () => { findSortOptions().at(1).vm.$emit('click'); - expect(wrapper.emitted('input')[0]).toEqual([ - { - filters: [], - sort: mockOtherSort, - pagination: { page: 1 }, - }, - ]); + expectToHaveLastEmittedInput({ + runnerType: null, + filters: [], + sort: mockOtherSort, + pagination: { page: 1 }, + }); }); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index e24dffea1eb..986e55a2132 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -1,6 +1,5 @@ import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import { cloneDeep } from 'lodash'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerList from '~/runner/components/runner_list.vue'; @@ -43,12 +42,10 @@ describe('RunnerList', () => { const headerLabels = findHeaders().wrappers.map((w) => w.text()); expect(headerLabels).toEqual([ - 'Type/State', - 'Runner', + 'Status', + 'Runner ID', 'Version', 'IP Address', - 'Projects', - 'Jobs', 'Tags', 'Last contact', '', // actions has no label @@ -65,7 +62,7 @@ describe('RunnerList', () => { const { id, description, version, ipAddress, shortSha } = mockRunners[0]; // Badges - expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('specific paused'); + expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('not connected paused'); // Runner summary expect(findCell({ fieldKey: 'summary' }).text()).toContain( @@ -76,8 +73,6 @@ describe('RunnerList', () => { // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('1'); - expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0'); expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); @@ -88,54 +83,6 @@ describe('RunnerList', () => { expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); }); - describe('Table data formatting', () => { - let mockRunnersCopy; - - beforeEach(() => { - mockRunnersCopy = cloneDeep(mockRunners); - }); - - it('Formats null project counts', () => { - mockRunnersCopy[0].projectCount = null; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('n/a'); - }); - - it('Formats 0 project counts', () => { - mockRunnersCopy[0].projectCount = 0; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('0'); - }); - - it('Formats big project counts', () => { - mockRunnersCopy[0].projectCount = 1000; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('1,000'); - }); - - it('Formats job counts', () => { - mockRunnersCopy[0].jobCount = 1000; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000'); - }); - - it('Formats big job counts with a plus symbol', () => { - mockRunnersCopy[0].jobCount = 1001; - - createComponent({ props: { runners: mockRunnersCopy } }, mount); - - expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000+'); - }); - }); - it('Shows runner identifier', () => { const { id, shortSha } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js deleted file mode 100644 index effef0e7ebf..00000000000 --- a/spec/frontend/runner/components/runner_manual_setup_help_spec.js +++ /dev/null @@ -1,122 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'helpers/test_constants'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import MaskedValue from '~/runner/components/helpers/masked_value.vue'; -import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; -import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; - -const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; -const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/'; - -describe('RunnerManualSetupHelp', () => { - let wrapper; - let originalGon; - - const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions); - const findRunnerRegistrationTokenReset = () => - wrapper.findComponent(RunnerRegistrationTokenReset); - const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton); - const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title'); - const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url'); - const findRegistrationToken = () => wrapper.findByTestId('registration-token'); - const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link'); - - const createComponent = ({ props = {} } = {}) => { - wrapper = extendedWrapper( - shallowMount(RunnerManualSetupHelp, { - provide: { - runnerInstallHelpPage: mockRunnerInstallHelpPage, - }, - propsData: { - registrationToken: mockRegistrationToken, - type: INSTANCE_TYPE, - ...props, - }, - stubs: { - MaskedValue, - GlSprintf, - }, - }), - ); - }; - - beforeAll(() => { - originalGon = global.gon; - global.gon = { gitlab_url: TEST_HOST }; - }); - - afterAll(() => { - global.gon = originalGon; - }); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('Title contains the shared runner type', () => { - createComponent({ props: { type: INSTANCE_TYPE } }); - - expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually'); - }); - - it('Title contains the group runner type', () => { - createComponent({ props: { type: GROUP_TYPE } }); - - expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually'); - }); - - it('Title contains the specific runner type', () => { - createComponent({ props: { type: PROJECT_TYPE } }); - - expect(findRunnerHelpTitle().text()).toMatchInterpolatedText( - 'Set up a specific runner manually', - ); - }); - - it('Runner Install Page link', () => { - expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage); - }); - - it('Displays the coordinator URL token', () => { - expect(findCoordinatorUrl().text()).toBe(TEST_HOST); - expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST); - }); - - it('Displays the runner instructions', () => { - expect(findRunnerInstructions().exists()).toBe(true); - }); - - it('Displays the registration token', async () => { - findRegistrationToken().find('[data-testid="toggle-masked"]').vm.$emit('click'); - - await nextTick(); - - expect(findRegistrationToken().text()).toBe(mockRegistrationToken); - expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken); - }); - - it('Displays the runner registration token reset button', () => { - expect(findRunnerRegistrationTokenReset().exists()).toBe(true); - }); - - it('Replaces the runner reset button', async () => { - const mockNewRegistrationToken = 'NEW_MOCK_REGISTRATION_TOKEN'; - - findRegistrationToken().find('[data-testid="toggle-masked"]').vm.$emit('click'); - findRunnerRegistrationTokenReset().vm.$emit('tokenReset', mockNewRegistrationToken); - - await nextTick(); - - expect(findRegistrationToken().text()).toBe(mockNewRegistrationToken); - expect(findClipboardButtons().at(1).props('text')).toBe(mockNewRegistrationToken); - }); -}); diff --git a/spec/frontend/runner/components/runner_state_paused_badge_spec.js b/spec/frontend/runner/components/runner_paused_badge_spec.js index 8df56d6e3f3..18cfcfae864 100644 --- a/spec/frontend/runner/components/runner_state_paused_badge_spec.js +++ b/spec/frontend/runner/components/runner_paused_badge_spec.js @@ -1,6 +1,6 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import RunnerStatePausedBadge from '~/runner/components/runner_state_paused_badge.vue'; +import RunnerStatePausedBadge from '~/runner/components/runner_paused_badge.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; describe('RunnerTypeBadge', () => { diff --git a/spec/frontend/runner/components/runner_state_locked_badge_spec.js b/spec/frontend/runner/components/runner_state_locked_badge_spec.js deleted file mode 100644 index e92b671f5a1..00000000000 --- a/spec/frontend/runner/components/runner_state_locked_badge_spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import RunnerStateLockedBadge from '~/runner/components/runner_state_locked_badge.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - -describe('RunnerTypeBadge', () => { - let wrapper; - - const findBadge = () => wrapper.findComponent(GlBadge); - const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(RunnerStateLockedBadge, { - propsData: { - ...props, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders locked state', () => { - expect(wrapper.text()).toBe('locked'); - expect(findBadge().props('variant')).toBe('warning'); - }); - - it('renders tooltip', () => { - expect(getTooltip().value).toBeDefined(); - }); - - it('passes arbitrary attributes to the badge', () => { - createComponent({ props: { size: 'sm' } }); - - expect(findBadge().props('size')).toBe('sm'); - }); -}); diff --git a/spec/frontend/runner/components/runner_tag_spec.js b/spec/frontend/runner/components/runner_tag_spec.js index dda318f8153..bd05d4b2cfe 100644 --- a/spec/frontend/runner/components/runner_tag_spec.js +++ b/spec/frontend/runner/components/runner_tag_spec.js @@ -1,18 +1,35 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import RunnerTag from '~/runner/components/runner_tag.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +const mockTag = 'tag1'; describe('RunnerTag', () => { let wrapper; const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltipValue = () => getBinding(findBadge().element, 'gl-tooltip').value; + + const setDimensions = ({ scrollWidth, offsetWidth }) => { + jest.spyOn(findBadge().element, 'scrollWidth', 'get').mockReturnValue(scrollWidth); + jest.spyOn(findBadge().element, 'offsetWidth', 'get').mockReturnValue(offsetWidth); + + // Mock trigger resize + getBinding(findBadge().element, 'gl-resize-observer').value(); + }; const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(RunnerTag, { propsData: { - tag: 'tag1', + tag: mockTag, ...props, }, + directives: { + GlTooltip: createMockDirective(), + GlResizeObserver: createMockDirective(), + }, }); }; @@ -25,21 +42,36 @@ describe('RunnerTag', () => { }); it('Displays tag text', () => { - expect(wrapper.text()).toBe('tag1'); + expect(wrapper.text()).toBe(mockTag); }); it('Displays tags with correct style', () => { expect(findBadge().props()).toMatchObject({ - size: 'md', - variant: 'info', + size: 'sm', + variant: 'neutral', }); }); - it('Displays tags with small size', () => { + it('Displays tags with md size', () => { createComponent({ - props: { size: 'sm' }, + props: { size: 'md' }, }); - expect(findBadge().props('size')).toBe('sm'); + expect(findBadge().props('size')).toBe('md'); }); + + it.each` + case | scrollWidth | offsetWidth | expectedTooltip + ${'overflowing'} | ${110} | ${100} | ${mockTag} + ${'not overflowing'} | ${90} | ${100} | ${''} + ${'almost overflowing'} | ${100} | ${100} | ${''} + `( + 'Sets "$expectedTooltip" as tooltip when $case', + async ({ scrollWidth, offsetWidth, expectedTooltip }) => { + setDimensions({ scrollWidth, offsetWidth }); + await nextTick(); + + expect(getTooltipValue()).toBe(expectedTooltip); + }, + ); }); diff --git a/spec/frontend/runner/components/runner_tags_spec.js b/spec/frontend/runner/components/runner_tags_spec.js index b6487ade0d6..da89a659432 100644 --- a/spec/frontend/runner/components/runner_tags_spec.js +++ b/spec/frontend/runner/components/runner_tags_spec.js @@ -33,16 +33,16 @@ describe('RunnerTags', () => { }); it('Displays tags with correct style', () => { - expect(findBadge().props('size')).toBe('md'); - expect(findBadge().props('variant')).toBe('info'); + expect(findBadge().props('size')).toBe('sm'); + expect(findBadge().props('variant')).toBe('neutral'); }); - it('Displays tags with small size', () => { + it('Displays tags with md size', () => { createComponent({ - props: { size: 'sm' }, + props: { size: 'md' }, }); - expect(findBadge().props('size')).toBe('sm'); + expect(findBadge().props('size')).toBe('md'); }); it('Is empty when there are no tags', () => { diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js index e54e499743b..4023c75c9a8 100644 --- a/spec/frontend/runner/components/runner_type_alert_spec.js +++ b/spec/frontend/runner/components/runner_type_alert_spec.js @@ -23,11 +23,11 @@ describe('RunnerTypeAlert', () => { }); describe.each` - type | exampleText | anchor | variant - ${INSTANCE_TYPE} | ${'This runner is available to all groups and projects'} | ${'#shared-runners'} | ${'success'} - ${GROUP_TYPE} | ${'This runner is available to all projects and subgroups in a group'} | ${'#group-runners'} | ${'success'} - ${PROJECT_TYPE} | ${'This runner is associated with one or more projects'} | ${'#specific-runners'} | ${'info'} - `('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => { + type | exampleText | anchor + ${INSTANCE_TYPE} | ${'This runner is available to all groups and projects'} | ${'#shared-runners'} + ${GROUP_TYPE} | ${'This runner is available to all projects and subgroups in a group'} | ${'#group-runners'} + ${PROJECT_TYPE} | ${'This runner is associated with one or more projects'} | ${'#specific-runners'} + `('When it is an $type level runner', ({ type, exampleText, anchor }) => { beforeEach(() => { createComponent({ props: { type } }); }); @@ -36,8 +36,8 @@ describe('RunnerTypeAlert', () => { expect(wrapper.text()).toMatch(exampleText); }); - it(`Shows a ${variant} variant`, () => { - expect(findAlert().props('variant')).toBe(variant); + it(`Shows an "info" variant`, () => { + expect(findAlert().props('variant')).toBe('info'); }); it(`Links to anchor "${anchor}"`, () => { diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js index fb344e65389..7bb0a2e6e2f 100644 --- a/spec/frontend/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/runner/components/runner_type_badge_spec.js @@ -26,18 +26,18 @@ describe('RunnerTypeBadge', () => { }); describe.each` - type | text | variant - ${INSTANCE_TYPE} | ${'shared'} | ${'success'} - ${GROUP_TYPE} | ${'group'} | ${'success'} - ${PROJECT_TYPE} | ${'specific'} | ${'info'} - `('displays $type runner', ({ type, text, variant }) => { + type | text + ${INSTANCE_TYPE} | ${'shared'} + ${GROUP_TYPE} | ${'group'} + ${PROJECT_TYPE} | ${'specific'} + `('displays $type runner', ({ type, text }) => { beforeEach(() => { createComponent({ props: { type } }); }); - it(`as "${text}" with a ${variant} variant`, () => { + it(`as "${text}" with an "info" variant`, () => { expect(findBadge().text()).toBe(text); - expect(findBadge().props('variant')).toBe(variant); + expect(findBadge().props('variant')).toBe('info'); }); it('with a tooltip', () => { diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js new file mode 100644 index 00000000000..4871d9c470a --- /dev/null +++ b/spec/frontend/runner/components/runner_type_tabs_spec.js @@ -0,0 +1,109 @@ +import { GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; +import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants'; + +const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }; + +describe('RunnerTypeTabs', () => { + let wrapper; + + const findTabs = () => wrapper.findAll(GlTab); + const findActiveTab = () => + findTabs() + .filter((tab) => tab.attributes('active') === 'true') + .at(0); + + const createComponent = ({ props, ...options } = {}) => { + wrapper = shallowMount(RunnerTypeTabs, { + propsData: { + value: mockSearch, + ...props, + }, + stubs: { + GlTab, + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Renders options to filter runners', () => { + expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([ + 'All', + 'Instance', + 'Group', + 'Project', + ]); + }); + + it('"All" is selected by default', () => { + expect(findActiveTab().text()).toBe('All'); + }); + + it('Another tab can be preselected by the user', () => { + createComponent({ + props: { + value: { + ...mockSearch, + runnerType: INSTANCE_TYPE, + }, + }, + }); + + expect(findActiveTab().text()).toBe('Instance'); + }); + + describe('When the user selects a tab', () => { + const emittedValue = () => wrapper.emitted('input')[0][0]; + + beforeEach(() => { + findTabs().at(2).vm.$emit('click'); + }); + + it(`Runner type is emitted`, () => { + expect(emittedValue()).toEqual({ + ...mockSearch, + runnerType: GROUP_TYPE, + }); + }); + + it('Runner type is selected', async () => { + const newValue = emittedValue(); + await wrapper.setProps({ value: newValue }); + + expect(findActiveTab().text()).toBe('Group'); + }); + }); + + describe('When using a custom slot', () => { + const mockContent = 'content'; + + beforeEach(() => { + createComponent({ + scopedSlots: { + title: ` + <span> + {{props.tab.title}} ${mockContent} + </span>`, + }, + }); + }); + + it('Renders tabs with additional information', () => { + expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([ + `All ${mockContent}`, + `Instance ${mockContent}`, + `Group ${mockContent}`, + `Project ${mockContent}`, + ]); + }); + }); +}); 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 5f3aabd4bc3..39bca743c80 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlLink } from '@gitlab/ui'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; @@ -11,7 +12,7 @@ import { updateHistory } from '~/lib/utils/url_utility'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; -import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; import { @@ -19,8 +20,8 @@ import { CREATED_DESC, DEFAULT_SORT, INSTANCE_TYPE, + GROUP_TYPE, PARAM_KEY_STATUS, - PARAM_KEY_RUNNER_TYPE, STATUS_ACTIVE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; @@ -48,7 +49,7 @@ describe('GroupRunnersApp', () => { let wrapper; let mockGroupRunnersQuery; - const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPaginationPrev = () => @@ -82,13 +83,13 @@ describe('GroupRunnersApp', () => { }); it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); + expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); }); it('shows the runners list', () => { - expect(findRunnerList().props('runners')).toEqual( - groupRunnersData.data.group.runners.edges.map(({ node }) => node), - ); + const runners = findRunnerList().props('runners'); + expect(runners).toEqual(groupRunnersData.data.group.runners.edges.map(({ node }) => node)); }); it('runner item links to the runner group page', async () => { @@ -117,16 +118,15 @@ describe('GroupRunnersApp', () => { it('sets tokens in the filtered search', () => { createComponent({ mountFn: mount }); - expect(findFilteredSearch().props('tokens')).toEqual([ + const tokens = findFilteredSearch().props('tokens'); + + expect(tokens).toHaveLength(1); + expect(tokens[0]).toEqual( expect.objectContaining({ type: PARAM_KEY_STATUS, options: expect.any(Array), }), - expect.objectContaining({ - type: PARAM_KEY_RUNNER_TYPE, - options: expect.any(Array), - }), - ]); + ); }); describe('shows the active runner count', () => { @@ -161,10 +161,8 @@ describe('GroupRunnersApp', () => { it('sets the filters in the search bar', () => { expect(findRunnerFilteredSearchBar().props('value')).toEqual({ - filters: [ - { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, - { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, - ], + runnerType: INSTANCE_TYPE, + filters: [{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }], sort: 'CREATED_DESC', pagination: { page: 1 }, }); @@ -182,11 +180,14 @@ describe('GroupRunnersApp', () => { }); describe('when a filter is selected by the user', () => { - beforeEach(() => { + beforeEach(async () => { findRunnerFilteredSearchBar().vm.$emit('input', { + runnerType: null, filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], sort: CREATED_ASC, }); + + await nextTick(); }); it('updates the browser url', () => { diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index 3a0c3abe7bd..0fc7917663e 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -1,5 +1,6 @@ import { RUNNER_PAGE_SIZE } from '~/runner/constants'; import { + searchValidator, fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, @@ -10,13 +11,14 @@ describe('search_params.js', () => { { name: 'a default query', urlQuery: '', - search: { filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, + search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, }, { name: 'a single status', urlQuery: '?status[]=ACTIVE', search: { + runnerType: null, filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], pagination: { page: 1 }, sort: 'CREATED_DESC', @@ -27,6 +29,7 @@ describe('search_params.js', () => { name: 'a single term text search', urlQuery: '?search=something', search: { + runnerType: null, filters: [ { type: 'filtered-search-term', @@ -42,6 +45,7 @@ describe('search_params.js', () => { name: 'a two terms text search', urlQuery: '?search=something+else', search: { + runnerType: null, filters: [ { type: 'filtered-search-term', @@ -61,7 +65,8 @@ describe('search_params.js', () => { name: 'single instance type', urlQuery: '?runner_type[]=INSTANCE_TYPE', search: { - filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }], + runnerType: 'INSTANCE_TYPE', + filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC', }, @@ -71,6 +76,7 @@ describe('search_params.js', () => { name: 'multiple runner status', urlQuery: '?status[]=ACTIVE&status[]=PAUSED', search: { + runnerType: null, filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'status', value: { data: 'PAUSED', operator: '=' } }, @@ -84,10 +90,8 @@ describe('search_params.js', () => { name: 'multiple status, a single instance type and a non default sort', urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', search: { - filters: [ - { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, - { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, - ], + runnerType: 'INSTANCE_TYPE', + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], pagination: { page: 1 }, sort: 'CREATED_ASC', }, @@ -102,6 +106,7 @@ describe('search_params.js', () => { name: 'a tag', urlQuery: '?tag[]=tag-1', search: { + runnerType: null, filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], pagination: { page: 1 }, sort: 'CREATED_DESC', @@ -116,6 +121,7 @@ describe('search_params.js', () => { name: 'two tags', urlQuery: '?tag[]=tag-1&tag[]=tag-2', search: { + runnerType: null, filters: [ { type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } }, @@ -132,13 +138,19 @@ describe('search_params.js', () => { { name: 'the next page', urlQuery: '?page=2&after=AFTER_CURSOR', - search: { filters: [], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC' }, + search: { + runnerType: null, + filters: [], + pagination: { page: 2, after: 'AFTER_CURSOR' }, + sort: 'CREATED_DESC', + }, graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, }, { name: 'the previous page', urlQuery: '?page=2&before=BEFORE_CURSOR', search: { + runnerType: null, filters: [], pagination: { page: 2, before: 'BEFORE_CURSOR' }, sort: 'CREATED_DESC', @@ -150,9 +162,9 @@ describe('search_params.js', () => { urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', search: { + runnerType: 'INSTANCE_TYPE', filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, - { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, { type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } }, ], @@ -170,6 +182,14 @@ describe('search_params.js', () => { }, ]; + describe('searchValidator', () => { + examples.forEach(({ name, search }) => { + it(`Validates ${name} as a search object`, () => { + expect(searchValidator(search)).toBe(true); + }); + }); + }); + describe('fromUrlQueryToSearch', () => { examples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a search object`, () => { |