summaryrefslogtreecommitdiff
path: root/spec/frontend/ci
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/ci')
-rw-r--r--spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js159
-rw-r--r--spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js40
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js36
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js118
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js73
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js46
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js520
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js147
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js450
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js172
-rw-r--r--spec/frontend/ci/ci_variable_list/mocks.js213
-rw-r--r--spec/frontend/ci/ci_variable_list/services/mock_data.js156
-rw-r--r--spec/frontend/ci/ci_variable_list/stubs.js14
-rw-r--r--spec/frontend/ci/ci_variable_list/utils_spec.js78
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js29
-rw-r--r--spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js468
-rw-r--r--spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js201
-rw-r--r--spec/frontend/ci/pipeline_new/mock_data.js134
-rw-r--r--spec/frontend/ci/pipeline_new/utils/filter_variables_spec.js21
-rw-r--r--spec/frontend/ci/pipeline_new/utils/format_refs_spec.js82
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js73
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js11
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js12
-rw-r--r--spec/frontend/ci/pipeline_schedules/mock_data.js18
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js12
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(