diff options
Diffstat (limited to 'spec/frontend/ci/runner/components/runner_update_form_spec.js')
-rw-r--r-- | spec/frontend/ci/runner/components/runner_update_form_spec.js | 288 |
1 files changed, 288 insertions, 0 deletions
diff --git a/spec/frontend/ci/runner/components/runner_update_form_spec.js b/spec/frontend/ci/runner/components/runner_update_form_spec.js new file mode 100644 index 00000000000..a0e51ebf958 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_update_form_spec.js @@ -0,0 +1,288 @@ +import Vue, { nextTick } from 'vue'; +import { GlForm, GlSkeletonLoader } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { __ } from '~/locale'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import RunnerUpdateForm from '~/ci/runner/components/runner_update_form.vue'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + ACCESS_LEVEL_REF_PROTECTED, + ACCESS_LEVEL_NOT_PROTECTED, +} from '~/ci/runner/constants'; +import runnerUpdateMutation from '~/ci/runner/graphql/edit/runner_update.mutation.graphql'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; +import { runnerFormData } from '../mock_data'; + +jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/flash'); +jest.mock('~/ci/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility'); + +const mockRunner = runnerFormData.data.runner; +const mockRunnerPath = '/admin/runners/1'; + +Vue.use(VueApollo); + +describe('RunnerUpdateForm', () => { + let wrapper; + let runnerUpdateHandler; + + const findForm = () => wrapper.findComponent(GlForm); + const findPausedCheckbox = () => wrapper.findByTestId('runner-field-paused'); + const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected'); + const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged'); + const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked'); + const findFields = () => wrapper.findAll('[data-testid^="runner-field"'); + + const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input'); + const findMaxJobTimeoutInput = () => + wrapper.findByTestId('runner-field-max-timeout').find('input'); + const findTagsInput = () => wrapper.findByTestId('runner-field-tags').find('input'); + + const findSubmit = () => wrapper.find('[type="submit"]'); + const findSubmitDisabledAttr = () => findSubmit().attributes('disabled'); + const findCancelBtn = () => wrapper.findByRole('link', { name: __('Cancel') }); + const submitForm = () => findForm().trigger('submit'); + const submitFormAndWait = () => submitForm().then(waitForPromises); + + const getFieldsModel = () => ({ + active: !findPausedCheckbox().element.checked, + accessLevel: findProtectedCheckbox().element.checked + ? ACCESS_LEVEL_REF_PROTECTED + : ACCESS_LEVEL_NOT_PROTECTED, + runUntagged: findRunUntaggedCheckbox().element.checked, + locked: findLockedCheckbox().element?.checked || false, + maximumTimeout: findMaxJobTimeoutInput().element.value || null, + tagList: findTagsInput().element.value.split(',').filter(Boolean), + }); + + const createComponent = ({ props } = {}) => { + wrapper = mountExtended(RunnerUpdateForm, { + propsData: { + runner: mockRunner, + runnerPath: mockRunnerPath, + ...props, + }, + apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]), + }); + }; + + const expectToHaveSubmittedRunnerContaining = (submittedRunner) => { + expect(runnerUpdateHandler).toHaveBeenCalledTimes(1); + expect(runnerUpdateHandler).toHaveBeenCalledWith({ + input: expect.objectContaining(submittedRunner), + }); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.any(String), + variant: VARIANT_SUCCESS, + }), + ); + expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath); + }; + + beforeEach(() => { + runnerUpdateHandler = jest.fn().mockImplementation(({ input }) => { + return Promise.resolve({ + data: { + runnerUpdate: { + runner: { + ...mockRunner, + ...input, + }, + errors: [], + }, + }, + }); + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Form has a submit button', () => { + expect(findSubmit().exists()).toBe(true); + }); + + it('Form fields match data', () => { + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + + it('Form shows a cancel button', () => { + expect(runnerUpdateHandler).not.toHaveBeenCalled(); + expect(findCancelBtn().attributes('href')).toBe(mockRunnerPath); + }); + + it('Form prevent multiple submissions', async () => { + await submitForm(); + + expect(findSubmitDisabledAttr()).toBe('disabled'); + }); + + it('Updates runner with no changes', async () => { + await submitFormAndWait(); + + // Some read-only fields are not submitted + const { __typename, shortSha, runnerType, createdAt, status, ...submitted } = mockRunner; + + expectToHaveSubmittedRunnerContaining(submitted); + }); + + describe('When data is being loaded', () => { + beforeEach(() => { + createComponent({ props: { loading: true } }); + }); + + it('Form skeleton is shown', () => { + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); + expect(findFields()).toHaveLength(0); + }); + + it('Form cannot be submitted', () => { + expect(findSubmit().props('loading')).toBe(true); + }); + + it('Form is updated when data loads', async () => { + wrapper.setProps({ + loading: false, + }); + + await nextTick(); + + expect(findFields()).not.toHaveLength(0); + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + }); + + it.each` + runnerType | exists | outcome + ${INSTANCE_TYPE} | ${false} | ${'hidden'} + ${GROUP_TYPE} | ${false} | ${'hidden'} + ${PROJECT_TYPE} | ${true} | ${'shown'} + `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, exists }) => { + const runner = { ...mockRunner, runnerType }; + createComponent({ props: { runner } }); + + expect(findLockedCheckbox().exists()).toBe(exists); + }); + + describe('On submit, runner gets updated', () => { + it.each` + test | initialValue | findCheckbox | checked | submitted + ${'pauses'} | ${{ active: true }} | ${findPausedCheckbox} | ${true} | ${{ active: false }} + ${'activates'} | ${{ active: false }} | ${findPausedCheckbox} | ${false} | ${{ active: true }} + ${'unprotects'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} | ${findProtectedCheckbox} | ${true} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} + ${'protects'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} | ${findProtectedCheckbox} | ${false} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} + ${'"runs untagged jobs"'} | ${{ runUntagged: true }} | ${findRunUntaggedCheckbox} | ${false} | ${{ runUntagged: false }} + ${'"runs tagged jobs"'} | ${{ runUntagged: false }} | ${findRunUntaggedCheckbox} | ${true} | ${{ runUntagged: true }} + ${'locks'} | ${{ runnerType: PROJECT_TYPE, locked: true }} | ${findLockedCheckbox} | ${false} | ${{ locked: false }} + ${'unlocks'} | ${{ runnerType: PROJECT_TYPE, locked: false }} | ${findLockedCheckbox} | ${true} | ${{ locked: true }} + `('Checkbox $test runner', async ({ initialValue, findCheckbox, checked, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findCheckbox().setChecked(checked); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + test | initialValue | findInput | value | submitted + ${'description'} | ${{ description: 'Desc. 1' }} | ${findDescriptionInput} | ${'Desc. 2'} | ${{ description: 'Desc. 2' }} + ${'max timeout'} | ${{ maximumTimeout: 36000 }} | ${findMaxJobTimeoutInput} | ${'40000'} | ${{ maximumTimeout: 40000 }} + ${'tags'} | ${{ tagList: ['tag1'] }} | ${findTagsInput} | ${'tag2, tag3'} | ${{ tagList: ['tag2', 'tag3'] }} + `("Field updates runner's $test", async ({ initialValue, findInput, value, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + value | submitted + ${''} | ${{ tagList: [] }} + ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} + ${'with spaces'} | ${{ tagList: ['with spaces'] }} + ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }} + `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => { + const runner = { ...mockRunner, tagList: ['tag1'] }; + createComponent({ props: { runner } }); + + await findTagsInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + }); + + describe('On error', () => { + beforeEach(() => { + createComponent(); + }); + + it('On network error, error message is shown', async () => { + const mockErrorMsg = 'Update error!'; + + runnerUpdateHandler.mockRejectedValue(new Error(mockErrorMsg)); + + await submitFormAndWait(); + + expect(createAlert).toHaveBeenLastCalledWith({ + message: mockErrorMsg, + }); + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerUpdateForm', + error: new Error(mockErrorMsg), + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + }); + + it('On validation error, error message is shown and it is not sent to sentry', async () => { + const mockErrorMsg = 'Invalid value!'; + + runnerUpdateHandler.mockResolvedValue({ + data: { + runnerUpdate: { + runner: mockRunner, + errors: [mockErrorMsg], + }, + }, + }); + + await submitFormAndWait(); + + expect(createAlert).toHaveBeenLastCalledWith({ + message: mockErrorMsg, + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + + expect(captureException).not.toHaveBeenCalled(); + expect(saveAlertToLocalStorage).not.toHaveBeenCalled(); + expect(redirectTo).not.toHaveBeenCalled(); + }); + }); +}); |