diff options
Diffstat (limited to 'spec/frontend/ci')
26 files changed, 3250 insertions, 37 deletions
diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js new file mode 100644 index 00000000000..e4abedb412f --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -0,0 +1,159 @@ +import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import VariableList from '~/ci/ci_variable_list/ci_variable_list'; + +const HIDE_CLASS = 'hide'; + +describe('VariableList', () => { + let $wrapper; + let variableList; + + describe('with only key/value inputs', () => { + describe('with no variables', () => { + beforeEach(() => { + loadHTMLFixture('pipeline_schedules/edit.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'schedule', + }); + variableList.init(); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('should remove the row when clicking the remove button', () => { + $wrapper.find('.js-row-remove-button').trigger('click'); + + expect($wrapper.find('.js-row').length).toBe(0); + }); + + it('should add another row when editing the last rows key input', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-key').val('foo').trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); + + expect($keyInput.val()).toBe(''); + }); + + it('should add another row when editing the last rows value textarea', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-value').val('foo').trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); + + expect($valueInput.val()).toBe(''); + }); + + it('should remove empty row after blurring', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-key').val('foo').trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + $row.find('.js-ci-variable-input-key').val('').trigger('input').trigger('blur'); + + expect($wrapper.find('.js-row').length).toBe(1); + }); + }); + + describe('with persisted variables', () => { + beforeEach(() => { + loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'schedule', + }); + variableList.init(); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('should have "Reveal values" button initially when there are already variables', () => { + expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values'); + }); + + it('should reveal hidden values', () => { + const $row = $wrapper.find('.js-row:first-child'); + const $inputValue = $row.find('.js-ci-variable-input-value'); + const $placeholder = $row.find('.js-secret-value-placeholder'); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(false); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(true); + + // Reveal values + $wrapper.find('.js-secret-value-reveal-button').click(); + + expect($placeholder.hasClass(HIDE_CLASS)).toBe(true); + expect($inputValue.hasClass(HIDE_CLASS)).toBe(false); + }); + }); + }); + + describe('toggleEnableRow method', () => { + beforeEach(() => { + loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'variables', + }); + variableList.init(); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('should disable all key inputs', () => { + expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); + + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); + }); + + it('should disable all remove buttons', () => { + expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); + + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); + }); + + it('should enable all remove buttons', () => { + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); + + variableList.toggleEnableRow(true); + + expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); + }); + + it('should enable all key inputs', () => { + variableList.toggleEnableRow(false); + + expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); + + variableList.toggleEnableRow(true); + + expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js new file mode 100644 index 00000000000..71e8e6d3afb --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js @@ -0,0 +1,40 @@ +import $ from 'jquery'; +import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import setupNativeFormVariableList from '~/ci/ci_variable_list/native_form_variable_list'; + +describe('NativeFormVariableList', () => { + let $wrapper; + + beforeEach(() => { + loadHTMLFixture('pipeline_schedules/edit.html'); + $wrapper = $('.js-ci-variable-list-section'); + + setupNativeFormVariableList({ + container: $wrapper, + formField: 'schedule', + }); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + describe('onFormSubmit', () => { + it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { + const $row = $wrapper.find('.js-row'); + + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe( + 'schedule[variables_attributes][][key]', + ); + + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe( + 'schedule[variables_attributes][][secret_value]', + ); + + $wrapper.closest('form').trigger('trigger-submit'); + + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe(''); + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe(''); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js new file mode 100644 index 00000000000..5e0c35c9f90 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js @@ -0,0 +1,36 @@ +import { shallowMount } from '@vue/test-utils'; + +import ciAdminVariables from '~/ci/ci_variable_list/components/ci_admin_variables.vue'; +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; + +describe('Ci Project Variable wrapper', () => { + let wrapper; + + const findCiShared = () => wrapper.findComponent(ciVariableShared); + + const createComponent = () => { + wrapper = shallowMount(ciAdminVariables); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Passes down the correct props to ci_variable_shared', () => { + expect(findCiShared().props()).toEqual({ + areScopedVariablesAvailable: false, + componentName: 'InstanceVariables', + entity: '', + hideEnvironmentScope: true, + mutationData: wrapper.vm.$options.mutationData, + queryData: wrapper.vm.$options.queryData, + refetchAfterMutation: true, + fullPath: null, + id: null, + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js new file mode 100644 index 00000000000..2fd395a1230 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -0,0 +1,118 @@ +import { GlListboxItem, GlCollapsibleListbox, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { allEnvironments } from '~/ci/ci_variable_list/constants'; +import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; + +describe('Ci environments dropdown', () => { + let wrapper; + + const envs = ['dev', 'prod', 'staging']; + const defaultProps = { environments: envs, selectedEnvironmentScope: '' }; + + const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const findListboxItemByIndex = (index) => wrapper.findAllComponents(GlListboxItem).at(index); + const findActiveIconByIndex = (index) => findListboxItemByIndex(index).findComponent(GlIcon); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxText = () => findListbox().props('toggleText'); + const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem); + + const createComponent = ({ props = {}, searchTerm = '' } = {}) => { + wrapper = mount(CiEnvironmentsDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + }); + + findListbox().vm.$emit('search', searchTerm); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('No environments found', () => { + beforeEach(() => { + createComponent({ searchTerm: 'stable' }); + }); + + it('renders create button with search term if environments do not contain search term', () => { + const button = findCreateWildcardButton(); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Create wildcard: stable'); + }); + }); + + describe('Search term is empty', () => { + beforeEach(() => { + createComponent({ props: { environments: envs } }); + }); + + it('renders all environments when search term is empty', () => { + expect(findListboxItemByIndex(0).text()).toBe(envs[0]); + expect(findListboxItemByIndex(1).text()).toBe(envs[1]); + expect(findListboxItemByIndex(2).text()).toBe(envs[2]); + }); + + it('does not display active checkmark on the inactive stage', () => { + expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); + }); + }); + + describe('when `*` is the value of selectedEnvironmentScope props', () => { + const wildcardScope = '*'; + + beforeEach(() => { + createComponent({ props: { selectedEnvironmentScope: wildcardScope } }); + }); + + it('shows the `All environments` text and not the wildcard', () => { + expect(findListboxText()).toContain(allEnvironments.text); + expect(findListboxText()).not.toContain(wildcardScope); + }); + }); + + describe('Environments found', () => { + const currentEnv = envs[2]; + + beforeEach(() => { + createComponent({ searchTerm: currentEnv }); + }); + + it('renders only the environment searched for', () => { + expect(findAllListboxItems()).toHaveLength(1); + expect(findListboxItemByIndex(0).text()).toBe(currentEnv); + }); + + it('does not display create button', () => { + expect(findCreateWildcardButton().exists()).toBe(false); + }); + + describe('Custom events', () => { + describe('when selecting an environment', () => { + const itemIndex = 0; + + beforeEach(() => { + createComponent(); + }); + + it('emits `select-environment` when an environment is clicked', () => { + findListbox().vm.$emit('select', envs[itemIndex]); + expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]); + }); + }); + + describe('when creating a new environment from a search term', () => { + const search = 'new-env'; + beforeEach(() => { + createComponent({ searchTerm: search }); + }); + + it('emits create-environment-scope', () => { + findCreateWildcardButton().vm.$emit('click'); + expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js new file mode 100644 index 00000000000..3f1eebbc6a5 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js @@ -0,0 +1,73 @@ +import { shallowMount } from '@vue/test-utils'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; + +import ciGroupVariables from '~/ci/ci_variable_list/components/ci_group_variables.vue'; +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; + +import { GRAPHQL_GROUP_TYPE } from '~/ci/ci_variable_list/constants'; + +const mockProvide = { + glFeatures: { + groupScopedCiVariables: false, + }, + groupPath: '/group', + groupId: 12, +}; + +describe('Ci Group Variable wrapper', () => { + let wrapper; + + const findCiShared = () => wrapper.findComponent(ciVariableShared); + + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMount(ciGroupVariables, { + provide: { ...mockProvide, ...provide }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Props', () => { + beforeEach(() => { + createComponent(); + }); + + it('are passed down the correctly to ci_variable_shared', () => { + expect(findCiShared().props()).toEqual({ + id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, mockProvide.groupId), + areScopedVariablesAvailable: false, + componentName: 'GroupVariables', + entity: 'group', + fullPath: mockProvide.groupPath, + hideEnvironmentScope: false, + mutationData: wrapper.vm.$options.mutationData, + queryData: wrapper.vm.$options.queryData, + refetchAfterMutation: false, + }); + }); + }); + + describe('feature flag', () => { + describe('When enabled', () => { + beforeEach(() => { + createComponent({ provide: { glFeatures: { groupScopedCiVariables: true } } }); + }); + + it('Passes down `true` to variable shared component', () => { + expect(findCiShared().props('areScopedVariablesAvailable')).toBe(true); + }); + }); + + describe('When disabled', () => { + beforeEach(() => { + createComponent({ provide: { glFeatures: { groupScopedCiVariables: false } } }); + }); + + it('Passes down `false` to variable shared component', () => { + expect(findCiShared().props('areScopedVariablesAvailable')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js new file mode 100644 index 00000000000..7230017c560 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; + +import ciProjectVariables from '~/ci/ci_variable_list/components/ci_project_variables.vue'; +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; + +import { GRAPHQL_PROJECT_TYPE } from '~/ci/ci_variable_list/constants'; + +const mockProvide = { + projectFullPath: '/namespace/project', + projectId: 1, +}; + +describe('Ci Project Variable wrapper', () => { + let wrapper; + + const findCiShared = () => wrapper.findComponent(ciVariableShared); + + const createComponent = () => { + wrapper = shallowMount(ciProjectVariables, { + provide: mockProvide, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Passes down the correct props to ci_variable_shared', () => { + expect(findCiShared().props()).toEqual({ + id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, mockProvide.projectId), + areScopedVariablesAvailable: true, + componentName: 'ProjectVariables', + entity: 'project', + fullPath: mockProvide.projectFullPath, + hideEnvironmentScope: false, + mutationData: wrapper.vm.$options.mutationData, + queryData: wrapper.vm.$options.queryData, + refetchAfterMutation: false, + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js new file mode 100644 index 00000000000..7838e4884d8 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -0,0 +1,520 @@ +import { GlButton, GlFormInput } from '@gitlab/ui'; +import { mockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; +import CiVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue'; +import { + ADD_VARIABLE_ACTION, + AWS_ACCESS_KEY_ID, + EDIT_VARIABLE_ACTION, + EVENT_LABEL, + EVENT_ACTION, + ENVIRONMENT_SCOPE_LINK_TITLE, + instanceString, + variableOptions, +} from '~/ci/ci_variable_list/constants'; +import { mockVariablesWithScopes } from '../mocks'; +import ModalStub from '../stubs'; + +describe('Ci variable modal', () => { + let wrapper; + let trackingSpy; + + const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$'; + const mockVariables = mockVariablesWithScopes(instanceString); + + const defaultProvide = { + awsLogoSvgPath: '/logo', + awsTipCommandsLink: '/tips', + awsTipDeployLink: '/deploy', + awsTipLearnLink: '/learn-link', + containsVariableReferenceLink: '/reference', + environmentScopeLink: '/help/environments', + isProtectedByDefault: false, + maskedEnvironmentVariablesLink: '/variables-link', + maskableRegex, + protectedEnvironmentVariablesLink: '/protected-link', + }; + + const defaultProps = { + areScopedVariablesAvailable: true, + environments: [], + hideEnvironmentScope: false, + mode: ADD_VARIABLE_ACTION, + selectedVariable: {}, + variable: [], + }; + + const createComponent = ({ mountFn = shallowMountExtended, props = {}, provide = {} } = {}) => { + wrapper = mountFn(CiVariableModal, { + attachTo: document.body, + provide: { ...defaultProvide, ...provide }, + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlModal: ModalStub, + }, + }); + }; + + const findCiEnvironmentsDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown); + const findReferenceWarning = () => wrapper.findByTestId('contains-variable-reference'); + const findModal = () => wrapper.findComponent(ModalStub); + const findAWSTip = () => wrapper.findByTestId('aws-guidance-tip'); + const findAddorUpdateButton = () => wrapper.findByTestId('ciUpdateOrAddVariableBtn'); + const deleteVariableButton = () => + findModal() + .findAllComponents(GlButton) + .wrappers.find((button) => button.props('variant') === 'danger'); + const findExpandedVariableCheckbox = () => wrapper.findByTestId('ci-variable-expanded-checkbox'); + const findProtectedVariableCheckbox = () => + wrapper.findByTestId('ci-variable-protected-checkbox'); + const findMaskedVariableCheckbox = () => wrapper.findByTestId('ci-variable-masked-checkbox'); + const findValueField = () => wrapper.find('#ci-variable-value'); + const findEnvScopeLink = () => wrapper.findByTestId('environment-scope-link'); + const findEnvScopeInput = () => + wrapper.findByTestId('environment-scope').findComponent(GlFormInput); + const findRawVarTip = () => wrapper.findByTestId('raw-variable-tip'); + const findVariableTypeDropdown = () => wrapper.find('#ci-variable-type'); + const findEnvironmentScopeText = () => wrapper.findByText('Environment scope'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Adding a variable', () => { + describe('when no key/value pair are present', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows the submit button as disabled', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('when a key/value pair is present', () => { + beforeEach(() => { + createComponent({ props: { selectedVariable: mockVariables[0] } }); + }); + + it('shows the submit button as enabled', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); + }); + }); + + describe('events', () => { + const [currentVariable] = mockVariables; + + beforeEach(() => { + createComponent({ props: { selectedVariable: currentVariable } }); + jest.spyOn(wrapper.vm, '$emit'); + }); + + it('Dispatches `add-variable` action on submit', () => { + findAddorUpdateButton().vm.$emit('click'); + expect(wrapper.emitted('add-variable')).toEqual([[currentVariable]]); + }); + + it('Dispatches the `hideModal` event when dismissing', () => { + findModal().vm.$emit('hidden'); + expect(wrapper.emitted('hideModal')).toEqual([[]]); + }); + }); + }); + + describe('when protected by default', () => { + describe('when adding a new variable', () => { + beforeEach(() => { + createComponent({ provide: { isProtectedByDefault: true } }); + findModal().vm.$emit('shown'); + }); + + it('updates the protected value to true', () => { + expect(findProtectedVariableCheckbox().attributes('data-is-protected-checked')).toBe( + 'true', + ); + }); + }); + + describe('when editing a variable', () => { + beforeEach(() => { + createComponent({ + provide: { isProtectedByDefault: false }, + props: { + selectedVariable: {}, + mode: EDIT_VARIABLE_ACTION, + }, + }); + findModal().vm.$emit('shown'); + }); + + it('keeps the value as false', async () => { + expect( + findProtectedVariableCheckbox().attributes('data-is-protected-checked'), + ).toBeUndefined(); + }); + }); + }); + + describe('Adding a new non-AWS variable', () => { + beforeEach(() => { + const [variable] = mockVariables; + createComponent({ mountFn: mountExtended, props: { selectedVariable: variable } }); + }); + + it('does not show AWS guidance tip', () => { + const tip = findAWSTip(); + expect(tip.exists()).toBe(true); + expect(tip.isVisible()).toBe(false); + }); + }); + + describe('Adding a new AWS variable', () => { + beforeEach(() => { + const [variable] = mockVariables; + const AWSKeyVariable = { + ...variable, + key: AWS_ACCESS_KEY_ID, + value: 'AKIAIOSFODNN7EXAMPLEjdhy', + }; + createComponent({ mountFn: mountExtended, props: { selectedVariable: AWSKeyVariable } }); + }); + + it('shows AWS guidance tip', () => { + const tip = findAWSTip(); + expect(tip.exists()).toBe(true); + expect(tip.isVisible()).toBe(true); + }); + }); + + describe('when expanded', () => { + describe('with a $ character', () => { + beforeEach(() => { + const [variable] = mockVariables; + const variableWithDollarSign = { + ...variable, + value: 'valueWith$', + }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: variableWithDollarSign }, + }); + }); + + it(`renders the variable reference warning`, () => { + expect(findReferenceWarning().exists()).toBe(true); + }); + + it(`does not render raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(false); + }); + }); + + describe('without a $ character', () => { + beforeEach(() => { + const [variable] = mockVariables; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: variable }, + }); + }); + + it(`does not render the variable reference warning`, () => { + expect(findReferenceWarning().exists()).toBe(false); + }); + + it(`does not render raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(false); + }); + }); + + describe('setting raw value', () => { + const [variable] = mockVariables; + + it('defaults to expanded and raw:false when adding a variable', () => { + createComponent({ props: { selectedVariable: variable } }); + jest.spyOn(wrapper.vm, '$emit'); + + findModal().vm.$emit('shown'); + + expect(findExpandedVariableCheckbox().attributes('checked')).toBe('true'); + + findAddorUpdateButton().vm.$emit('click'); + + expect(wrapper.emitted('add-variable')).toEqual([ + [ + { + ...variable, + raw: false, + }, + ], + ]); + }); + + it('sets correct raw value when editing', async () => { + createComponent({ + props: { + selectedVariable: variable, + mode: EDIT_VARIABLE_ACTION, + }, + }); + jest.spyOn(wrapper.vm, '$emit'); + + findModal().vm.$emit('shown'); + await findExpandedVariableCheckbox().vm.$emit('change'); + await findAddorUpdateButton().vm.$emit('click'); + + expect(wrapper.emitted('update-variable')).toEqual([ + [ + { + ...variable, + raw: true, + }, + ], + ]); + }); + }); + }); + + describe('when not expanded', () => { + describe('with a $ character', () => { + beforeEach(() => { + const selectedVariable = mockVariables[1]; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable }, + }); + }); + + it(`renders raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(true); + }); + }); + }); + + describe('Editing a variable', () => { + const [variable] = mockVariables; + + beforeEach(() => { + createComponent({ props: { selectedVariable: variable, mode: EDIT_VARIABLE_ACTION } }); + jest.spyOn(wrapper.vm, '$emit'); + }); + + it('button text is Update variable when updating', () => { + expect(findAddorUpdateButton().text()).toBe('Update variable'); + }); + + it('Update variable button dispatches updateVariable with correct variable', () => { + findAddorUpdateButton().vm.$emit('click'); + expect(wrapper.emitted('update-variable')).toEqual([[variable]]); + }); + + it('Propagates the `hideModal` event', () => { + findModal().vm.$emit('hidden'); + expect(wrapper.emitted('hideModal')).toEqual([[]]); + }); + + it('dispatches `delete-variable` with correct variable to delete', () => { + deleteVariableButton().vm.$emit('click'); + expect(wrapper.emitted('delete-variable')).toEqual([[variable]]); + }); + }); + + describe('Environment scope', () => { + describe('when feature is available', () => { + describe('and section is not hidden', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + areScopedVariablesAvailable: true, + hideEnvironmentScope: false, + }, + }); + }); + + it('renders the environment dropdown and section title', () => { + expect(findCiEnvironmentsDropdown().exists()).toBe(true); + expect(findCiEnvironmentsDropdown().isVisible()).toBe(true); + expect(findEnvironmentScopeText().exists()).toBe(true); + }); + + it('renders a link to documentation on scopes', () => { + const link = findEnvScopeLink(); + + expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE); + expect(link.attributes('href')).toBe(defaultProvide.environmentScopeLink); + }); + }); + + describe('and section is hidden', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + areScopedVariablesAvailable: true, + hideEnvironmentScope: true, + }, + }); + }); + + it('does not renders the environment dropdown and section title', () => { + expect(findCiEnvironmentsDropdown().exists()).toBe(false); + expect(findEnvironmentScopeText().exists()).toBe(false); + }); + }); + }); + + describe('when feature is not available', () => { + describe('and section is not hidden', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + areScopedVariablesAvailable: false, + hideEnvironmentScope: false, + }, + }); + }); + + it('disables the dropdown', () => { + expect(findCiEnvironmentsDropdown().exists()).toBe(false); + expect(findEnvironmentScopeText().exists()).toBe(true); + expect(findEnvScopeInput().attributes('readonly')).toBe('readonly'); + }); + }); + + describe('and section is hidden', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + areScopedVariablesAvailable: false, + hideEnvironmentScope: true, + }, + }); + }); + + it('hides the dropdown', () => { + expect(findEnvironmentScopeText().exists()).toBe(false); + expect(findCiEnvironmentsDropdown().exists()).toBe(false); + }); + }); + }); + }); + + describe('variable type dropdown', () => { + describe('default behaviour', () => { + beforeEach(() => { + createComponent({ mountFn: mountExtended }); + }); + + it('adds each option as a dropdown item', () => { + expect(findVariableTypeDropdown().findAll('option')).toHaveLength(variableOptions.length); + variableOptions.forEach((v) => { + expect(findVariableTypeDropdown().text()).toContain(v.text); + }); + }); + }); + }); + + describe('Validations', () => { + const maskError = 'This variable can not be masked.'; + + describe('when the mask state is invalid', () => { + beforeEach(async () => { + const [variable] = mockVariables; + const invalidMaskVariable = { + ...variable, + value: 'd:;', + masked: false, + }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: invalidMaskVariable }, + }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + await findMaskedVariableCheckbox().trigger('click'); + }); + + it('disables the submit button', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBe('disabled'); + }); + + it('shows the correct error text', () => { + expect(findModal().text()).toContain(maskError); + }); + + it('sends the correct tracking event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { + label: EVENT_LABEL, + property: ';', + }); + }); + }); + + describe.each` + value | masked | eventSent | trackingErrorProperty + ${'secretValue'} | ${false} | ${0} | ${null} + ${'short'} | ${true} | ${0} | ${null} + ${'dollar$ign'} | ${false} | ${1} | ${'$'} + ${'dollar$ign'} | ${true} | ${1} | ${'$'} + ${'unsupported|char'} | ${true} | ${1} | ${'|'} + ${'unsupported|char'} | ${false} | ${0} | ${null} + `('Adding a new variable', ({ value, masked, eventSent, trackingErrorProperty }) => { + beforeEach(async () => { + const [variable] = mockVariables; + const invalidKeyVariable = { + ...variable, + value: '', + masked: false, + }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: invalidKeyVariable }, + }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + await findValueField().vm.$emit('input', value); + if (masked) { + await findMaskedVariableCheckbox().trigger('click'); + } + }); + + it(`${ + eventSent > 0 ? 'sends the correct' : 'does not send the' + } variable validation tracking event with ${value}`, () => { + expect(trackingSpy).toHaveBeenCalledTimes(eventSent); + + if (eventSent > 0) { + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { + label: EVENT_LABEL, + property: trackingErrorProperty, + }); + } + }); + }); + + describe('when masked variable has acceptable value', () => { + beforeEach(() => { + const [variable] = mockVariables; + const validMaskandKeyVariable = { + ...variable, + key: AWS_ACCESS_KEY_ID, + value: '12345678', + masked: true, + }; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable: validMaskandKeyVariable }, + }); + }); + + it('does not disable the submit button', () => { + expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js new file mode 100644 index 00000000000..32af2ec4de9 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js @@ -0,0 +1,147 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import CiVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; +import ciVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue'; +import ciVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import { + ADD_VARIABLE_ACTION, + EDIT_VARIABLE_ACTION, + projectString, +} from '~/ci/ci_variable_list/constants'; +import { mapEnvironmentNames } from '~/ci/ci_variable_list/utils'; + +import { mockEnvs, mockVariablesWithScopes, newVariable } from '../mocks'; + +describe('Ci variable table', () => { + let wrapper; + + const defaultProps = { + areScopedVariablesAvailable: true, + entity: 'project', + environments: mapEnvironmentNames(mockEnvs), + hideEnvironmentScope: false, + isLoading: false, + maxVariableLimit: 5, + variables: mockVariablesWithScopes(projectString), + }; + + const findCiVariableTable = () => wrapper.findComponent(ciVariableTable); + const findCiVariableModal = () => wrapper.findComponent(ciVariableModal); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(CiVariableSettings, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('props passing', () => { + it('passes props down correctly to the ci table', () => { + createComponent(); + + expect(findCiVariableTable().props()).toEqual({ + entity: 'project', + isLoading: defaultProps.isLoading, + maxVariableLimit: defaultProps.maxVariableLimit, + variables: defaultProps.variables, + }); + }); + + it('passes props down correctly to the ci modal', async () => { + createComponent(); + + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + expect(findCiVariableModal().props()).toEqual({ + areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable, + environments: defaultProps.environments, + hideEnvironmentScope: defaultProps.hideEnvironmentScope, + variables: defaultProps.variables, + mode: ADD_VARIABLE_ACTION, + selectedVariable: {}, + }); + }); + }); + + describe('modal mode', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes down ADD mode when receiving an empty variable', async () => { + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + expect(findCiVariableModal().props('mode')).toBe(ADD_VARIABLE_ACTION); + }); + + it('passes down EDIT mode when receiving a variable', async () => { + findCiVariableTable().vm.$emit('set-selected-variable', newVariable); + await nextTick(); + + expect(findCiVariableModal().props('mode')).toBe(EDIT_VARIABLE_ACTION); + }); + }); + + describe('variable modal', () => { + beforeEach(() => { + createComponent(); + }); + + it('is hidden by default', () => { + expect(findCiVariableModal().exists()).toBe(false); + }); + + it('shows modal when adding a new variable', async () => { + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + expect(findCiVariableModal().exists()).toBe(true); + }); + + it('shows modal when updating a variable', async () => { + findCiVariableTable().vm.$emit('set-selected-variable', newVariable); + await nextTick(); + + expect(findCiVariableModal().exists()).toBe(true); + }); + + it('hides modal when receiving the event from the modal', async () => { + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + findCiVariableModal().vm.$emit('hideModal'); + await nextTick(); + + expect(findCiVariableModal().exists()).toBe(false); + }); + }); + + describe('variable events', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + eventName + ${'add-variable'} + ${'update-variable'} + ${'delete-variable'} + `('bubbles up the $eventName event', async ({ eventName }) => { + findCiVariableTable().vm.$emit('set-selected-variable'); + await nextTick(); + + findCiVariableModal().vm.$emit(eventName, newVariable); + await nextTick(); + + expect(wrapper.emitted(eventName)).toEqual([[newVariable]]); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js new file mode 100644 index 00000000000..2d39bff8ce0 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -0,0 +1,450 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { resolvers } from '~/ci/ci_variable_list/graphql/settings'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; + +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import ciVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; +import ciVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql'; +import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql'; +import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; +import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; + +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, + environmentFetchErrorText, + genericMutationErrorText, + variableFetchErrorText, +} from '~/ci/ci_variable_list/constants'; + +import { + createGroupProps, + createInstanceProps, + createProjectProps, + createGroupProvide, + createProjectProvide, + devName, + mockProjectEnvironments, + mockProjectVariables, + newVariable, + prodName, + mockGroupVariables, + mockAdminVariables, +} from '../mocks'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +const mockProvide = { + endpoint: '/variables', + isGroup: false, + isProject: false, +}; + +const defaultProps = { + areScopedVariablesAvailable: true, + hideEnvironmentScope: false, + refetchAfterMutation: false, +}; + +describe('Ci Variable Shared Component', () => { + let wrapper; + + let mockApollo; + let mockEnvironments; + let mockVariables; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findCiTable = () => wrapper.findComponent(GlTable); + const findCiSettings = () => wrapper.findComponent(ciVariableSettings); + + // eslint-disable-next-line consistent-return + async function createComponentWithApollo({ + customHandlers = null, + isLoading = false, + props = { ...createProjectProps() }, + provide = {}, + } = {}) { + const handlers = customHandlers || [ + [getProjectEnvironments, mockEnvironments], + [getProjectVariables, mockVariables], + ]; + + mockApollo = createMockApollo(handlers, resolvers); + + wrapper = shallowMount(ciVariableShared, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...mockProvide, + ...provide, + }, + apolloProvider: mockApollo, + stubs: { ciVariableSettings, ciVariableTable }, + }); + + if (!isLoading) { + return waitForPromises(); + } + } + + beforeEach(() => { + mockEnvironments = jest.fn(); + mockVariables = jest.fn(); + }); + + describe('while queries are being fetch', () => { + beforeEach(() => { + createComponentWithApollo({ isLoading: true }); + }); + + it('shows a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findCiTable().exists()).toBe(false); + }); + }); + + describe('when queries are resolved', () => { + describe('successfully', () => { + beforeEach(async () => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockResolvedValue(mockProjectVariables); + + await createComponentWithApollo({ provide: createProjectProvide() }); + }); + + it('passes down the expected max variable limit as props', () => { + expect(findCiSettings().props('maxVariableLimit')).toBe( + mockProjectVariables.data.project.ciVariables.limit, + ); + }); + + it('passes down the expected environments as props', () => { + expect(findCiSettings().props('environments')).toEqual([prodName, devName]); + }); + + it('passes down the expected variables as props', () => { + expect(findCiSettings().props('variables')).toEqual( + mockProjectVariables.data.project.ciVariables.nodes, + ); + }); + + it('createAlert was not called', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('with an error for variables', () => { + beforeEach(async () => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockRejectedValue(); + + await createComponentWithApollo(); + }); + + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: variableFetchErrorText }); + }); + }); + + describe('with an error for environments', () => { + beforeEach(async () => { + mockEnvironments.mockRejectedValue(); + mockVariables.mockResolvedValue(mockProjectVariables); + + await createComponentWithApollo(); + }); + + it('calls createAlert with the expected error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: environmentFetchErrorText }); + }); + }); + }); + + describe('environment query', () => { + describe('when there is an environment key in queryData', () => { + beforeEach(async () => { + mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockResolvedValue(mockProjectVariables); + + await createComponentWithApollo({ props: { ...createProjectProps() } }); + }); + + it('is executed', () => { + expect(mockVariables).toHaveBeenCalled(); + }); + }); + + describe('when there isnt an environment key in queryData', () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + + await createComponentWithApollo({ props: { ...createGroupProps() } }); + }); + + it('is skipped', () => { + expect(mockVariables).not.toHaveBeenCalled(); + }); + }); + }); + + describe('mutations', () => { + const groupProps = createGroupProps(); + + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + props: groupProps, + }); + }); + it.each` + actionName | mutation | event + ${'add'} | ${groupProps.mutationData[ADD_MUTATION_ACTION]} | ${'add-variable'} + ${'update'} | ${groupProps.mutationData[UPDATE_MUTATION_ACTION]} | ${'update-variable'} + ${'delete'} | ${groupProps.mutationData[DELETE_MUTATION_ACTION]} | ${'delete-variable'} + `( + 'calls the right mutation from propsData when user performs $actionName variable', + async ({ event, mutation }) => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + + await findCiSettings().vm.$emit(event, newVariable); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation, + variables: { + endpoint: mockProvide.endpoint, + fullPath: groupProps.fullPath, + id: convertToGraphQLId('Group', groupProps.id), + variable: newVariable, + }, + }); + }, + ); + + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'throws with the specific graphql error if present when user performs $actionName variable', + async ({ event }) => { + const graphQLErrorMessage = 'There is a problem with this graphQL action'; + jest + .spyOn(wrapper.vm.$apollo, 'mutate') + .mockResolvedValue({ data: { ciVariableMutation: { errors: [graphQLErrorMessage] } } }); + await findCiSettings().vm.$emit(event, newVariable); + await nextTick(); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); + }, + ); + + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'throws generic error on failure with no graphql errors and user performs $actionName variable', + async ({ event }) => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => { + throw new Error(); + }); + await findCiSettings().vm.$emit(event, newVariable); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); + }, + ); + + describe('without fullpath and ID props', () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockAdminVariables); + + await createComponentWithApollo({ + customHandlers: [[getAdminVariables, mockVariables]], + props: createInstanceProps(), + }); + }); + + it('does not pass fullPath and ID to the mutation', async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + + await findCiSettings().vm.$emit('add-variable', newVariable); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: wrapper.props().mutationData[ADD_MUTATION_ACTION], + variables: { + endpoint: mockProvide.endpoint, + variable: newVariable, + }, + }); + }); + }); + }); + + describe('Props', () => { + const mockGroupCiVariables = mockGroupVariables.data.group.ciVariables; + const mockProjectCiVariables = mockProjectVariables.data.project.ciVariables; + + describe('in a specific context as', () => { + it.each` + name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | provideFn | mutation | maxVariableLimit + ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${createProjectProvide} | ${null} | ${mockProjectCiVariables.limit} + ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${createGroupProvide} | ${getGroupVariables} | ${mockGroupCiVariables.limit} + ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${() => {}} | ${getAdminVariables} | ${0} + `( + 'passes down all the required props when its a $name component', + async ({ + mutation, + maxVariableLimit, + mockVariablesValue, + mockEnvironmentsValue, + withEnvironments, + expectedEnvironments, + propsFn, + provideFn, + }) => { + const props = propsFn(); + const provide = provideFn(); + + mockVariables.mockResolvedValue(mockVariablesValue); + + if (withEnvironments) { + mockEnvironments.mockResolvedValue(mockEnvironmentsValue); + } + + let customHandlers = null; + + if (mutation) { + customHandlers = [[mutation, mockVariables]]; + } + + await createComponentWithApollo({ customHandlers, props, provide }); + + expect(findCiSettings().props()).toEqual({ + areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, + hideEnvironmentScope: defaultProps.hideEnvironmentScope, + isLoading: false, + maxVariableLimit, + variables: wrapper.props().queryData.ciVariables.lookup(mockVariablesValue.data)?.nodes, + entity: props.entity, + environments: expectedEnvironments, + }); + }, + ); + }); + + describe('refetchAfterMutation', () => { + it.each` + bool | text + ${true} | ${'refetches the variables'} + ${false} | ${'does not refetch the variables'} + `('when $bool it $text', async ({ bool }) => { + await createComponentWithApollo({ + props: { ...createInstanceProps(), refetchAfterMutation: bool }, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: {} }); + jest.spyOn(wrapper.vm.$apollo.queries.ciVariables, 'refetch').mockImplementation(jest.fn()); + + await findCiSettings().vm.$emit('add-variable', newVariable); + + await nextTick(); + + if (bool) { + expect(wrapper.vm.$apollo.queries.ciVariables.refetch).toHaveBeenCalled(); + } else { + expect(wrapper.vm.$apollo.queries.ciVariables.refetch).not.toHaveBeenCalled(); + } + }); + }); + + describe('Validators', () => { + describe('queryData', () => { + let error; + + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + }); + + it('will mount component with right data', async () => { + try { + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + props: { ...createGroupProps() }, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(true); + expect(error).toBeUndefined(); + } + }); + + it('will not mount component with wrong data', async () => { + try { + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + props: { ...createGroupProps(), queryData: { wrongKey: {} } }, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(false); + expect(error.toString()).toContain('custom validator check failed for prop'); + } + }); + }); + + describe('mutationData', () => { + let error; + + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + }); + + it('will mount component with right data', async () => { + try { + await createComponentWithApollo({ + props: { ...createGroupProps() }, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(true); + expect(error).toBeUndefined(); + } + }); + + it('will not mount component with wrong data', async () => { + try { + await createComponentWithApollo({ + props: { ...createGroupProps(), mutationData: { wrongKey: {} } }, + }); + } catch (e) { + error = e; + } finally { + expect(wrapper.exists()).toBe(false); + expect(error.toString()).toContain('custom validator check failed for prop'); + } + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js new file mode 100644 index 00000000000..9e2508c56ee --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js @@ -0,0 +1,172 @@ +import { GlAlert } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import { EXCEEDS_VARIABLE_LIMIT_TEXT, projectString } from '~/ci/ci_variable_list/constants'; +import { mockVariables } from '../mocks'; + +describe('Ci variable table', () => { + let wrapper; + + const defaultProps = { + entity: 'project', + isLoading: false, + maxVariableLimit: mockVariables(projectString).length + 1, + variables: mockVariables(projectString), + }; + + const mockMaxVariableLimit = defaultProps.variables.length; + + const createComponent = ({ props = {} } = {}) => { + wrapper = mountExtended(CiVariableTable, { + attachTo: document.body, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findRevealButton = () => wrapper.findByText('Reveal values'); + const findAddButton = () => wrapper.findByLabelText('Add'); + const findEditButton = () => wrapper.findByLabelText('Edit'); + const findEmptyVariablesPlaceholder = () => wrapper.findByText('There are no variables yet.'); + const findHiddenValues = () => wrapper.findAllByTestId('hiddenValue'); + const findLimitReachedAlerts = () => wrapper.findAllComponents(GlAlert); + const findRevealedValues = () => wrapper.findAllByTestId('revealedValue'); + const findOptionsValues = (rowIndex) => + wrapper.findAllByTestId('ci-variable-table-row-options').at(rowIndex).text(); + + const generateExceedsVariableLimitText = (entity, currentVariableCount, maxVariableLimit) => { + return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { entity, currentVariableCount, maxVariableLimit }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When table is empty', () => { + beforeEach(() => { + createComponent({ props: { variables: [] } }); + }); + + it('displays empty message', () => { + expect(findEmptyVariablesPlaceholder().exists()).toBe(true); + }); + + it('hides the reveal button', () => { + expect(findRevealButton().exists()).toBe(false); + }); + }); + + describe('When table has variables', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not display the empty message', () => { + expect(findEmptyVariablesPlaceholder().exists()).toBe(false); + }); + + it('displays the reveal button', () => { + expect(findRevealButton().exists()).toBe(true); + }); + + it('displays the correct amount of variables', async () => { + expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length); + }); + + it('displays the correct variable options', async () => { + expect(findOptionsValues(0)).toBe('Protected, Expanded'); + expect(findOptionsValues(1)).toBe('Masked'); + }); + + it('enables the Add Variable button', () => { + expect(findAddButton().props('disabled')).toBe(false); + }); + }); + + describe('When variables have exceeded the max limit', () => { + beforeEach(() => { + createComponent({ props: { maxVariableLimit: mockVariables(projectString).length } }); + }); + + it('disables the Add Variable button', () => { + expect(findAddButton().props('disabled')).toBe(true); + }); + }); + + describe('max limit reached alert', () => { + describe('when there is no variable limit', () => { + beforeEach(() => { + createComponent({ + props: { maxVariableLimit: 0 }, + }); + }); + + it('hides alert', () => { + expect(findLimitReachedAlerts().length).toBe(0); + }); + }); + + describe('when variable limit exists', () => { + it('hides alert when limit has not been reached', () => { + createComponent(); + + expect(findLimitReachedAlerts().length).toBe(0); + }); + + it('shows alert when limit has been reached', () => { + const exceedsVariableLimitText = generateExceedsVariableLimitText( + defaultProps.entity, + defaultProps.variables.length, + mockMaxVariableLimit, + ); + + createComponent({ + props: { maxVariableLimit: mockMaxVariableLimit }, + }); + + expect(findLimitReachedAlerts().length).toBe(2); + + expect(findLimitReachedAlerts().at(0).props('dismissible')).toBe(false); + expect(findLimitReachedAlerts().at(0).text()).toContain(exceedsVariableLimitText); + + expect(findLimitReachedAlerts().at(1).props('dismissible')).toBe(false); + expect(findLimitReachedAlerts().at(1).text()).toContain(exceedsVariableLimitText); + }); + }); + }); + + describe('Table click actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('reveals secret values when button is clicked', async () => { + expect(findHiddenValues()).toHaveLength(defaultProps.variables.length); + expect(findRevealedValues()).toHaveLength(0); + + await findRevealButton().trigger('click'); + + expect(findHiddenValues()).toHaveLength(0); + expect(findRevealedValues()).toHaveLength(defaultProps.variables.length); + }); + + it('dispatches `setSelectedVariable` with correct variable to edit', async () => { + await findEditButton().trigger('click'); + + expect(wrapper.emitted('set-selected-variable')).toEqual([[defaultProps.variables[0]]]); + }); + + it('dispatches `setSelectedVariable` with no variable when adding a new one', async () => { + await findAddButton().trigger('click'); + + expect(wrapper.emitted('set-selected-variable')).toEqual([[null]]); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js new file mode 100644 index 00000000000..4da4f53f69f --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -0,0 +1,213 @@ +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, + variableTypes, + groupString, + instanceString, + projectString, +} from '~/ci/ci_variable_list/constants'; + +import addAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql'; +import deleteAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql'; +import updateAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql'; +import addGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql'; +import deleteGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql'; +import updateGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql'; +import addProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql'; +import deleteProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql'; +import updateProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql'; + +import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql'; +import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; +import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql'; +import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; + +export const devName = 'dev'; +export const prodName = 'prod'; + +export const mockVariables = (kind) => { + return [ + { + __typename: `Ci${kind}Variable`, + id: 1, + key: 'my-var', + masked: false, + protected: true, + raw: false, + value: 'variable_value', + variableType: variableTypes.envType, + }, + { + __typename: `Ci${kind}Variable`, + id: 2, + key: 'secret', + masked: true, + protected: false, + raw: true, + value: 'another_value', + variableType: variableTypes.fileType, + }, + ]; +}; + +export const mockVariablesWithScopes = (kind) => + mockVariables(kind).map((variable) => { + return { ...variable, environmentScope: '*' }; + }); + +const createDefaultVars = ({ withScope = true, kind } = {}) => { + let base = mockVariables(kind); + + if (withScope) { + base = mockVariablesWithScopes(kind); + } + + return { + __typename: `Ci${kind}VariableConnection`, + limit: 200, + pageInfo: { + startCursor: 'adsjsd12kldpsa', + endCursor: 'adsjsd12kldpsa', + hasPreviousPage: false, + hasNextPage: true, + }, + nodes: base, + }; +}; + +const defaultEnvs = { + __typename: 'EnvironmentConnection', + nodes: [ + { + __typename: 'Environment', + id: 1, + name: prodName, + }, + { + __typename: 'Environment', + id: 2, + name: devName, + }, + ], +}; + +export const mockEnvs = defaultEnvs.nodes; + +export const mockProjectEnvironments = { + data: { + project: { + __typename: 'Project', + id: 1, + environments: defaultEnvs, + }, + }, +}; + +export const mockProjectVariables = { + data: { + project: { + __typename: 'Project', + id: 1, + ciVariables: createDefaultVars({ kind: projectString }), + }, + }, +}; + +export const mockGroupVariables = { + data: { + group: { + __typename: 'Group', + id: 1, + ciVariables: createDefaultVars({ kind: groupString }), + }, + }, +}; + +export const mockAdminVariables = { + data: { + ciVariables: createDefaultVars({ withScope: false, kind: instanceString }), + }, +}; + +export const newVariable = { + id: 3, + environmentScope: 'new', + key: 'AWS_RANDOM_THING', + masked: true, + protected: false, + value: 'devops', + variableType: variableTypes.variableType, +}; + +export const createProjectProps = () => { + return { + componentName: 'ProjectVariable', + entity: 'project', + fullPath: '/namespace/project/', + id: 'gid://gitlab/Project/20', + mutationData: { + [ADD_MUTATION_ACTION]: addProjectVariable, + [UPDATE_MUTATION_ACTION]: updateProjectVariable, + [DELETE_MUTATION_ACTION]: deleteProjectVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.project?.ciVariables, + query: getProjectVariables, + }, + environments: { + lookup: (data) => data?.project?.environments, + query: getProjectEnvironments, + }, + }, + }; +}; + +export const createGroupProps = () => { + return { + componentName: 'GroupVariable', + entity: 'group', + fullPath: '/my-group', + id: 'gid://gitlab/Group/20', + mutationData: { + [ADD_MUTATION_ACTION]: addGroupVariable, + [UPDATE_MUTATION_ACTION]: updateGroupVariable, + [DELETE_MUTATION_ACTION]: deleteGroupVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.group?.ciVariables, + query: getGroupVariables, + }, + }, + }; +}; + +export const createInstanceProps = () => { + return { + componentName: 'InstanceVariable', + entity: '', + mutationData: { + [ADD_MUTATION_ACTION]: addAdminVariable, + [UPDATE_MUTATION_ACTION]: updateAdminVariable, + [DELETE_MUTATION_ACTION]: deleteAdminVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.ciVariables, + query: getAdminVariables, + }, + }, + }; +}; + +export const createGroupProvide = () => ({ + isGroup: true, + isProject: false, +}); + +export const createProjectProvide = () => ({ + isGroup: false, + isProject: true, +}); diff --git a/spec/frontend/ci/ci_variable_list/services/mock_data.js b/spec/frontend/ci/ci_variable_list/services/mock_data.js new file mode 100644 index 00000000000..44f4db93c63 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/services/mock_data.js @@ -0,0 +1,156 @@ +export default { + mockVariables: [ + { + environment_scope: 'All (default)', + id: 113, + key: 'test_var', + masked: false, + protected: false, + secret_value: 'test_val', + value: 'test_val', + variable_type: 'Variable', + }, + ], + + mockVariablesApi: [ + { + environment_scope: '*', + id: 113, + key: 'test_var', + masked: false, + protected: false, + secret_value: 'test_val', + value: 'test_val', + variable_type: 'env_var', + }, + { + environment_scope: '*', + id: 114, + key: 'test_var_2', + masked: false, + protected: false, + secret_value: 'test_val_2', + value: 'test_val_2', + variable_type: 'file', + }, + ], + + mockVariablesDisplay: [ + { + environment_scope: 'All (default)', + id: 113, + key: 'test_var', + masked: false, + protected: false, + protected_variable: false, + secret_value: 'test_val', + value: 'test_val', + variable_type: 'Variable', + }, + { + environment_scope: 'All (default)', + id: 114, + key: 'test_var_2', + masked: false, + protected: false, + protected_variable: false, + secret_value: 'test_val_2', + value: 'test_val_2', + variable_type: 'File', + }, + ], + + mockEnvironments: [ + { + id: 28, + name: 'staging', + slug: 'staging', + external_url: 'https://staging.example.com', + state: 'available', + }, + { + id: 29, + name: 'production', + slug: 'production', + external_url: 'https://production.example.com', + state: 'available', + }, + ], + + mockPemCert: `-----BEGIN CERTIFICATE REQUEST----- + MIIB9TCCAWACAQAwgbgxGTAXBgNVBAoMEFF1b1ZhZGlzIExpbWl0ZWQxHDAaBgNV + BAsME0RvY3VtZW50IERlcGFydG1lbnQxOTA3BgNVBAMMMFdoeSBhcmUgeW91IGRl + Y29kaW5nIG1lPyAgVGhpcyBpcyBvbmx5IGEgdGVzdCEhITERMA8GA1UEBwwISGFt + aWx0b24xETAPBgNVBAgMCFBlbWJyb2tlMQswCQYDVQQGEwJCTTEPMA0GCSqGSIb3 + DQEJARYAMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCJ9WRanG/fUvcfKiGl + EL4aRLjGt537mZ28UU9/3eiJeJznNSOuNLnF+hmabAu7H0LT4K7EdqfF+XUZW/2j + RKRYcvOUDGF9A7OjW7UfKk1In3+6QDCi7X34RE161jqoaJjrm/T18TOKcgkkhRzE + apQnIDm0Ea/HVzX/PiSOGuertwIDAQABMAsGCSqGSIb3DQEBBQOBgQBzMJdAV4QP + Awel8LzGx5uMOshezF/KfP67wJ93UW+N7zXY6AwPgoLj4Kjw+WtU684JL8Dtr9FX + ozakE+8p06BpxegR4BR3FMHf6p+0jQxUEAkAyb/mVgm66TyghDGC6/YkiKoZptXQ + 98TwDIK/39WEB/V607As+KoYazQG8drorw== + -----END CERTIFICATE REQUEST-----`, + + mockVariableScopes: [ + { + id: 13, + key: 'test_var_1', + value: 'test_val_1', + variable_type: 'File', + protected: true, + masked: true, + environment_scope: 'All (default)', + secret_value: 'test_val_1', + }, + { + id: 28, + key: 'goku_var', + value: 'goku_val', + variable_type: 'Variable', + protected: true, + masked: true, + environment_scope: 'staging', + secret_value: 'goku_val', + }, + { + id: 25, + key: 'test_var_4', + value: 'test_val_4', + variable_type: 'Variable', + protected: false, + masked: false, + environment_scope: 'production', + secret_value: 'test_val_4', + }, + { + id: 14, + key: 'test_var_2', + value: 'test_val_2', + variable_type: 'File', + protected: false, + masked: false, + environment_scope: 'staging', + secret_value: 'test_val_2', + }, + { + id: 24, + key: 'test_var_3', + value: 'test_val_3', + variable_type: 'Variable', + protected: false, + masked: false, + environment_scope: 'All (default)', + secret_value: 'test_val_3', + }, + { + id: 26, + key: 'test_var_5', + value: 'test_val_5', + variable_type: 'Variable', + protected: false, + masked: false, + environment_scope: 'production', + secret_value: 'test_val_5', + }, + ], +}; diff --git a/spec/frontend/ci/ci_variable_list/stubs.js b/spec/frontend/ci/ci_variable_list/stubs.js new file mode 100644 index 00000000000..5769d6190f6 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/stubs.js @@ -0,0 +1,14 @@ +const ModalStub = { + name: 'glmodal-stub', + template: ` + <div> + <slot></slot> + <slot name="modal-footer"></slot> + </div> + `, + methods: { + hide: jest.fn(), + }, +}; + +export default ModalStub; diff --git a/spec/frontend/ci/ci_variable_list/utils_spec.js b/spec/frontend/ci/ci_variable_list/utils_spec.js new file mode 100644 index 00000000000..beeae71376a --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/utils_spec.js @@ -0,0 +1,78 @@ +import { + createJoinedEnvironments, + convertEnvironmentScope, + mapEnvironmentNames, +} from '~/ci/ci_variable_list/utils'; +import { allEnvironments } from '~/ci/ci_variable_list/constants'; + +describe('utils', () => { + const environments = ['dev', 'prod']; + const newEnvironments = ['staging']; + + describe('createJoinedEnvironments', () => { + it('returns only `environments` if `variables` argument is undefined', () => { + const variables = undefined; + + expect(createJoinedEnvironments(variables, environments, [])).toEqual(environments); + }); + + it('returns a list of environments and environment scopes taken from variables in alphabetical order', () => { + const envScope1 = 'new1'; + const envScope2 = 'new2'; + + const variables = [{ environmentScope: envScope1 }, { environmentScope: envScope2 }]; + + expect(createJoinedEnvironments(variables, environments, [])).toEqual([ + environments[0], + envScope1, + envScope2, + environments[1], + ]); + }); + + it('returns combined list with new environments included', () => { + const variables = undefined; + + expect(createJoinedEnvironments(variables, environments, newEnvironments)).toEqual([ + ...environments, + ...newEnvironments, + ]); + }); + + it('removes duplicate environments', () => { + const envScope1 = environments[0]; + const envScope2 = 'new2'; + + const variables = [{ environmentScope: envScope1 }, { environmentScope: envScope2 }]; + + expect(createJoinedEnvironments(variables, environments, [])).toEqual([ + environments[0], + envScope2, + environments[1], + ]); + }); + }); + + describe('convertEnvironmentScope', () => { + it('converts the * to the `All environments` text', () => { + expect(convertEnvironmentScope('*')).toBe(allEnvironments.text); + }); + + it('returns the environment as is if not the *', () => { + expect(convertEnvironmentScope('prod')).toBe('prod'); + }); + }); + + describe('mapEnvironmentNames', () => { + const envName = 'dev'; + const envName2 = 'prod'; + + const nodes = [ + { name: envName, otherProp: {} }, + { name: envName2, otherProp: {} }, + ]; + it('flatten a nodes array with only their names', () => { + expect(mapEnvironmentNames(nodes)).toEqual([envName, envName2]); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js index 63e23c41263..ec987be8cb8 100644 --- a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js @@ -26,14 +26,13 @@ describe('Pipeline Editor | Text editor component', () => { props: ['value', 'fileName', 'editorOptions', 'debounceValue'], }; - const createComponent = (glFeatures = {}, mountFn = shallowMount) => { + const createComponent = (mountFn = shallowMount) => { wrapper = mountFn(TextEditor, { provide: { projectPath: mockProjectPath, projectNamespace: mockProjectNamespace, ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, - glFeatures, }, propsData: { commitSha: mockCommitSha, @@ -107,28 +106,14 @@ describe('Pipeline Editor | Text editor component', () => { }); describe('CI schema', () => { - describe('when `schema_linting` feature flag is on', () => { - beforeEach(() => { - createComponent({ schemaLinting: true }); - findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); - }); - - it('configures editor with syntax highlight', () => { - expect(mockUse).toHaveBeenCalledTimes(1); - expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); - }); + beforeEach(() => { + createComponent(); + findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); }); - describe('when `schema_linting` feature flag is off', () => { - beforeEach(() => { - createComponent(); - findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); - }); - - it('does not call the register CI schema function', () => { - expect(mockUse).not.toHaveBeenCalled(); - expect(mockRegisterCiSchema).not.toHaveBeenCalled(); - }); + it('configures editor with syntax highlight', () => { + expect(mockUse).toHaveBeenCalledTimes(1); + expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js index e54c72a758f..6a6cc3a14de 100644 --- a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import { mockLintResponse } from '../mock_data'; @@ -20,7 +20,7 @@ describe('~/ci/pipeline_editor/graphql/resolvers', () => { beforeEach(async () => { mock = new MockAdapter(axios); - mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse); + mock.onPost(endpoint).reply(HTTP_STATUS_OK, mockLintResponse); result = await resolvers.Mutation.lintCI(null, { endpoint, diff --git a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js new file mode 100644 index 00000000000..cd16045f92d --- /dev/null +++ b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js @@ -0,0 +1,468 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlForm, GlDropdownItem, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { + HTTP_STATUS_BAD_REQUEST, + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_OK, +} from '~/lib/utils/http_status'; +import { redirectTo } from '~/lib/utils/url_utility'; +import PipelineNewForm from '~/ci/pipeline_new/components/pipeline_new_form.vue'; +import ciConfigVariablesQuery from '~/ci/pipeline_new/graphql/queries/ci_config_variables.graphql'; +import { resolvers } from '~/ci/pipeline_new/graphql/resolvers'; +import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue'; +import { + mockCreditCardValidationRequiredError, + mockCiConfigVariablesResponse, + mockCiConfigVariablesResponseWithoutDesc, + mockEmptyCiConfigVariablesResponse, + mockError, + mockQueryParams, + mockPostParams, + mockProjectId, + mockRefs, + mockYamlVariables, +} from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), +})); + +const projectRefsEndpoint = '/root/project/refs'; +const pipelinesPath = '/root/project/-/pipelines'; +const projectPath = '/root/project/-/pipelines/config_variables'; +const newPipelinePostResponse = { id: 1 }; +const defaultBranch = 'main'; + +describe('Pipeline New Form', () => { + let wrapper; + let mock; + let mockApollo; + let mockCiConfigVariables; + let dummySubmitEvent; + + const findForm = () => wrapper.findComponent(GlForm); + const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); + const findSubmitButton = () => wrapper.findByTestId('run_pipeline_button'); + const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); + const findVariableTypes = () => wrapper.findAllByTestId('pipeline-form-ci-variable-type'); + const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); + const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); + const findValueDropdowns = () => + wrapper.findAllByTestId('pipeline-form-ci-variable-value-dropdown'); + const findValueDropdownItems = (dropdown) => dropdown.findAllComponents(GlDropdownItem); + const findErrorAlert = () => wrapper.findByTestId('run-pipeline-error-alert'); + const findWarningAlert = () => wrapper.findByTestId('run-pipeline-warning-alert'); + const findWarningAlertSummary = () => findWarningAlert().findComponent(GlSprintf); + const findWarnings = () => wrapper.findAllByTestId('run-pipeline-warning'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); + const getFormPostParams = () => JSON.parse(mock.history.post[0].data); + + const selectBranch = async (branch) => { + // Select a branch in the dropdown + findRefsDropdown().vm.$emit('input', { + shortName: branch, + fullName: `refs/heads/${branch}`, + }); + + await waitForPromises(); + }; + + const changeKeyInputValue = async (keyInputIndex, value) => { + const input = findKeyInputs().at(keyInputIndex); + input.element.value = value; + input.trigger('change'); + + await nextTick(); + }; + + const createComponentWithApollo = ({ method = shallowMountExtended, props = {} } = {}) => { + const handlers = [[ciConfigVariablesQuery, mockCiConfigVariables]]; + mockApollo = createMockApollo(handlers, resolvers); + + wrapper = method(PipelineNewForm, { + apolloProvider: mockApollo, + provide: { + projectRefsEndpoint, + }, + propsData: { + projectId: mockProjectId, + pipelinesPath, + projectPath, + defaultBranch, + refParam: defaultBranch, + settingsLink: '', + maxWarnings: 25, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mockCiConfigVariables = jest.fn(); + mock.onGet(projectRefsEndpoint).reply(HTTP_STATUS_OK, mockRefs); + + dummySubmitEvent = { + preventDefault: jest.fn(), + }; + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('Form', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); + await waitForPromises(); + }); + + it('displays the correct values for the provided query params', async () => { + expect(findVariableTypes().at(0).props('text')).toBe('Variable'); + expect(findVariableTypes().at(1).props('text')).toBe('File'); + expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); + expect(findVariableRows()).toHaveLength(3); + }); + + it('displays a variable from provided query params', () => { + expect(findKeyInputs().at(0).element.value).toBe('test_var'); + expect(findValueInputs().at(0).element.value).toBe('test_var_val'); + }); + + it('displays an empty variable for the user to fill out', async () => { + expect(findKeyInputs().at(2).element.value).toBe(''); + expect(findValueInputs().at(2).element.value).toBe(''); + expect(findVariableTypes().at(2).props('text')).toBe('Variable'); + }); + + it('does not display remove icon for last row', () => { + expect(findRemoveIcons()).toHaveLength(2); + }); + + it('removes ci variable row on remove icon button click', async () => { + findRemoveIcons().at(1).trigger('click'); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(2); + }); + + it('creates blank variable on input change event', async () => { + const input = findKeyInputs().at(2); + input.element.value = 'test_var_2'; + input.trigger('change'); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(4); + expect(findKeyInputs().at(3).element.value).toBe(''); + expect(findValueInputs().at(3).element.value).toBe(''); + }); + }); + + describe('Pipeline creation', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + mock.onPost(pipelinesPath).reply(HTTP_STATUS_OK, newPipelinePostResponse); + }); + + it('does not submit the native HTML form', async () => { + createComponentWithApollo(); + + findForm().vm.$emit('submit', dummySubmitEvent); + + expect(dummySubmitEvent.preventDefault).toHaveBeenCalled(); + }); + + it('disables the submit button immediately after submitting', async () => { + createComponentWithApollo(); + + expect(findSubmitButton().props('disabled')).toBe(false); + + findForm().vm.$emit('submit', dummySubmitEvent); + await waitForPromises(); + + expect(findSubmitButton().props('disabled')).toBe(true); + }); + + it('creates pipeline with full ref and variables', async () => { + createComponentWithApollo(); + + findForm().vm.$emit('submit', dummySubmitEvent); + await waitForPromises(); + + expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); + }); + + it('creates a pipeline with short ref and variables from the query params', async () => { + createComponentWithApollo({ props: mockQueryParams }); + + await waitForPromises(); + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + + expect(getFormPostParams()).toEqual(mockPostParams); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); + }); + }); + + describe('When the ref has been changed', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); + + await waitForPromises(); + }); + + it('variables persist between ref changes', async () => { + await selectBranch('main'); + await changeKeyInputValue(0, 'build_var'); + + await selectBranch('branch-1'); + await changeKeyInputValue(0, 'deploy_var'); + + await selectBranch('main'); + + expect(findKeyInputs().at(0).element.value).toBe('build_var'); + expect(findVariableRows().length).toBe(2); + + await selectBranch('branch-1'); + + expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); + expect(findVariableRows().length).toBe(2); + }); + + it('skips query call when form variables are already cached', async () => { + await selectBranch('main'); + await changeKeyInputValue(0, 'build_var'); + + expect(mockCiConfigVariables).toHaveBeenCalledTimes(1); + + await selectBranch('branch-1'); + + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); + + // no additional call since `main` form values have been cached + await selectBranch('main'); + + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); + }); + }); + + describe('when yml defines a variable', () => { + it('loading icon is shown when content is requested and hidden when received', async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); + + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('with different predefined values', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); + await waitForPromises(); + }); + + it('multi-line strings are added to the value field without removing line breaks', () => { + expect(findValueInputs().at(1).element.value).toBe(mockYamlVariables[1].value); + }); + + it('multiple predefined values are rendered as a dropdown', () => { + const dropdown = findValueDropdowns().at(0); + const dropdownItems = findValueDropdownItems(dropdown); + const { valueOptions } = mockYamlVariables[2]; + + expect(dropdownItems.at(0).text()).toBe(valueOptions[0]); + expect(dropdownItems.at(1).text()).toBe(valueOptions[1]); + expect(dropdownItems.at(2).text()).toBe(valueOptions[2]); + }); + + it('variable with multiple predefined values sets value as the default', () => { + const dropdown = findValueDropdowns().at(0); + const { valueOptions } = mockYamlVariables[2]; + + expect(dropdown.props('text')).toBe(valueOptions[1]); + }); + }); + + describe('with description', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); + await waitForPromises(); + }); + + it('displays all the variables', async () => { + expect(findVariableRows()).toHaveLength(6); + }); + + it('displays a variable from yml', () => { + expect(findKeyInputs().at(0).element.value).toBe(mockYamlVariables[0].key); + expect(findValueInputs().at(0).element.value).toBe(mockYamlVariables[0].value); + }); + + it('displays a variable from provided query params', () => { + expect(findKeyInputs().at(3).element.value).toBe( + Object.keys(mockQueryParams.variableParams)[0], + ); + expect(findValueInputs().at(3).element.value).toBe( + Object.values(mockQueryParams.fileParams)[0], + ); + }); + + it('adds a description to the first variable from yml', () => { + expect(findVariableRows().at(0).text()).toContain(mockYamlVariables[0].description); + }); + + it('removes the description when a variable key changes', async () => { + findKeyInputs().at(0).element.value = 'yml_var_modified'; + findKeyInputs().at(0).trigger('change'); + + await nextTick(); + + expect(findVariableRows().at(0).text()).not.toContain(mockYamlVariables[0].description); + }); + }); + + describe('without description', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponseWithoutDesc); + createComponentWithApollo({ method: mountExtended }); + await waitForPromises(); + }); + + it('displays variables with description only', async () => { + expect(findVariableRows()).toHaveLength(2); // extra empty variable is added at the end + }); + }); + }); + + describe('Form errors and warnings', () => { + beforeEach(() => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo(); + }); + + describe('when the refs cannot be loaded', () => { + beforeEach(() => { + mock + .onGet(projectRefsEndpoint, { params: { search: '' } }) + .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findRefsDropdown().vm.$emit('loadingError'); + }); + + it('shows both an error alert', () => { + expect(findErrorAlert().exists()).toBe(true); + expect(findWarningAlert().exists()).toBe(false); + }); + }); + + describe('when the error response can be handled', () => { + beforeEach(async () => { + mock.onPost(pipelinesPath).reply(HTTP_STATUS_BAD_REQUEST, mockError); + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + }); + + it('shows both error and warning', () => { + expect(findErrorAlert().exists()).toBe(true); + expect(findWarningAlert().exists()).toBe(true); + }); + + it('shows the correct error', () => { + expect(findErrorAlert().text()).toBe(mockError.errors[0]); + }); + + it('shows the correct warning title', () => { + const { length } = mockError.warnings; + + expect(findWarningAlertSummary().attributes('message')).toBe(`${length} warnings found:`); + }); + + it('shows the correct amount of warnings', () => { + expect(findWarnings()).toHaveLength(mockError.warnings.length); + }); + + it('re-enables the submit button', () => { + expect(findSubmitButton().props('disabled')).toBe(false); + }); + + it('does not show the credit card validation required alert', () => { + expect(findCCAlert().exists()).toBe(false); + }); + + describe('when the error response is credit card validation required', () => { + beforeEach(async () => { + mock + .onPost(pipelinesPath) + .reply(HTTP_STATUS_BAD_REQUEST, mockCreditCardValidationRequiredError); + + window.gon = { + subscriptions_url: TEST_HOST, + payment_form_url: TEST_HOST, + }; + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + }); + + it('shows credit card validation required alert', () => { + expect(findErrorAlert().exists()).toBe(false); + expect(findCCAlert().exists()).toBe(true); + }); + + it('clears error and hides the alert on dismiss', async () => { + expect(findCCAlert().exists()).toBe(true); + expect(wrapper.vm.$data.error).toBe(mockCreditCardValidationRequiredError.errors[0]); + + findCCAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findCCAlert().exists()).toBe(false); + expect(wrapper.vm.$data.error).toBe(null); + }); + }); + }); + + describe('when the error response cannot be handled', () => { + beforeEach(async () => { + mock.onPost(pipelinesPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, 'something went wrong'); + + findForm().vm.$emit('submit', dummySubmitEvent); + + await waitForPromises(); + }); + + it('re-enables the submit button', () => { + expect(findSubmitButton().props('disabled')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js new file mode 100644 index 00000000000..cf8009e388f --- /dev/null +++ b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js @@ -0,0 +1,201 @@ +import { GlListbox, GlListboxItem } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; + +import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue'; + +import { mockBranches, mockRefs, mockFilteredRefs, mockTags } from '../mock_data'; + +const projectRefsEndpoint = '/root/project/refs'; +const refShortName = 'main'; +const refFullName = 'refs/heads/main'; + +jest.mock('~/flash'); + +describe('Pipeline New Form', () => { + let wrapper; + let mock; + + const findDropdown = () => wrapper.findComponent(GlListbox); + const findRefsDropdownItems = () => wrapper.findAllComponents(GlListboxItem); + const findSearchBox = () => wrapper.findByTestId('listbox-search-input'); + const findListboxGroups = () => wrapper.findAll('ul[role="group"]'); + + const createComponent = (props = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(RefsDropdown, { + provide: { + projectRefsEndpoint, + }, + propsData: { + value: { + shortName: refShortName, + fullName: refFullName, + }, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockRefs); + }); + + beforeEach(() => { + createComponent(); + }); + + it('displays empty dropdown initially', () => { + findDropdown().vm.$emit('shown'); + + expect(findRefsDropdownItems()).toHaveLength(0); + }); + + it('does not make requests immediately', async () => { + expect(mock.history.get).toHaveLength(0); + }); + + describe('when user opens dropdown', () => { + beforeEach(async () => { + createComponent({}, mountExtended); + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('requests unfiltered tags and branches', () => { + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0].url).toBe(projectRefsEndpoint); + expect(mock.history.get[0].params).toEqual({ search: '' }); + }); + + it('displays dropdown with branches and tags', () => { + const refLength = mockRefs.Tags.length + mockRefs.Branches.length; + expect(findRefsDropdownItems()).toHaveLength(refLength); + }); + + it('displays the names of refs', () => { + // Branches + expect(findRefsDropdownItems().at(0).text()).toBe(mockRefs.Branches[0]); + + // Tags (appear after branches) + const firstTag = mockRefs.Branches.length; + expect(findRefsDropdownItems().at(firstTag).text()).toBe(mockRefs.Tags[0]); + }); + + it('when user shows dropdown a second time, only one request is done', () => { + expect(mock.history.get).toHaveLength(1); + }); + + describe('when user selects a value', () => { + const selectedIndex = 1; + + beforeEach(async () => { + findRefsDropdownItems().at(selectedIndex).vm.$emit('select', 'refs/heads/branch-1'); + await waitForPromises(); + }); + + it('component emits @input', () => { + const inputs = wrapper.emitted('input'); + + expect(inputs).toHaveLength(1); + expect(inputs[0]).toEqual([{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' }]); + }); + }); + + describe('when user types searches for a tag', () => { + const mockSearchTerm = 'my-search'; + + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { params: { search: mockSearchTerm } }) + .reply(HTTP_STATUS_OK, mockFilteredRefs); + + await findSearchBox().vm.$emit('input', mockSearchTerm); + await waitForPromises(); + }); + + it('requests filtered tags and branches', async () => { + expect(mock.history.get).toHaveLength(2); + expect(mock.history.get[1].params).toEqual({ + search: mockSearchTerm, + }); + }); + + it('displays dropdown with branches and tags', async () => { + const filteredRefLength = mockFilteredRefs.Tags.length + mockFilteredRefs.Branches.length; + + expect(findRefsDropdownItems()).toHaveLength(filteredRefLength); + }); + }); + }); + + describe('when user has selected a value', () => { + const selectedIndex = 1; + const mockShortName = mockRefs.Branches[selectedIndex]; + const mockFullName = `refs/heads/${mockShortName}`; + + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { + params: { ref: mockFullName }, + }) + .reply(HTTP_STATUS_OK, mockRefs); + + createComponent( + { + value: { + shortName: mockShortName, + fullName: mockFullName, + }, + }, + mountExtended, + ); + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('branch is checked', () => { + expect(findRefsDropdownItems().at(selectedIndex).props('isSelected')).toBe(true); + }); + }); + + describe('when server returns an error', () => { + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { params: { search: '' } }) + .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('loading error event is emitted', () => { + expect(wrapper.emitted('loadingError')).toHaveLength(1); + expect(wrapper.emitted('loadingError')[0]).toEqual([expect.any(Error)]); + }); + }); + + describe('should display branches and tags based on its length', () => { + it.each` + mockData | expectedGroupLength | expectedListboxItemsLength + ${{ ...mockBranches, Tags: [] }} | ${1} | ${mockBranches.Branches.length} + ${{ Branches: [], ...mockTags }} | ${1} | ${mockTags.Tags.length} + ${{ ...mockRefs }} | ${2} | ${mockBranches.Branches.length + mockTags.Tags.length} + ${{ Branches: undefined, Tags: undefined }} | ${0} | ${0} + `( + 'should render branches and tags based on presence', + async ({ mockData, expectedGroupLength, expectedListboxItemsLength }) => { + mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockData); + createComponent({}, mountExtended); + findDropdown().vm.$emit('shown'); + await waitForPromises(); + + expect(findListboxGroups()).toHaveLength(expectedGroupLength); + expect(findRefsDropdownItems()).toHaveLength(expectedListboxItemsLength); + }, + ); + }); +}); diff --git a/spec/frontend/ci/pipeline_new/mock_data.js b/spec/frontend/ci/pipeline_new/mock_data.js new file mode 100644 index 00000000000..dfb643a0ba4 --- /dev/null +++ b/spec/frontend/ci/pipeline_new/mock_data.js @@ -0,0 +1,134 @@ +export const mockBranches = { + Branches: ['main', 'branch-1', 'branch-2'], +}; + +export const mockTags = { + Tags: ['1.0.0', '1.1.0', '1.2.0'], +}; + +export const mockRefs = { + ...mockBranches, + ...mockTags, +}; + +export const mockFilteredRefs = { + Branches: ['branch-1'], + Tags: ['1.0.0', '1.1.0'], +}; + +export const mockQueryParams = { + refParam: 'tag-1', + variableParams: { + test_var: 'test_var_val', + }, + fileParams: { + test_file: 'test_file_val', + }, +}; + +export const mockProjectId = '21'; + +export const mockPostParams = { + ref: 'tag-1', + variables_attributes: [ + { key: 'test_var', secret_value: 'test_var_val', variable_type: 'env_var' }, + { key: 'test_file', secret_value: 'test_file_val', variable_type: 'file' }, + ], +}; + +export const mockError = { + errors: [ + 'test job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post', + ], + warnings: [ + 'jobs:build1 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings', + 'jobs:build2 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings', + 'jobs:build3 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings', + ], + total_warnings: 7, +}; + +export const mockCreditCardValidationRequiredError = { + errors: ['Credit card required to be on file in order to create a pipeline'], + warnings: [], + total_warnings: 0, +}; + +export const mockBranchRefs = ['main', 'dev', 'release']; + +export const mockTagRefs = ['1.0.0', '1.1.0', '1.2.0']; + +export const mockVariables = [ + { + uniqueId: 'var-refs/heads/main2', + variable_type: 'env_var', + key: 'var_without_value', + value: '', + }, + { + uniqueId: 'var-refs/heads/main3', + variable_type: 'env_var', + key: 'var_with_value', + value: 'test_value', + }, + { uniqueId: 'var-refs/heads/main4', variable_type: 'env_var', key: '', value: '' }, +]; + +export const mockYamlVariables = [ + { + description: 'This is a variable with a value.', + key: 'VAR_WITH_VALUE', + value: 'test_value', + valueOptions: null, + }, + { + description: 'This is a variable with a multi-line value.', + key: 'VAR_WITH_MULTILINE', + value: `this is + a multiline value`, + valueOptions: null, + }, + { + description: 'This is a variable with predefined values.', + key: 'VAR_WITH_OPTIONS', + value: 'staging', + valueOptions: ['development', 'staging', 'production'], + }, +]; + +export const mockYamlVariablesWithoutDesc = [ + { + description: 'This is a variable with a value.', + key: 'VAR_WITH_VALUE', + value: 'test_value', + valueOptions: null, + }, + { + description: null, + key: 'VAR_WITH_MULTILINE', + value: `this is + a multiline value`, + valueOptions: null, + }, + { + description: null, + key: 'VAR_WITH_OPTIONS', + value: 'staging', + valueOptions: ['development', 'staging', 'production'], + }, +]; + +export const mockCiConfigVariablesQueryResponse = (ciConfigVariables) => ({ + data: { + project: { + id: 1, + ciConfigVariables, + }, + }, +}); + +export const mockCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse(mockYamlVariables); +export const mockEmptyCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse([]); +export const mockCiConfigVariablesResponseWithoutDesc = mockCiConfigVariablesQueryResponse( + mockYamlVariablesWithoutDesc, +); diff --git a/spec/frontend/ci/pipeline_new/utils/filter_variables_spec.js b/spec/frontend/ci/pipeline_new/utils/filter_variables_spec.js new file mode 100644 index 00000000000..d1b89704b58 --- /dev/null +++ b/spec/frontend/ci/pipeline_new/utils/filter_variables_spec.js @@ -0,0 +1,21 @@ +import filterVariables from '~/ci/pipeline_new/utils/filter_variables'; +import { mockVariables } from '../mock_data'; + +describe('Filter variables utility function', () => { + it('filters variables that do not contain a key', () => { + const expectedVaraibles = [ + { + variable_type: 'env_var', + key: 'var_without_value', + secret_value: '', + }, + { + variable_type: 'env_var', + key: 'var_with_value', + secret_value: 'test_value', + }, + ]; + + expect(filterVariables(mockVariables)).toEqual(expectedVaraibles); + }); +}); diff --git a/spec/frontend/ci/pipeline_new/utils/format_refs_spec.js b/spec/frontend/ci/pipeline_new/utils/format_refs_spec.js new file mode 100644 index 00000000000..137a9339649 --- /dev/null +++ b/spec/frontend/ci/pipeline_new/utils/format_refs_spec.js @@ -0,0 +1,82 @@ +import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '~/ci/pipeline_new/constants'; +import { + formatRefs, + formatListBoxItems, + searchByFullNameInListboxOptions, +} from '~/ci/pipeline_new/utils/format_refs'; +import { mockBranchRefs, mockTagRefs } from '../mock_data'; + +describe('Format refs util', () => { + it('formats branch ref correctly', () => { + expect(formatRefs(mockBranchRefs, BRANCH_REF_TYPE)).toEqual([ + { fullName: 'refs/heads/main', shortName: 'main' }, + { fullName: 'refs/heads/dev', shortName: 'dev' }, + { fullName: 'refs/heads/release', shortName: 'release' }, + ]); + }); + + it('formats tag ref correctly', () => { + expect(formatRefs(mockTagRefs, TAG_REF_TYPE)).toEqual([ + { fullName: 'refs/tags/1.0.0', shortName: '1.0.0' }, + { fullName: 'refs/tags/1.1.0', shortName: '1.1.0' }, + { fullName: 'refs/tags/1.2.0', shortName: '1.2.0' }, + ]); + }); +}); + +describe('formatListBoxItems', () => { + it('formats branches and tags to listbox items correctly', () => { + expect(formatListBoxItems(mockBranchRefs, mockTagRefs)).toEqual([ + { + text: 'Branches', + options: [ + { value: 'refs/heads/main', text: 'main' }, + { value: 'refs/heads/dev', text: 'dev' }, + { value: 'refs/heads/release', text: 'release' }, + ], + }, + { + text: 'Tags', + options: [ + { value: 'refs/tags/1.0.0', text: '1.0.0' }, + { value: 'refs/tags/1.1.0', text: '1.1.0' }, + { value: 'refs/tags/1.2.0', text: '1.2.0' }, + ], + }, + ]); + + expect(formatListBoxItems(mockBranchRefs, [])).toEqual([ + { + text: 'Branches', + options: [ + { value: 'refs/heads/main', text: 'main' }, + { value: 'refs/heads/dev', text: 'dev' }, + { value: 'refs/heads/release', text: 'release' }, + ], + }, + ]); + + expect(formatListBoxItems([], mockTagRefs)).toEqual([ + { + text: 'Tags', + options: [ + { value: 'refs/tags/1.0.0', text: '1.0.0' }, + { value: 'refs/tags/1.1.0', text: '1.1.0' }, + { value: 'refs/tags/1.2.0', text: '1.2.0' }, + ], + }, + ]); + }); +}); + +describe('searchByFullNameInListboxOptions', () => { + const listbox = formatListBoxItems(mockBranchRefs, mockTagRefs); + + it.each` + fullName | expectedResult + ${'refs/heads/main'} | ${{ fullName: 'refs/heads/main', shortName: 'main' }} + ${'refs/tags/1.0.0'} | ${{ fullName: 'refs/tags/1.0.0', shortName: '1.0.0' }} + `('should search item in listbox correctly', ({ fullName, expectedResult }) => { + expect(searchByFullNameInListboxOptions(fullName, listbox)).toEqual(expectedResult); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js index 4aa4cdf89a1..611993556e3 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { trimText } from 'helpers/text_helper'; @@ -10,13 +10,16 @@ import DeletePipelineScheduleModal from '~/ci/pipeline_schedules/components/dele import TakeOwnershipModal from '~/ci/pipeline_schedules/components/take_ownership_modal.vue'; import PipelineSchedulesTable from '~/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue'; import deletePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql'; +import playPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '~/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; import { mockGetPipelineSchedulesGraphQLResponse, mockPipelineScheduleNodes, deleteMutationResponse, + playMutationResponse, takeOwnershipMutationResponse, + emptyPipelineSchedulesResponse, } from '../mock_data'; Vue.use(VueApollo); @@ -29,10 +32,13 @@ describe('Pipeline schedules app', () => { let wrapper; const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); + const successEmptyHandler = jest.fn().mockResolvedValue(emptyPipelineSchedulesResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); const deleteMutationHandlerSuccess = jest.fn().mockResolvedValue(deleteMutationResponse); const deleteMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const playMutationHandlerSuccess = jest.fn().mockResolvedValue(playMutationResponse); + const playMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); const takeOwnershipMutationHandlerSuccess = jest .fn() .mockResolvedValue(takeOwnershipMutationResponse); @@ -60,14 +66,18 @@ describe('Pipeline schedules app', () => { const findTable = () => wrapper.findComponent(PipelineSchedulesTable); const findAlert = () => wrapper.findComponent(GlAlert); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDeleteModal = () => wrapper.findComponent(DeletePipelineScheduleModal); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findTakeOwnershipModal = () => wrapper.findComponent(TakeOwnershipModal); const findTabs = () => wrapper.findComponent(GlTabs); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLink = () => wrapper.findComponent(GlLink); const findNewButton = () => wrapper.findByTestId('new-schedule-button'); const findAllTab = () => wrapper.findByTestId('pipeline-schedules-all-tab'); const findActiveTab = () => wrapper.findByTestId('pipeline-schedules-active-tab'); const findInactiveTab = () => wrapper.findByTestId('pipeline-schedules-inactive-tab'); + const findSchedulesCharacteristics = () => + wrapper.findByTestId('pipeline-schedules-characteristics'); afterEach(() => { wrapper.destroy(); @@ -181,6 +191,45 @@ describe('Pipeline schedules app', () => { }); }); + describe('playing a pipeline schedule', () => { + it('shows play mutation error alert', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [playPipelineScheduleMutation, playMutationHandlerFailed], + ]); + + await waitForPromises(); + + findTable().vm.$emit('playPipelineSchedule'); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was a problem playing the pipeline schedule.'); + }); + + it('plays pipeline schedule', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [playPipelineScheduleMutation, playMutationHandlerSuccess], + ]); + + await waitForPromises(); + + const scheduleId = mockPipelineScheduleNodes[0].id; + + findTable().vm.$emit('playPipelineSchedule', scheduleId); + + await waitForPromises(); + + expect(playMutationHandlerSuccess).toHaveBeenCalledWith({ + id: scheduleId, + }); + expect(findAlert().text()).toBe( + 'Successfully scheduled a pipeline to run. Go to the Pipelines page for details.', + ); + }); + }); + describe('taking ownership of a pipeline schedule', () => { it('shows take ownership mutation error alert', async () => { createComponent([ @@ -277,4 +326,24 @@ describe('Pipeline schedules app', () => { expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalledTimes(1); }); }); + + describe('Empty pipeline schedules response', () => { + it('should show an empty state', async () => { + createComponent([[getPipelineSchedulesQuery, successEmptyHandler]]); + + await waitForPromises(); + + const schedulesCharacteristics = findSchedulesCharacteristics(); + + expect(findEmptyState().exists()).toBe(true); + expect(schedulesCharacteristics.text()).toContain('Runs for a specific branch or tag.'); + expect(schedulesCharacteristics.text()).toContain('Can have custom CI/CD variables.'); + expect(schedulesCharacteristics.text()).toContain( + 'Runs with the same project permissions as the schedule owner.', + ); + + expect(findLink().exists()).toBe(true); + expect(findLink().text()).toContain('scheduled pipelines documentation.'); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js index 3364c61d155..6fb6a8bc33b 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js @@ -25,6 +25,7 @@ describe('Pipeline schedule actions', () => { const findAllButtons = () => wrapper.findAllComponents(GlButton); const findDeleteBtn = () => wrapper.findByTestId('delete-pipeline-schedule-btn'); const findTakeOwnershipBtn = () => wrapper.findByTestId('take-ownership-pipeline-schedule-btn'); + const findPlayScheduleBtn = () => wrapper.findByTestId('play-pipeline-schedule-btn'); afterEach(() => { wrapper.destroy(); @@ -61,4 +62,14 @@ describe('Pipeline schedule actions', () => { showTakeOwnershipModal: [[mockTakeOwnershipNodes[0].id]], }); }); + + it('play button emits playPipelineSchedule event and schedule id', () => { + createComponent(); + + findPlayScheduleBtn().vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({ + playPipelineSchedule: [[mockPipelineScheduleNodes[0].id]], + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js index 17bf465baf3..0821c59c8a0 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js @@ -1,5 +1,5 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import PipelineScheduleLastPipeline from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue'; import { mockPipelineScheduleNodes } from '../../../mock_data'; @@ -18,7 +18,7 @@ describe('Pipeline schedule last pipeline', () => { }); }; - const findCIBadge = () => wrapper.findComponent(CiBadge); + const findCIBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findStatusText = () => wrapper.findByTestId('pipeline-schedule-status-text'); afterEach(() => { @@ -28,8 +28,10 @@ describe('Pipeline schedule last pipeline', () => { it('displays pipeline status', () => { createComponent(); - expect(findCIBadge().exists()).toBe(true); - expect(findCIBadge().props('status')).toBe(defaultProps.schedule.lastPipeline.detailedStatus); + expect(findCIBadgeLink().exists()).toBe(true); + expect(findCIBadgeLink().props('status')).toBe( + defaultProps.schedule.lastPipeline.detailedStatus, + ); expect(findStatusText().exists()).toBe(false); }); @@ -37,6 +39,6 @@ describe('Pipeline schedule last pipeline', () => { createComponent({ schedule: mockPipelineScheduleNodes[0] }); expect(findStatusText().text()).toBe('None'); - expect(findCIBadge().exists()).toBe(false); + expect(findCIBadgeLink().exists()).toBe(false); }); }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 3010f1d06c3..2826c054249 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -32,6 +32,14 @@ export const mockPipelineScheduleNodes = nodes; export const mockPipelineScheduleAsGuestNodes = guestNodes; export const mockTakeOwnershipNodes = takeOwnershipNodes; +export const emptyPipelineSchedulesResponse = { + data: { + project: { + id: 'gid://gitlab/Project/1', + pipelineSchedules: { nodes: [], count: 0 }, + }, + }, +}; export const deleteMutationResponse = { data: { @@ -43,6 +51,16 @@ export const deleteMutationResponse = { }, }; +export const playMutationResponse = { + data: { + pipelineSchedulePlay: { + clientMutationId: null, + errors: [], + __typename: 'PipelineSchedulePlayPayload', + }, + }, +}; + export const takeOwnershipMutationResponse = { data: { pipelineScheduleTakeOwnership: { diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js index cb46c668930..0ecafdd7d83 100644 --- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -13,12 +13,12 @@ import RegistrationTokenResetDropdownItem from '~/ci/runner/components/registrat import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/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 getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql'; import { - mockGraphqlRunnerPlatforms, - mockGraphqlInstructions, + mockRunnerPlatforms, + mockInstructions, } from 'jest/vue_shared/components/runner_instructions/mock_data'; const mockToken = '0123456789'; @@ -67,8 +67,8 @@ describe('RegistrationDropdown', () => { const createComponentWithModal = () => { const requestHandlers = [ - [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], - [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockRunnerPlatforms)], + [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockInstructions)], ]; createComponent( |