summaryrefslogtreecommitdiff
path: root/spec/frontend
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-10-22 11:31:16 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2019-10-22 11:31:16 +0000
commit905c1110b08f93a19661cf42a276c7ea90d0a0ff (patch)
tree756d138db422392c00471ab06acdff92c5a9b69c /spec/frontend
parent50d93f8d1686950fc58dda4823c4835fd0d8c14b (diff)
downloadgitlab-ce-905c1110b08f93a19661cf42a276c7ea90d0a0ff.tar.gz
Add latest changes from gitlab-org/gitlab@12-4-stable-ee
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/clusters/stores/clusters_store_spec.js3
-rw-r--r--spec/frontend/commit/commit_pipeline_status_component_spec.js179
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js25
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js457
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js55
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/role_name_dropdown_spec.js43
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/actions_spec.js60
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/actions_spec.js95
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/mutations_spec.js36
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js62
-rw-r--r--spec/frontend/error_tracking/utils_spec.js27
-rw-r--r--spec/frontend/fixtures/abuse_reports.rb2
-rw-r--r--spec/frontend/fixtures/admin_users.rb2
-rw-r--r--spec/frontend/fixtures/application_settings.rb2
-rw-r--r--spec/frontend/fixtures/autocomplete_sources.rb4
-rw-r--r--spec/frontend/fixtures/blob.rb2
-rw-r--r--spec/frontend/fixtures/boards.rb2
-rw-r--r--spec/frontend/fixtures/branches.rb2
-rw-r--r--spec/frontend/fixtures/clusters.rb2
-rw-r--r--spec/frontend/fixtures/commit.rb2
-rw-r--r--spec/frontend/fixtures/deploy_keys.rb2
-rw-r--r--spec/frontend/fixtures/groups.rb2
-rw-r--r--spec/frontend/fixtures/issues.rb2
-rw-r--r--spec/frontend/fixtures/jobs.rb2
-rw-r--r--spec/frontend/fixtures/labels.rb2
-rw-r--r--spec/frontend/fixtures/merge_requests.rb2
-rw-r--r--spec/frontend/fixtures/merge_requests_diffs.rb2
-rw-r--r--spec/frontend/fixtures/pipeline_schedules.rb2
-rw-r--r--spec/frontend/fixtures/pipelines.rb2
-rw-r--r--spec/frontend/fixtures/projects.rb2
-rw-r--r--spec/frontend/fixtures/prometheus_service.rb2
-rw-r--r--spec/frontend/fixtures/raw.rb2
-rw-r--r--spec/frontend/fixtures/search.rb2
-rw-r--r--spec/frontend/fixtures/services.rb2
-rw-r--r--spec/frontend/fixtures/sessions.rb2
-rw-r--r--spec/frontend/fixtures/snippet.rb2
-rw-r--r--spec/frontend/fixtures/static/environments_logs.html109
-rw-r--r--spec/frontend/fixtures/todos.rb2
-rw-r--r--spec/frontend/fixtures/u2f.rb2
-rw-r--r--spec/frontend/helpers/dom_shims/README.md12
-rw-r--r--spec/frontend/helpers/dom_shims/get_client_rects.js50
-rw-r--r--spec/frontend/helpers/dom_shims/get_client_rects_spec.js71
-rw-r--r--spec/frontend/helpers/dom_shims/index.js1
-rw-r--r--spec/frontend/helpers/test_constants.js7
-rw-r--r--spec/frontend/helpers/tracking_helper.js25
-rw-r--r--spec/frontend/helpers/vue_resource_helper.js11
-rw-r--r--spec/frontend/ide/components/branches/search_list_spec.js81
-rw-r--r--spec/frontend/ide/components/error_message_spec.js122
-rw-r--r--spec/frontend/ide/components/file_templates/dropdown_spec.js175
-rw-r--r--spec/frontend/ide/components/jobs/list_spec.js115
-rw-r--r--spec/frontend/ide/components/merge_requests/list_spec.js214
-rw-r--r--spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap15
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js193
-rw-r--r--spec/frontend/ide/lib/files_spec.js4
-rw-r--r--spec/frontend/ide/mock_data.js228
-rw-r--r--spec/frontend/ide/stores/integration_spec.js100
-rw-r--r--spec/frontend/import_projects/components/import_projects_table_spec.js8
-rw-r--r--spec/frontend/import_projects/store/actions_spec.js64
-rw-r--r--spec/frontend/issue_show/store_spec.js39
-rw-r--r--spec/frontend/issue_show/utils/update_description_spec.js24
-rw-r--r--spec/frontend/jobs/components/log/collapsible_section_spec.js73
-rw-r--r--spec/frontend/jobs/components/log/mock_data.js64
-rw-r--r--spec/frontend/jobs/store/mutations_spec.js76
-rw-r--r--spec/frontend/jobs/store/utils_spec.js350
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js29
-rw-r--r--spec/frontend/lib/utils/number_utility_spec.js13
-rw-r--r--spec/frontend/lib/utils/set_spec.js19
-rw-r--r--spec/frontend/lib/utils/suppress_ajax_errors_during_navigation_spec.js37
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js18
-rw-r--r--spec/frontend/monitoring/components/date_time_picker/date_time_picker_input_spec.js66
-rw-r--r--spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js157
-rw-r--r--spec/frontend/monitoring/embed/embed_spec.js4
-rw-r--r--spec/frontend/monitoring/utils_spec.js54
-rw-r--r--spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap1
-rw-r--r--spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap63
-rw-r--r--spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap33
-rw-r--r--spec/frontend/pages/admin/users/components/delete_user_modal_spec.js85
-rw-r--r--spec/frontend/pages/admin/users/components/stubs/modal_stub.js23
-rw-r--r--spec/frontend/pages/admin/users/components/user_modal_manager_spec.js148
-rw-r--r--spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js47
-rw-r--r--spec/frontend/performance_bar/components/detailed_metric_spec.js111
-rw-r--r--spec/frontend/performance_bar/components/performance_bar_app_spec.js20
-rw-r--r--spec/frontend/performance_bar/components/request_selector_spec.js64
-rw-r--r--spec/frontend/performance_bar/components/request_warning_spec.js33
-rw-r--r--spec/frontend/registry/components/__snapshots__/group_empty_state_spec.js.snap61
-rw-r--r--spec/frontend/registry/components/__snapshots__/project_empty_state_spec.js.snap186
-rw-r--r--spec/frontend/registry/components/app_spec.js160
-rw-r--r--spec/frontend/registry/components/collapsible_container_spec.js127
-rw-r--r--spec/frontend/registry/components/group_empty_state_spec.js23
-rw-r--r--spec/frontend/registry/components/project_empty_state_spec.js27
-rw-r--r--spec/frontend/registry/components/table_registry_spec.js268
-rw-r--r--spec/frontend/registry/mock_data.js134
-rw-r--r--spec/frontend/registry/stores/actions_spec.js203
-rw-r--r--spec/frontend/registry/stores/getters_spec.js (renamed from spec/frontend/registry/getters_spec.js)6
-rw-r--r--spec/frontend/registry/stores/mutations_spec.js94
-rw-r--r--spec/frontend/releases/components/milestone_list_spec.js56
-rw-r--r--spec/frontend/releases/components/release_block_spec.js120
-rw-r--r--spec/frontend/releases/detail/components/app_spec.js70
-rw-r--r--spec/frontend/releases/detail/store/actions_spec.js217
-rw-r--r--spec/frontend/releases/detail/store/mutations_spec.js119
-rw-r--r--spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap332
-rw-r--r--spec/frontend/releases/list/components/release_block_spec.js266
-rw-r--r--spec/frontend/releases/mock_data.js7
-rw-r--r--spec/frontend/reports/store/utils_spec.js14
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap107
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js6
-rw-r--r--spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap229
-rw-r--r--spec/frontend/sidebar/__snapshots__/todo_spec.js.snap37
-rw-r--r--spec/frontend/sidebar/confidential_issue_sidebar_spec.js167
-rw-r--r--spec/frontend/sidebar/todo_spec.js93
-rw-r--r--spec/frontend/test_setup.js2
-rw-r--r--spec/frontend/tracking_spec.js81
-rw-r--r--spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js121
-rw-r--r--spec/frontend/vue_mr_widget/components/artifacts_list_spec.js61
-rw-r--r--spec/frontend/vue_mr_widget/components/mock_data.js15
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js99
-rw-r--r--spec/frontend/vue_mr_widget/stores/artifacts_list/getters_spec.js32
-rw-r--r--spec/frontend/vue_mr_widget/stores/artifacts_list/mutations_spec.js78
-rw-r--r--spec/frontend/vue_shared/components/changed_file_icon_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/file_icon_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/modal_copy_button_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js21
-rw-r--r--spec/frontend/vue_shared/components/recaptcha_modal_spec.js36
-rw-r--r--spec/frontend/vue_shared/directives/track_event_spec.js49
-rw-r--r--spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js42
-rw-r--r--spec/frontend/vue_shared/mixins/gl_feature_flags_mixin_spec.js36
-rw-r--r--spec/frontend/vue_shared/plugins/global_toast_spec.js10
127 files changed, 7810 insertions, 365 deletions
diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js
index ee3b7d8aa90..5ee06eb44c9 100644
--- a/spec/frontend/clusters/stores/clusters_store_spec.js
+++ b/spec/frontend/clusters/stores/clusters_store_spec.js
@@ -54,8 +54,11 @@ describe('Clusters Store', () => {
environmentsHelpPath: null,
clustersHelpPath: null,
deployBoardsHelpPath: null,
+ cloudRunHelpPath: null,
status: mockResponseData.status,
statusReason: mockResponseData.status_reason,
+ providerType: null,
+ preInstalledKnative: false,
rbac: false,
applications: {
helm: {
diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js
new file mode 100644
index 00000000000..1736d1d0df8
--- /dev/null
+++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js
@@ -0,0 +1,179 @@
+import Visibility from 'visibilityjs';
+import { GlLoadingIcon } from '@gitlab/ui';
+import Poll from '~/lib/utils/poll';
+import flash from '~/flash';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import { shallowMount } from '@vue/test-utils';
+import { getJSONFixture } from '../helpers/fixtures';
+
+jest.mock('~/lib/utils/poll');
+jest.mock('visibilityjs');
+jest.mock('~/flash');
+
+const mockFetchData = jest.fn();
+jest.mock('~/projects/tree/services/commit_pipeline_service', () =>
+ jest.fn().mockImplementation(() => ({
+ fetchData: mockFetchData.mockReturnValue(Promise.resolve()),
+ })),
+);
+
+describe('Commit pipeline status component', () => {
+ let wrapper;
+ const { pipelines } = getJSONFixture('pipelines/pipelines.json');
+ const { status: mockCiStatus } = pipelines[0].details;
+
+ const defaultProps = {
+ endpoint: 'endpoint',
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(CommitPipelineStatus, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ sync: false,
+ });
+ };
+
+ const findLoader = () => wrapper.find(GlLoadingIcon);
+ const findLink = () => wrapper.find('a');
+ const findCiIcon = () => findLink().find(CiIcon);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ jest.clearAllMocks();
+ });
+
+ describe('Visibility management', () => {
+ describe('when component is hidden', () => {
+ beforeEach(() => {
+ Visibility.hidden.mockReturnValue(true);
+ createComponent();
+ });
+
+ it('does not start polling', () => {
+ const [pollInstance] = Poll.mock.instances;
+ expect(pollInstance.makeRequest).not.toHaveBeenCalled();
+ });
+
+ it('requests pipeline data', () => {
+ expect(mockFetchData).toHaveBeenCalled();
+ });
+ });
+
+ describe('when component is visible', () => {
+ beforeEach(() => {
+ Visibility.hidden.mockReturnValue(false);
+ createComponent();
+ });
+
+ it('starts polling', () => {
+ const [pollInstance] = [...Poll.mock.instances].reverse();
+ expect(pollInstance.makeRequest).toHaveBeenCalled();
+ });
+ });
+
+ describe('when component changes its visibility', () => {
+ it.each`
+ visibility | action
+ ${false} | ${'restart'}
+ ${true} | ${'stop'}
+ `(
+ '$action polling when component visibility becomes $visibility',
+ ({ visibility, action }) => {
+ Visibility.hidden.mockReturnValue(!visibility);
+ createComponent();
+ const [pollInstance] = Poll.mock.instances;
+ expect(pollInstance[action]).not.toHaveBeenCalled();
+ Visibility.hidden.mockReturnValue(visibility);
+ const [visibilityHandler] = Visibility.change.mock.calls[0];
+ visibilityHandler();
+ expect(pollInstance[action]).toHaveBeenCalled();
+ },
+ );
+ });
+ });
+
+ it('stops polling when component is destroyed', () => {
+ createComponent();
+ wrapper.destroy();
+ const [pollInstance] = Poll.mock.instances;
+ expect(pollInstance.stop).toHaveBeenCalled();
+ });
+
+ describe('when polling', () => {
+ let pollConfig;
+ beforeEach(() => {
+ Poll.mockImplementation(config => {
+ pollConfig = config;
+ return { makeRequest: jest.fn(), restart: jest.fn(), stop: jest.fn() };
+ });
+ createComponent();
+ });
+
+ it('shows the loading icon at start', () => {
+ createComponent();
+ expect(findLoader().exists()).toBe(true);
+
+ pollConfig.successCallback({
+ data: { pipelines: [] },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findLoader().exists()).toBe(false);
+ });
+ });
+
+ describe('is successful', () => {
+ beforeEach(() => {
+ pollConfig.successCallback({
+ data: { pipelines: [{ details: { status: mockCiStatus } }] },
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('does not render loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders link with href', () => {
+ expect(findLink().attributes('href')).toEqual(mockCiStatus.details_path);
+ });
+
+ it('renders CI icon', () => {
+ expect(findCiIcon().attributes('data-original-title')).toEqual('Pipeline: pending');
+ expect(findCiIcon().props('status')).toEqual(mockCiStatus);
+ });
+ });
+
+ describe('is not successful', () => {
+ beforeEach(() => {
+ pollConfig.errorCallback();
+ });
+
+ it('does not render loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders link with href', () => {
+ expect(findLink().attributes('href')).toBeUndefined();
+ });
+
+ it('renders not found CI icon', () => {
+ expect(findCiIcon().attributes('data-original-title')).toEqual('Pipeline: not found');
+ expect(findCiIcon().props('status')).toEqual({
+ text: 'not found',
+ icon: 'status_notfound',
+ group: 'notfound',
+ });
+ });
+
+ it('displays flash error message', () => {
+ expect(flash).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js
index e873ef0b2fa..366c2fc7b26 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js
@@ -7,12 +7,22 @@ import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidde
describe('ClusterFormDropdown', () => {
let vm;
+ const firstItem = { name: 'item 1', value: 1 };
+ const secondItem = { name: 'item 2', value: 2 };
+ const items = [firstItem, secondItem, { name: 'item 3', value: 3 }];
beforeEach(() => {
vm = shallowMount(ClusterFormDropdown);
});
afterEach(() => vm.destroy());
+ describe('when initial value is provided', () => {
+ it('sets selectedItem to initial value', () => {
+ vm.setProps({ items, value: secondItem.value });
+ expect(vm.find(DropdownButton).props('toggleText')).toEqual(secondItem.name);
+ });
+ });
+
describe('when no item is selected', () => {
it('displays placeholder text', () => {
const placeholder = 'placeholder';
@@ -24,18 +34,19 @@ describe('ClusterFormDropdown', () => {
});
describe('when an item is selected', () => {
- const selectedItem = { name: 'Name', value: 'value' };
-
beforeEach(() => {
- vm.setData({ selectedItem });
+ vm.setProps({ items });
+ vm.findAll('.js-dropdown-item')
+ .at(1)
+ .trigger('click');
});
it('displays selected item label', () => {
- expect(vm.find(DropdownButton).props('toggleText')).toEqual(selectedItem.name);
+ expect(vm.find(DropdownButton).props('toggleText')).toEqual(secondItem.name);
});
it('sets selected value to dropdown hidden input', () => {
- expect(vm.find(DropdownHiddenInput).props('value')).toEqual(selectedItem.value);
+ expect(vm.find(DropdownHiddenInput).props('value')).toEqual(secondItem.value);
});
});
@@ -124,9 +135,7 @@ describe('ClusterFormDropdown', () => {
});
it('it filters results by search query', () => {
- const secondItem = { name: 'second item' };
- const items = [{ name: 'first item' }, secondItem];
- const searchQuery = 'second';
+ const searchQuery = secondItem.name;
vm.setProps({ items });
vm.setData({ searchQuery });
diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
new file mode 100644
index 00000000000..69290f6dfa9
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
@@ -0,0 +1,457 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import Vue from 'vue';
+import { GlFormCheckbox } from '@gitlab/ui';
+
+import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue';
+import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue';
+import eksClusterFormState from '~/create_cluster/eks_cluster/store/state';
+import clusterDropdownStoreState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('EksClusterConfigurationForm', () => {
+ let store;
+ let actions;
+ let state;
+ let rolesState;
+ let regionsState;
+ let vpcsState;
+ let subnetsState;
+ let keyPairsState;
+ let securityGroupsState;
+ let vpcsActions;
+ let rolesActions;
+ let regionsActions;
+ let subnetsActions;
+ let keyPairsActions;
+ let securityGroupsActions;
+ let vm;
+
+ beforeEach(() => {
+ state = eksClusterFormState();
+ actions = {
+ setClusterName: jest.fn(),
+ setEnvironmentScope: jest.fn(),
+ setKubernetesVersion: jest.fn(),
+ setRegion: jest.fn(),
+ setVpc: jest.fn(),
+ setSubnet: jest.fn(),
+ setRole: jest.fn(),
+ setKeyPair: jest.fn(),
+ setSecurityGroup: jest.fn(),
+ setGitlabManagedCluster: jest.fn(),
+ };
+ regionsActions = {
+ fetchItems: jest.fn(),
+ };
+ keyPairsActions = {
+ fetchItems: jest.fn(),
+ };
+ vpcsActions = {
+ fetchItems: jest.fn(),
+ };
+ subnetsActions = {
+ fetchItems: jest.fn(),
+ };
+ rolesActions = {
+ fetchItems: jest.fn(),
+ };
+ securityGroupsActions = {
+ fetchItems: jest.fn(),
+ };
+ rolesState = {
+ ...clusterDropdownStoreState(),
+ };
+ regionsState = {
+ ...clusterDropdownStoreState(),
+ };
+ vpcsState = {
+ ...clusterDropdownStoreState(),
+ };
+ subnetsState = {
+ ...clusterDropdownStoreState(),
+ };
+ keyPairsState = {
+ ...clusterDropdownStoreState(),
+ };
+ securityGroupsState = {
+ ...clusterDropdownStoreState(),
+ };
+ store = new Vuex.Store({
+ state,
+ actions,
+ modules: {
+ vpcs: {
+ namespaced: true,
+ state: vpcsState,
+ actions: vpcsActions,
+ },
+ regions: {
+ namespaced: true,
+ state: regionsState,
+ actions: regionsActions,
+ },
+ subnets: {
+ namespaced: true,
+ state: subnetsState,
+ actions: subnetsActions,
+ },
+ roles: {
+ namespaced: true,
+ state: rolesState,
+ actions: rolesActions,
+ },
+ keyPairs: {
+ namespaced: true,
+ state: keyPairsState,
+ actions: keyPairsActions,
+ },
+ securityGroups: {
+ namespaced: true,
+ state: securityGroupsState,
+ actions: securityGroupsActions,
+ },
+ },
+ });
+ });
+
+ beforeEach(() => {
+ vm = shallowMount(EksClusterConfigurationForm, {
+ localVue,
+ store,
+ propsData: {
+ gitlabManagedClusterHelpPath: '',
+ kubernetesIntegrationHelpPath: '',
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ const findClusterNameInput = () => vm.find('[id=eks-cluster-name]');
+ const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]');
+ const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]');
+ const findRegionDropdown = () => vm.find(RegionDropdown);
+ const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]');
+ const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]');
+ const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]');
+ const findRoleDropdown = () => vm.find('[field-id="eks-role"]');
+ const findSecurityGroupDropdown = () => vm.find('[field-id="eks-security-group"]');
+ const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox);
+
+ describe('when mounted', () => {
+ it('fetches available regions', () => {
+ expect(regionsActions.fetchItems).toHaveBeenCalled();
+ });
+
+ it('fetches available roles', () => {
+ expect(rolesActions.fetchItems).toHaveBeenCalled();
+ });
+ });
+
+ it('sets isLoadingRoles to RoleDropdown loading property', () => {
+ rolesState.isLoadingItems = true;
+
+ return Vue.nextTick().then(() => {
+ expect(findRoleDropdown().props('loading')).toBe(rolesState.isLoadingItems);
+ });
+ });
+
+ it('sets roles to RoleDropdown items property', () => {
+ expect(findRoleDropdown().props('items')).toBe(rolesState.items);
+ });
+
+ it('sets RoleDropdown hasErrors to true when loading roles failed', () => {
+ rolesState.loadingItemsError = new Error();
+
+ expect(findRoleDropdown().props('hasErrors')).toEqual(true);
+ });
+
+ it('sets isLoadingRegions to RegionDropdown loading property', () => {
+ regionsState.isLoadingItems = true;
+
+ return Vue.nextTick().then(() => {
+ expect(findRegionDropdown().props('loading')).toBe(regionsState.isLoadingItems);
+ });
+ });
+
+ it('sets regions to RegionDropdown regions property', () => {
+ expect(findRegionDropdown().props('regions')).toBe(regionsState.items);
+ });
+
+ it('sets loadingRegionsError to RegionDropdown error property', () => {
+ expect(findRegionDropdown().props('error')).toBe(regionsState.loadingItemsError);
+ });
+
+ it('disables KeyPairDropdown when no region is selected', () => {
+ expect(findKeyPairDropdown().props('disabled')).toBe(true);
+ });
+
+ it('enables KeyPairDropdown when no region is selected', () => {
+ state.selectedRegion = { name: 'west-1 ' };
+
+ return Vue.nextTick().then(() => {
+ expect(findKeyPairDropdown().props('disabled')).toBe(false);
+ });
+ });
+
+ it('sets isLoadingKeyPairs to KeyPairDropdown loading property', () => {
+ keyPairsState.isLoadingItems = true;
+
+ return Vue.nextTick().then(() => {
+ expect(findKeyPairDropdown().props('loading')).toBe(keyPairsState.isLoadingItems);
+ });
+ });
+
+ it('sets keyPairs to KeyPairDropdown items property', () => {
+ expect(findKeyPairDropdown().props('items')).toBe(keyPairsState.items);
+ });
+
+ it('sets KeyPairDropdown hasErrors to true when loading key pairs fails', () => {
+ keyPairsState.loadingItemsError = new Error();
+
+ expect(findKeyPairDropdown().props('hasErrors')).toEqual(true);
+ });
+
+ it('disables VpcDropdown when no region is selected', () => {
+ expect(findVpcDropdown().props('disabled')).toBe(true);
+ });
+
+ it('enables VpcDropdown when no region is selected', () => {
+ state.selectedRegion = { name: 'west-1 ' };
+
+ return Vue.nextTick().then(() => {
+ expect(findVpcDropdown().props('disabled')).toBe(false);
+ });
+ });
+
+ it('sets isLoadingVpcs to VpcDropdown loading property', () => {
+ vpcsState.isLoadingItems = true;
+
+ return Vue.nextTick().then(() => {
+ expect(findVpcDropdown().props('loading')).toBe(vpcsState.isLoadingItems);
+ });
+ });
+
+ it('sets vpcs to VpcDropdown items property', () => {
+ expect(findVpcDropdown().props('items')).toBe(vpcsState.items);
+ });
+
+ it('sets VpcDropdown hasErrors to true when loading vpcs fails', () => {
+ vpcsState.loadingItemsError = new Error();
+
+ expect(findVpcDropdown().props('hasErrors')).toEqual(true);
+ });
+
+ it('disables SubnetDropdown when no vpc is selected', () => {
+ expect(findSubnetDropdown().props('disabled')).toBe(true);
+ });
+
+ it('enables SubnetDropdown when a vpc is selected', () => {
+ state.selectedVpc = { name: 'vpc-1 ' };
+
+ return Vue.nextTick().then(() => {
+ expect(findSubnetDropdown().props('disabled')).toBe(false);
+ });
+ });
+
+ it('sets isLoadingSubnets to SubnetDropdown loading property', () => {
+ subnetsState.isLoadingItems = true;
+
+ return Vue.nextTick().then(() => {
+ expect(findSubnetDropdown().props('loading')).toBe(subnetsState.isLoadingItems);
+ });
+ });
+
+ it('sets subnets to SubnetDropdown items property', () => {
+ expect(findSubnetDropdown().props('items')).toBe(subnetsState.items);
+ });
+
+ it('sets SubnetDropdown hasErrors to true when loading subnets fails', () => {
+ subnetsState.loadingItemsError = new Error();
+
+ expect(findSubnetDropdown().props('hasErrors')).toEqual(true);
+ });
+
+ it('disables SecurityGroupDropdown when no vpc is selected', () => {
+ expect(findSecurityGroupDropdown().props('disabled')).toBe(true);
+ });
+
+ it('enables SecurityGroupDropdown when a vpc is selected', () => {
+ state.selectedVpc = { name: 'vpc-1 ' };
+
+ return Vue.nextTick().then(() => {
+ expect(findSecurityGroupDropdown().props('disabled')).toBe(false);
+ });
+ });
+
+ it('sets isLoadingSecurityGroups to SecurityGroupDropdown loading property', () => {
+ securityGroupsState.isLoadingItems = true;
+
+ return Vue.nextTick().then(() => {
+ expect(findSecurityGroupDropdown().props('loading')).toBe(securityGroupsState.isLoadingItems);
+ });
+ });
+
+ it('sets securityGroups to SecurityGroupDropdown items property', () => {
+ expect(findSecurityGroupDropdown().props('items')).toBe(securityGroupsState.items);
+ });
+
+ it('sets SecurityGroupDropdown hasErrors to true when loading security groups fails', () => {
+ securityGroupsState.loadingItemsError = new Error();
+
+ expect(findSecurityGroupDropdown().props('hasErrors')).toEqual(true);
+ });
+
+ describe('when region is selected', () => {
+ const region = { name: 'us-west-2' };
+
+ beforeEach(() => {
+ findRegionDropdown().vm.$emit('input', region);
+ });
+
+ it('dispatches setRegion action', () => {
+ expect(actions.setRegion).toHaveBeenCalledWith(expect.anything(), { region }, undefined);
+ });
+
+ it('fetches available vpcs', () => {
+ expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }, undefined);
+ });
+
+ it('fetches available key pairs', () => {
+ expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(
+ expect.anything(),
+ { region },
+ undefined,
+ );
+ });
+ });
+
+ it('dispatches setClusterName when cluster name input changes', () => {
+ const clusterName = 'name';
+
+ findClusterNameInput().vm.$emit('input', clusterName);
+
+ expect(actions.setClusterName).toHaveBeenCalledWith(
+ expect.anything(),
+ { clusterName },
+ undefined,
+ );
+ });
+
+ it('dispatches setEnvironmentScope when environment scope input changes', () => {
+ const environmentScope = 'production';
+
+ findEnvironmentScopeInput().vm.$emit('input', environmentScope);
+
+ expect(actions.setEnvironmentScope).toHaveBeenCalledWith(
+ expect.anything(),
+ { environmentScope },
+ undefined,
+ );
+ });
+
+ it('dispatches setKubernetesVersion when kubernetes version dropdown changes', () => {
+ const kubernetesVersion = { name: '1.11' };
+
+ findKubernetesVersionDropdown().vm.$emit('input', kubernetesVersion);
+
+ expect(actions.setKubernetesVersion).toHaveBeenCalledWith(
+ expect.anything(),
+ { kubernetesVersion },
+ undefined,
+ );
+ });
+
+ it('dispatches setGitlabManagedCluster when gitlab managed cluster input changes', () => {
+ const gitlabManagedCluster = false;
+
+ findGitlabManagedClusterCheckbox().vm.$emit('input', gitlabManagedCluster);
+
+ expect(actions.setGitlabManagedCluster).toHaveBeenCalledWith(
+ expect.anything(),
+ { gitlabManagedCluster },
+ undefined,
+ );
+ });
+
+ describe('when vpc is selected', () => {
+ const vpc = { name: 'vpc-1' };
+
+ beforeEach(() => {
+ findVpcDropdown().vm.$emit('input', vpc);
+ });
+
+ it('dispatches setVpc action', () => {
+ expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined);
+ });
+
+ it('dispatches fetchSubnets action', () => {
+ expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined);
+ });
+
+ it('dispatches fetchSecurityGroups action', () => {
+ expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith(
+ expect.anything(),
+ { vpc },
+ undefined,
+ );
+ });
+ });
+
+ describe('when a subnet is selected', () => {
+ const subnet = { name: 'subnet-1' };
+
+ beforeEach(() => {
+ findSubnetDropdown().vm.$emit('input', subnet);
+ });
+
+ it('dispatches setSubnet action', () => {
+ expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet }, undefined);
+ });
+ });
+
+ describe('when role is selected', () => {
+ const role = { name: 'admin' };
+
+ beforeEach(() => {
+ findRoleDropdown().vm.$emit('input', role);
+ });
+
+ it('dispatches setRole action', () => {
+ expect(actions.setRole).toHaveBeenCalledWith(expect.anything(), { role }, undefined);
+ });
+ });
+
+ describe('when key pair is selected', () => {
+ const keyPair = { name: 'key pair' };
+
+ beforeEach(() => {
+ findKeyPairDropdown().vm.$emit('input', keyPair);
+ });
+
+ it('dispatches setKeyPair action', () => {
+ expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair }, undefined);
+ });
+ });
+
+ describe('when security group is selected', () => {
+ const securityGroup = { name: 'default group' };
+
+ beforeEach(() => {
+ findSecurityGroupDropdown().vm.$emit('input', securityGroup);
+ });
+
+ it('dispatches setSecurityGroup action', () => {
+ expect(actions.setSecurityGroup).toHaveBeenCalledWith(
+ expect.anything(),
+ { securityGroup },
+ undefined,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js
new file mode 100644
index 00000000000..0ebb5026a4b
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js
@@ -0,0 +1,55 @@
+import { shallowMount } from '@vue/test-utils';
+
+import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue';
+import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue';
+
+describe('RegionDropdown', () => {
+ let vm;
+
+ const getClusterFormDropdown = () => vm.find(ClusterFormDropdown);
+
+ beforeEach(() => {
+ vm = shallowMount(RegionDropdown);
+ });
+ afterEach(() => vm.destroy());
+
+ it('renders a cluster-form-dropdown', () => {
+ expect(getClusterFormDropdown().exists()).toBe(true);
+ });
+
+ it('sets regions to cluster-form-dropdown items property', () => {
+ const regions = [{ name: 'basic' }];
+
+ vm.setProps({ regions });
+
+ expect(getClusterFormDropdown().props('items')).toEqual(regions);
+ });
+
+ it('sets a loading text', () => {
+ expect(getClusterFormDropdown().props('loadingText')).toEqual('Loading Regions');
+ });
+
+ it('sets a placeholder', () => {
+ expect(getClusterFormDropdown().props('placeholder')).toEqual('Select a region');
+ });
+
+ it('sets an empty results text', () => {
+ expect(getClusterFormDropdown().props('emptyText')).toEqual('No region found');
+ });
+
+ it('sets a search field placeholder', () => {
+ expect(getClusterFormDropdown().props('searchFieldPlaceholder')).toEqual('Search regions');
+ });
+
+ it('sets hasErrors property', () => {
+ vm.setProps({ error: {} });
+
+ expect(getClusterFormDropdown().props('hasErrors')).toEqual(true);
+ });
+
+ it('sets an error message', () => {
+ expect(getClusterFormDropdown().props('errorMessage')).toEqual(
+ 'Could not load regions from your AWS account',
+ );
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/role_name_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/role_name_dropdown_spec.js
deleted file mode 100644
index 657637c1b56..00000000000
--- a/spec/frontend/create_cluster/eks_cluster/components/role_name_dropdown_spec.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-
-import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue';
-import RoleNameDropdown from '~/create_cluster/eks_cluster/components/role_name_dropdown.vue';
-
-describe('RoleNameDropdown', () => {
- let vm;
-
- beforeEach(() => {
- vm = shallowMount(RoleNameDropdown);
- });
- afterEach(() => vm.destroy());
-
- it('renders a cluster-form-dropdown', () => {
- expect(vm.find(ClusterFormDropdown).exists()).toBe(true);
- });
-
- it('sets roles to cluster-form-dropdown items property', () => {
- const roles = [{ name: 'basic' }];
-
- vm.setProps({ roles });
-
- expect(vm.find(ClusterFormDropdown).props('items')).toEqual(roles);
- });
-
- it('sets a loading text', () => {
- expect(vm.find(ClusterFormDropdown).props('loadingText')).toEqual('Loading IAM Roles');
- });
-
- it('sets a placeholder', () => {
- expect(vm.find(ClusterFormDropdown).props('placeholder')).toEqual('Select role name');
- });
-
- it('sets an empty results text', () => {
- expect(vm.find(ClusterFormDropdown).props('emptyText')).toEqual('No IAM Roles found');
- });
-
- it('sets a search field placeholder', () => {
- expect(vm.find(ClusterFormDropdown).props('searchFieldPlaceholder')).toEqual(
- 'Search IAM Roles',
- );
- });
-});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
new file mode 100644
index 00000000000..1ed7f806804
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
@@ -0,0 +1,60 @@
+import testAction from 'helpers/vuex_action_helper';
+
+import createState from '~/create_cluster/eks_cluster/store/state';
+import * as actions from '~/create_cluster/eks_cluster/store/actions';
+import {
+ SET_CLUSTER_NAME,
+ SET_ENVIRONMENT_SCOPE,
+ SET_KUBERNETES_VERSION,
+ SET_REGION,
+ SET_VPC,
+ SET_KEY_PAIR,
+ SET_SUBNET,
+ SET_ROLE,
+ SET_SECURITY_GROUP,
+ SET_GITLAB_MANAGED_CLUSTER,
+} from '~/create_cluster/eks_cluster/store/mutation_types';
+
+describe('EKS Cluster Store Actions', () => {
+ let clusterName;
+ let environmentScope;
+ let kubernetesVersion;
+ let region;
+ let vpc;
+ let subnet;
+ let role;
+ let keyPair;
+ let securityGroup;
+ let gitlabManagedCluster;
+
+ beforeEach(() => {
+ clusterName = 'my cluster';
+ environmentScope = 'production';
+ kubernetesVersion = '11.1';
+ region = { name: 'regions-1' };
+ vpc = { name: 'vpc-1' };
+ subnet = { name: 'subnet-1' };
+ role = { name: 'role-1' };
+ keyPair = { name: 'key-pair-1' };
+ securityGroup = { name: 'default group' };
+ gitlabManagedCluster = true;
+ });
+
+ it.each`
+ action | mutation | payload | payloadDescription
+ ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'}
+ ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'}
+ ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'}
+ ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'}
+ ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'}
+ ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'}
+ ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'}
+ ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'}
+ ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'}
+ ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
+ `(`$action commits $mutation with $payloadDescription payload`, data => {
+ const { action, mutation, payload } = data;
+
+ testAction(actions[action], payload, createState(), [{ type: mutation, payload }]);
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/actions_spec.js
new file mode 100644
index 00000000000..58f8855a64c
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/actions_spec.js
@@ -0,0 +1,95 @@
+import testAction from 'helpers/vuex_action_helper';
+
+import createState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state';
+import * as types from '~/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types';
+import actionsFactory from '~/create_cluster/eks_cluster/store/cluster_dropdown/actions';
+
+describe('Cluster dropdown Store Actions', () => {
+ const items = [{ name: 'item 1' }];
+ let fetchFn;
+ let actions;
+
+ beforeEach(() => {
+ fetchFn = jest.fn();
+ actions = actionsFactory(fetchFn);
+ });
+
+ describe('fetchItems', () => {
+ describe('on success', () => {
+ beforeEach(() => {
+ fetchFn.mockResolvedValueOnce(items);
+ actions = actionsFactory(fetchFn);
+ });
+
+ it('dispatches success with received items', () =>
+ testAction(
+ actions.fetchItems,
+ null,
+ createState(),
+ [],
+ [
+ { type: 'requestItems' },
+ {
+ type: 'receiveItemsSuccess',
+ payload: { items },
+ },
+ ],
+ ));
+ });
+
+ describe('on failure', () => {
+ const error = new Error('Could not fetch items');
+
+ beforeEach(() => {
+ fetchFn.mockRejectedValueOnce(error);
+ });
+
+ it('dispatches success with received items', () =>
+ testAction(
+ actions.fetchItems,
+ null,
+ createState(),
+ [],
+ [
+ { type: 'requestItems' },
+ {
+ type: 'receiveItemsError',
+ payload: { error },
+ },
+ ],
+ ));
+ });
+ });
+
+ describe('requestItems', () => {
+ it(`commits ${types.REQUEST_ITEMS} mutation`, () =>
+ testAction(actions.requestItems, null, createState(), [{ type: types.REQUEST_ITEMS }]));
+ });
+
+ describe('receiveItemsSuccess', () => {
+ it(`commits ${types.RECEIVE_ITEMS_SUCCESS} mutation`, () =>
+ testAction(actions.receiveItemsSuccess, { items }, createState(), [
+ {
+ type: types.RECEIVE_ITEMS_SUCCESS,
+ payload: {
+ items,
+ },
+ },
+ ]));
+ });
+
+ describe('receiveItemsError', () => {
+ it(`commits ${types.RECEIVE_ITEMS_ERROR} mutation`, () => {
+ const error = new Error('Error fetching items');
+
+ testAction(actions.receiveItemsError, { error }, createState(), [
+ {
+ type: types.RECEIVE_ITEMS_ERROR,
+ payload: {
+ error,
+ },
+ },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/mutations_spec.js b/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/mutations_spec.js
new file mode 100644
index 00000000000..0665047edea
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/mutations_spec.js
@@ -0,0 +1,36 @@
+import {
+ REQUEST_ITEMS,
+ RECEIVE_ITEMS_SUCCESS,
+ RECEIVE_ITEMS_ERROR,
+} from '~/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types';
+import createState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state';
+import mutations from '~/create_cluster/eks_cluster/store/cluster_dropdown/mutations';
+
+describe('Cluster dropdown store mutations', () => {
+ let state;
+ let emptyPayload;
+ let items;
+ let error;
+
+ beforeEach(() => {
+ emptyPayload = {};
+ items = [{ name: 'item 1' }];
+ error = new Error('could not load error');
+ state = createState();
+ });
+
+ it.each`
+ mutation | mutatedProperty | payload | expectedValue | expectedValueDescription
+ ${REQUEST_ITEMS} | ${'isLoadingItems'} | ${emptyPayload} | ${true} | ${true}
+ ${REQUEST_ITEMS} | ${'loadingItemsError'} | ${emptyPayload} | ${null} | ${null}
+ ${RECEIVE_ITEMS_SUCCESS} | ${'isLoadingItems'} | ${{ items }} | ${false} | ${false}
+ ${RECEIVE_ITEMS_SUCCESS} | ${'items'} | ${{ items }} | ${items} | ${'items payload'}
+ ${RECEIVE_ITEMS_ERROR} | ${'isLoadingItems'} | ${{ error }} | ${false} | ${false}
+ ${RECEIVE_ITEMS_ERROR} | ${'error'} | ${{ error }} | ${error} | ${'received error object'}
+ `(`$mutation sets $mutatedProperty to $expectedValueDescription`, data => {
+ const { mutation, mutatedProperty, payload, expectedValue } = data;
+
+ mutations[mutation](state, payload);
+ expect(state[mutatedProperty]).toBe(expectedValue);
+ });
+});
diff --git a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js
new file mode 100644
index 00000000000..81b65180fb5
--- /dev/null
+++ b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js
@@ -0,0 +1,62 @@
+import {
+ SET_CLUSTER_NAME,
+ SET_ENVIRONMENT_SCOPE,
+ SET_KUBERNETES_VERSION,
+ SET_REGION,
+ SET_VPC,
+ SET_KEY_PAIR,
+ SET_SUBNET,
+ SET_ROLE,
+ SET_SECURITY_GROUP,
+ SET_GITLAB_MANAGED_CLUSTER,
+} from '~/create_cluster/eks_cluster/store/mutation_types';
+import createState from '~/create_cluster/eks_cluster/store/state';
+import mutations from '~/create_cluster/eks_cluster/store/mutations';
+
+describe('Create EKS cluster store mutations', () => {
+ let clusterName;
+ let environmentScope;
+ let kubernetesVersion;
+ let state;
+ let region;
+ let vpc;
+ let subnet;
+ let role;
+ let keyPair;
+ let securityGroup;
+ let gitlabManagedCluster;
+
+ beforeEach(() => {
+ clusterName = 'my cluster';
+ environmentScope = 'production';
+ kubernetesVersion = '11.1';
+ region = { name: 'regions-1' };
+ vpc = { name: 'vpc-1' };
+ subnet = { name: 'subnet-1' };
+ role = { name: 'role-1' };
+ keyPair = { name: 'key pair' };
+ securityGroup = { name: 'default group' };
+ gitlabManagedCluster = false;
+
+ state = createState();
+ });
+
+ it.each`
+ mutation | mutatedProperty | payload | expectedValue | expectedValueDescription
+ ${SET_CLUSTER_NAME} | ${'clusterName'} | ${{ clusterName }} | ${clusterName} | ${'cluster name'}
+ ${SET_ENVIRONMENT_SCOPE} | ${'environmentScope'} | ${{ environmentScope }} | ${environmentScope} | ${'environment scope'}
+ ${SET_KUBERNETES_VERSION} | ${'kubernetesVersion'} | ${{ kubernetesVersion }} | ${kubernetesVersion} | ${'kubernetes version'}
+ ${SET_ROLE} | ${'selectedRole'} | ${{ role }} | ${role} | ${'selected role payload'}
+ ${SET_REGION} | ${'selectedRegion'} | ${{ region }} | ${region} | ${'selected region payload'}
+ ${SET_KEY_PAIR} | ${'selectedKeyPair'} | ${{ keyPair }} | ${keyPair} | ${'selected key pair payload'}
+ ${SET_VPC} | ${'selectedVpc'} | ${{ vpc }} | ${vpc} | ${'selected vpc payload'}
+ ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected sybnet payload'}
+ ${SET_SECURITY_GROUP} | ${'selectedSecurityGroup'} | ${{ securityGroup }} | ${securityGroup} | ${'selected security group payload'}
+ ${SET_GITLAB_MANAGED_CLUSTER} | ${'gitlabManagedCluster'} | ${{ gitlabManagedCluster }} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
+ `(`$mutation sets $mutatedProperty to $expectedValueDescription`, data => {
+ const { mutation, mutatedProperty, payload, expectedValue } = data;
+
+ mutations[mutation](state, payload);
+ expect(state[mutatedProperty]).toBe(expectedValue);
+ });
+});
diff --git a/spec/frontend/error_tracking/utils_spec.js b/spec/frontend/error_tracking/utils_spec.js
new file mode 100644
index 00000000000..0e9047cd375
--- /dev/null
+++ b/spec/frontend/error_tracking/utils_spec.js
@@ -0,0 +1,27 @@
+import * as errorTrackingUtils from '~/error_tracking/utils';
+
+const externalUrl = 'https://sentry.io/organizations/test-sentry-nk/issues/1/?project=1';
+
+describe('Error Tracking Events', () => {
+ describe('trackViewInSentryOptions', () => {
+ it('should return correct event options', () => {
+ expect(errorTrackingUtils.trackViewInSentryOptions(externalUrl)).toEqual({
+ category: 'Error Tracking',
+ action: 'click_view_in_sentry',
+ label: 'External Url',
+ property: externalUrl,
+ });
+ });
+ });
+
+ describe('trackClickErrorLinkToSentryOptions', () => {
+ it('should return correct event options', () => {
+ expect(errorTrackingUtils.trackClickErrorLinkToSentryOptions(externalUrl)).toEqual({
+ category: 'Error Tracking',
+ action: 'click_error_link_to_sentry',
+ label: 'Error Link',
+ property: externalUrl,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/fixtures/abuse_reports.rb b/spec/frontend/fixtures/abuse_reports.rb
index 21356390cae..712ed2e8d7e 100644
--- a/spec/frontend/fixtures/abuse_reports.rb
+++ b/spec/frontend/fixtures/abuse_reports.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Admin::AbuseReportsController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/admin_users.rb b/spec/frontend/fixtures/admin_users.rb
index 0209594dadc..b0f7d69f091 100644
--- a/spec/frontend/fixtures/admin_users.rb
+++ b/spec/frontend/fixtures/admin_users.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Admin::UsersController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/application_settings.rb b/spec/frontend/fixtures/application_settings.rb
index afe5949ed3b..a16888d8f03 100644
--- a/spec/frontend/fixtures/application_settings.rb
+++ b/spec/frontend/fixtures/application_settings.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Admin::ApplicationSettingsController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/autocomplete_sources.rb b/spec/frontend/fixtures/autocomplete_sources.rb
index 9e04328e2b9..382eff02b0f 100644
--- a/spec/frontend/fixtures/autocomplete_sources.rb
+++ b/spec/frontend/fixtures/autocomplete_sources.rb
@@ -24,6 +24,10 @@ describe Projects::AutocompleteSourcesController, '(JavaScript fixtures)', type:
create(:label, project: project, title: 'feature')
create(:label, project: project, title: 'documentation')
+ create(:label, project: project, title: 'P1')
+ create(:label, project: project, title: 'P2')
+ create(:label, project: project, title: 'P3')
+ create(:label, project: project, title: 'P4')
get :labels,
format: :json,
diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb
index ce5030efbf8..28a3badaa17 100644
--- a/spec/frontend/fixtures/blob.rb
+++ b/spec/frontend/fixtures/blob.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/boards.rb b/spec/frontend/fixtures/boards.rb
index f257d80390f..b3c7865a088 100644
--- a/spec/frontend/fixtures/boards.rb
+++ b/spec/frontend/fixtures/boards.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::BoardsController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/branches.rb b/spec/frontend/fixtures/branches.rb
index 197fe42c52a..2dc8cde625a 100644
--- a/spec/frontend/fixtures/branches.rb
+++ b/spec/frontend/fixtures/branches.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::BranchesController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/clusters.rb b/spec/frontend/fixtures/clusters.rb
index f15ef010807..fd64d3c0e28 100644
--- a/spec/frontend/fixtures/clusters.rb
+++ b/spec/frontend/fixtures/clusters.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::ClustersController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/commit.rb b/spec/frontend/fixtures/commit.rb
index a328c455356..2c4bf6fbd3d 100644
--- a/spec/frontend/fixtures/commit.rb
+++ b/spec/frontend/fixtures/commit.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::CommitController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/deploy_keys.rb b/spec/frontend/fixtures/deploy_keys.rb
index fca233c6f59..f491c424bcf 100644
--- a/spec/frontend/fixtures/deploy_keys.rb
+++ b/spec/frontend/fixtures/deploy_keys.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/groups.rb b/spec/frontend/fixtures/groups.rb
index c1bb2d43332..237fc711594 100644
--- a/spec/frontend/fixtures/groups.rb
+++ b/spec/frontend/fixtures/groups.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Groups (JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb
index b5eb38e0023..7e524990863 100644
--- a/spec/frontend/fixtures/issues.rb
+++ b/spec/frontend/fixtures/issues.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb
index a3a7759c85b..787ab517f75 100644
--- a/spec/frontend/fixtures/jobs.rb
+++ b/spec/frontend/fixtures/jobs.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/labels.rb b/spec/frontend/fixtures/labels.rb
index a312287970f..e4d66dbcd0a 100644
--- a/spec/frontend/fixtures/labels.rb
+++ b/spec/frontend/fixtures/labels.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Labels (JavaScript fixtures)' do
diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb
index 88706e96676..8fbdb534b3d 100644
--- a/spec/frontend/fixtures/merge_requests.rb
+++ b/spec/frontend/fixtures/merge_requests.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/merge_requests_diffs.rb b/spec/frontend/fixtures/merge_requests_diffs.rb
index b633a0495a6..9493cba03bb 100644
--- a/spec/frontend/fixtures/merge_requests_diffs.rb
+++ b/spec/frontend/fixtures/merge_requests_diffs.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/pipeline_schedules.rb b/spec/frontend/fixtures/pipeline_schedules.rb
index a70091a3919..e00a35d5362 100644
--- a/spec/frontend/fixtures/pipeline_schedules.rb
+++ b/spec/frontend/fixtures/pipeline_schedules.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::PipelineSchedulesController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb
index ed57eb0aa80..83fc13af7d3 100644
--- a/spec/frontend/fixtures/pipelines.rb
+++ b/spec/frontend/fixtures/pipelines.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb
index 91e3b65215a..af5b70fbbeb 100644
--- a/spec/frontend/fixtures/projects.rb
+++ b/spec/frontend/fixtures/projects.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Projects (JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/prometheus_service.rb b/spec/frontend/fixtures/prometheus_service.rb
index 93ee81120d7..c404b8260d2 100644
--- a/spec/frontend/fixtures/prometheus_service.rb
+++ b/spec/frontend/fixtures/prometheus_service.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/raw.rb b/spec/frontend/fixtures/raw.rb
index 801c80a0112..9c9fa4ec40b 100644
--- a/spec/frontend/fixtures/raw.rb
+++ b/spec/frontend/fixtures/raw.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Raw files', '(JavaScript fixtures)' do
diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb
index c26c6998ae9..025cc53c745 100644
--- a/spec/frontend/fixtures/search.rb
+++ b/spec/frontend/fixtures/search.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe SearchController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/services.rb b/spec/frontend/fixtures/services.rb
index ee1e088f158..1b81a83ca49 100644
--- a/spec/frontend/fixtures/services.rb
+++ b/spec/frontend/fixtures/services.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/sessions.rb b/spec/frontend/fixtures/sessions.rb
index 18574ea06b5..a4dc0aef79c 100644
--- a/spec/frontend/fixtures/sessions.rb
+++ b/spec/frontend/fixtures/sessions.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Sessions (JavaScript fixtures)' do
diff --git a/spec/frontend/fixtures/snippet.rb b/spec/frontend/fixtures/snippet.rb
index 23bcdb47ac6..34a6fade9c9 100644
--- a/spec/frontend/fixtures/snippet.rb
+++ b/spec/frontend/fixtures/snippet.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe SnippetsController, '(JavaScript fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/static/environments_logs.html b/spec/frontend/fixtures/static/environments_logs.html
index 6179d3dbc23..ccf9c364154 100644
--- a/spec/frontend/fixtures/static/environments_logs.html
+++ b/spec/frontend/fixtures/static/environments_logs.html
@@ -1,29 +1,102 @@
-<div class="js-kubernetes-logs" data-logs-path="/root/kubernetes-app/environments/1/logs">
- <div class="build-page">
+<div
+ class="js-kubernetes-logs"
+ data-current-environment-name="production"
+ data-environments-path="/root/my-project/environments.json"
+ data-logs-page="/root/my-project/environments/1/logs"
+ data-logs-path="/root/my-project/environments/1/logs.json"
+>
+ <div class="build-page-pod-logs">
<div class="build-trace-container prepend-top-default">
- <div class="top-bar js-top-bar">
- <div class="truncated-info hidden-xs pull-left"></div>
- <div class="dropdown prepend-left-10 js-pod-dropdown">
- <button aria-expanded="false" class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
- <i class="fa fa-chevron-down"></i>
- </button>
- <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
+ <div class="top-bar js-top-bar d-flex">
+ <div class="row">
+ <div class="form-group col-6" role="group">
+ <label class="d-block col-form-label-sm col-form-label">
+ Environment
+ </label>
+ <div class="dropdown js-environment-dropdown d-flex">
+ <button
+ aria-expanded="false"
+ class="dropdown-menu-toggle d-flex align-content-center align-self-center"
+ data-toggle="dropdown"
+ type="button"
+ >
+ <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
+ <div class="dropdown-toggle-text">
+ &nbsp;
+ </div>
+ </button>
+ <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
+ </div>
+ </div>
+ <div class="form-group col-6" role="group">
+ <label class="d-block col-form-label-sm col-form-label">
+ Pod logs from
+ </label>
+ <div class="dropdown js-pod-dropdown d-flex">
+ <button
+ aria-expanded="false"
+ class="dropdown-menu-toggle d-flex align-content-center align-self-center"
+ data-toggle="dropdown"
+ type="button"
+ >
+ <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
+ <div class="dropdown-toggle-text">
+ &nbsp;
+ </div>
+ </button>
+ <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
+ </div>
+ </div>
</div>
- <div class="controllers pull-right">
- <div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Scroll to top">
- <button class="js-scroll-up btn-scroll btn-transparent btn-blank" disabled type="button"></button>
+ <div class="controllers align-self-end">
+ <div
+ class="has-tooltip controllers-buttons"
+ data-container="body"
+ data-placement="top"
+ title="Scroll to top"
+ >
+ <button
+ class="js-scroll-up btn-scroll btn-transparent btn-blank"
+ disabled
+ type="button"
+ ></button>
</div>
- <div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Scroll to bottom">
- <button class="js-scroll-down btn-scroll btn-transparent btn-blank" disabled type="button"></button>
+ <div
+ class="has-tooltip controllers-buttons"
+ data-container="body"
+ data-placement="top"
+ title="Scroll to bottom"
+ >
+ <button
+ class="js-scroll-down btn-scroll btn-transparent btn-blank"
+ disabled
+ type="button"
+ ></button>
</div>
- <div class="refresh-control pull-right">
- <div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Refresh">
- <button class="js-refresh-log btn-default btn-refresh" disabled type="button"></button>
+ <div class="refresh-control">
+ <div
+ class="has-tooltip controllers-buttons"
+ data-container="body"
+ data-placement="top"
+ title="Refresh"
+ >
+ <button
+ class="js-refresh-log btn btn-default btn-refresh h-32-px"
+ disabled
+ type="button"
+ ></button>
</div>
</div>
</div>
</div>
- <pre class="build-trace" id="build-trace"><code class="bash js-build-output"><div class="build-loader-animation js-build-refresh"></div></code></pre>
+ <pre class="build-trace" id="build-trace">
+ <code class="bash js-build-output"></code>
+ <div class="build-loader-animation js-build-refresh">
+ <div class="dot"></div>
+ <div class="dot"></div>
+ <div class="dot"></div>
+ </div>
+ </pre>
</div>
</div>
</div>
diff --git a/spec/frontend/fixtures/todos.rb b/spec/frontend/fixtures/todos.rb
index a7c183d2414..e5bdb4998ed 100644
--- a/spec/frontend/fixtures/todos.rb
+++ b/spec/frontend/fixtures/todos.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Todos (JavaScript fixtures)' do
diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb
index 8ecbc0390cd..dded6ce6380 100644
--- a/spec/frontend/fixtures/u2f.rb
+++ b/spec/frontend/fixtures/u2f.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'spec_helper'
context 'U2F' do
diff --git a/spec/frontend/helpers/dom_shims/README.md b/spec/frontend/helpers/dom_shims/README.md
new file mode 100644
index 00000000000..1105e4b0c4c
--- /dev/null
+++ b/spec/frontend/helpers/dom_shims/README.md
@@ -0,0 +1,12 @@
+## Jest DOM shims
+
+This is where we shim parts of JSDom. It is imported in our root `test_setup.js`.
+
+### Why do we need this?
+
+Since JSDom mocks a real DOM environment (which is a good thing), it
+unfortunately does not support some jQuery matchers.
+
+### References
+
+- https://gitlab.com/gitlab-org/gitlab/merge_requests/17906#note_224448120
diff --git a/spec/frontend/helpers/dom_shims/get_client_rects.js b/spec/frontend/helpers/dom_shims/get_client_rects.js
new file mode 100644
index 00000000000..d740c1bf154
--- /dev/null
+++ b/spec/frontend/helpers/dom_shims/get_client_rects.js
@@ -0,0 +1,50 @@
+function hasHiddenStyle(node) {
+ if (!node.style) {
+ return false;
+ } else if (node.style.display === 'none' || node.style.visibility === 'hidden') {
+ return true;
+ }
+
+ return false;
+}
+
+function createDefaultClientRect() {
+ return {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+}
+
+/**
+ * This is needed to get the `toBeVisible` matcher to work in `jsdom`
+ *
+ * Reference:
+ * - https://github.com/jsdom/jsdom/issues/1322
+ * - https://github.com/unindented/custom-jquery-matchers/blob/v2.1.0/packages/custom-jquery-matchers/src/matchers.js#L157
+ */
+window.Element.prototype.getClientRects = function getClientRects() {
+ let node = this;
+
+ while (node) {
+ if (node === document) {
+ break;
+ }
+
+ if (hasHiddenStyle(node)) {
+ return [];
+ }
+ node = node.parentNode;
+ }
+
+ if (!node) {
+ return [];
+ }
+
+ return [createDefaultClientRect()];
+};
diff --git a/spec/frontend/helpers/dom_shims/get_client_rects_spec.js b/spec/frontend/helpers/dom_shims/get_client_rects_spec.js
new file mode 100644
index 00000000000..e7b8f1e235b
--- /dev/null
+++ b/spec/frontend/helpers/dom_shims/get_client_rects_spec.js
@@ -0,0 +1,71 @@
+const createTestElement = () => {
+ const element = document.createElement('div');
+
+ element.textContent = 'Hello World!';
+
+ return element;
+};
+
+describe('DOM patch for getClientRects', () => {
+ let origHtml;
+ let el;
+
+ beforeEach(() => {
+ origHtml = document.body.innerHTML;
+ el = createTestElement();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = origHtml;
+ });
+
+ describe('toBeVisible matcher', () => {
+ describe('when not attached to document', () => {
+ it('does not match', () => {
+ expect(el).not.toBeVisible();
+ });
+ });
+
+ describe('when attached to document', () => {
+ beforeEach(() => {
+ document.body.appendChild(el);
+ });
+
+ it('matches', () => {
+ expect(el).toBeVisible();
+ });
+ });
+
+ describe('with parent and attached to document', () => {
+ let parentEl;
+
+ beforeEach(() => {
+ parentEl = createTestElement();
+ parentEl.appendChild(el);
+ document.body.appendChild(parentEl);
+ });
+
+ it('matches', () => {
+ expect(el).toBeVisible();
+ });
+
+ describe.each`
+ style
+ ${{ display: 'none' }}
+ ${{ visibility: 'hidden' }}
+ `('with style $style', ({ style }) => {
+ it('does not match when applied to element', () => {
+ Object.assign(el.style, style);
+
+ expect(el).not.toBeVisible();
+ });
+
+ it('does not match when applied to parent', () => {
+ Object.assign(parentEl.style, style);
+
+ expect(el).not.toBeVisible();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/helpers/dom_shims/index.js b/spec/frontend/helpers/dom_shims/index.js
new file mode 100644
index 00000000000..40256398e6d
--- /dev/null
+++ b/spec/frontend/helpers/dom_shims/index.js
@@ -0,0 +1 @@
+import './get_client_rects';
diff --git a/spec/frontend/helpers/test_constants.js b/spec/frontend/helpers/test_constants.js
index 8dc4aef87e1..c97d47a6406 100644
--- a/spec/frontend/helpers/test_constants.js
+++ b/spec/frontend/helpers/test_constants.js
@@ -1,2 +1,7 @@
-// eslint-disable-next-line import/prefer-default-export
+export const FIXTURES_PATH = `/fixtures`;
export const TEST_HOST = 'http://test.host';
+
+export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/static/images/one_white_pixel.png`;
+
+export const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/green_box.png`;
+export const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/red_box.png`;
diff --git a/spec/frontend/helpers/tracking_helper.js b/spec/frontend/helpers/tracking_helper.js
new file mode 100644
index 00000000000..68c1bd2dbca
--- /dev/null
+++ b/spec/frontend/helpers/tracking_helper.js
@@ -0,0 +1,25 @@
+import Tracking from '~/tracking';
+
+export default Tracking;
+
+let document;
+let handlers;
+
+export function mockTracking(category = '_category_', documentOverride, spyMethod) {
+ document = documentOverride || window.document;
+ window.snowplow = () => {};
+ Tracking.bindDocument(category, document);
+ return spyMethod ? spyMethod(Tracking, 'event') : null;
+}
+
+export function unmockTracking() {
+ window.snowplow = undefined;
+ handlers.forEach(event => document.removeEventListener(event.name, event.func));
+}
+
+export function triggerEvent(selectorOrEl, eventName = 'click') {
+ const event = new Event(eventName, { bubbles: true });
+ const el = typeof selectorOrEl === 'string' ? document.querySelector(selectorOrEl) : selectorOrEl;
+
+ el.dispatchEvent(event);
+}
diff --git a/spec/frontend/helpers/vue_resource_helper.js b/spec/frontend/helpers/vue_resource_helper.js
deleted file mode 100644
index 0f58af09933..00000000000
--- a/spec/frontend/helpers/vue_resource_helper.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// eslint-disable-next-line import/prefer-default-export
-export const headersInterceptor = (request, next) => {
- next(response => {
- const headers = {};
- response.headers.forEach((value, key) => {
- headers[key] = value;
- });
- // eslint-disable-next-line no-param-reassign
- response.headers = headers;
- });
-};
diff --git a/spec/frontend/ide/components/branches/search_list_spec.js b/spec/frontend/ide/components/branches/search_list_spec.js
new file mode 100644
index 00000000000..5cfe1c25c6b
--- /dev/null
+++ b/spec/frontend/ide/components/branches/search_list_spec.js
@@ -0,0 +1,81 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { __ } from '~/locale';
+import List from '~/ide/components/branches/search_list.vue';
+import Item from '~/ide/components/branches/item.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { branches } from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('IDE branches search list', () => {
+ let wrapper;
+ const fetchBranchesMock = jest.fn();
+
+ const createComponent = (state, currentBranchId = 'branch') => {
+ const fakeStore = new Vuex.Store({
+ state: {
+ currentBranchId,
+ currentProjectId: 'project',
+ },
+ modules: {
+ branches: {
+ namespaced: true,
+ state: { isLoading: false, branches: [], ...state },
+ actions: {
+ fetchBranches: fetchBranchesMock,
+ },
+ },
+ },
+ });
+
+ wrapper = shallowMount(List, {
+ localVue,
+ store: fakeStore,
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('calls fetch on mounted', () => {
+ createComponent();
+ expect(fetchBranchesMock).toHaveBeenCalled();
+ });
+
+ it('renders loading icon when `isLoading` is true', () => {
+ createComponent({ isLoading: true });
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders branches not found when search is not empty and branches list is empty', () => {
+ createComponent({ branches: [] });
+ wrapper.find('input[type="search"]').setValue('something');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.text()).toContain(__('No branches found'));
+ });
+ });
+
+ describe('with branches', () => {
+ it('renders list', () => {
+ createComponent({ branches });
+ const items = wrapper.findAll(Item);
+
+ expect(items.length).toBe(branches.length);
+ });
+
+ it('renders check next to active branch', () => {
+ const activeBranch = 'regular';
+ createComponent({ branches }, activeBranch);
+ const items = wrapper.findAll(Item).filter(w => w.props('isActive'));
+
+ expect(items.length).toBe(1);
+ expect(items.at(0).props('item').name).toBe(activeBranch);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/error_message_spec.js b/spec/frontend/ide/components/error_message_spec.js
new file mode 100644
index 00000000000..e995c64645e
--- /dev/null
+++ b/spec/frontend/ide/components/error_message_spec.js
@@ -0,0 +1,122 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import ErrorMessage from '~/ide/components/error_message.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('IDE error message component', () => {
+ let wrapper;
+
+ const setErrorMessageMock = jest.fn();
+ const createComponent = messageProps => {
+ const fakeStore = new Vuex.Store({
+ actions: { setErrorMessage: setErrorMessageMock },
+ });
+
+ wrapper = shallowMount(ErrorMessage, {
+ propsData: {
+ message: {
+ text: 'some text',
+ actionText: 'test action',
+ actionPayload: 'testActionPayload',
+ ...messageProps,
+ },
+ },
+ store: fakeStore,
+ localVue,
+ sync: false,
+ });
+ };
+
+ beforeEach(() => {
+ setErrorMessageMock.mockReset();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders error message', () => {
+ const text = 'error message';
+ createComponent({ text });
+ expect(wrapper.text()).toContain(text);
+ });
+
+ it('clears error message on click', () => {
+ createComponent();
+ wrapper.trigger('click');
+
+ expect(setErrorMessageMock).toHaveBeenCalledWith(expect.any(Object), null, undefined);
+ });
+
+ describe('with action', () => {
+ let actionMock;
+
+ const message = {
+ actionText: 'test action',
+ actionPayload: 'testActionPayload',
+ };
+
+ beforeEach(() => {
+ actionMock = jest.fn().mockResolvedValue();
+ createComponent({
+ ...message,
+ action: actionMock,
+ });
+ });
+
+ it('renders action button', () => {
+ const button = wrapper.find('button');
+
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toContain(message.actionText);
+ });
+
+ it('does not clear error message on click', () => {
+ wrapper.trigger('click');
+
+ expect(setErrorMessageMock).not.toHaveBeenCalled();
+ });
+
+ it('dispatches action', () => {
+ wrapper.find('button').trigger('click');
+
+ expect(actionMock).toHaveBeenCalledWith(message.actionPayload);
+ });
+
+ it('does not dispatch action when already loading', () => {
+ wrapper.find('button').trigger('click');
+ actionMock.mockReset();
+ wrapper.find('button').trigger('click');
+ expect(actionMock).not.toHaveBeenCalled();
+ });
+
+ it('shows loading icon when loading', () => {
+ let resolveAction;
+ actionMock.mockImplementation(
+ () =>
+ new Promise(resolve => {
+ resolveAction = resolve;
+ }),
+ );
+ wrapper.find('button').trigger('click');
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
+ resolveAction();
+ });
+ });
+
+ it('hides loading icon when operation finishes', () => {
+ wrapper.find('button').trigger('click');
+ return actionMock()
+ .then(() => wrapper.vm.$nextTick())
+ .then(() => {
+ expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/file_templates/dropdown_spec.js b/spec/frontend/ide/components/file_templates/dropdown_spec.js
new file mode 100644
index 00000000000..83d797469ad
--- /dev/null
+++ b/spec/frontend/ide/components/file_templates/dropdown_spec.js
@@ -0,0 +1,175 @@
+import Vuex from 'vuex';
+import $ from 'jquery';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Dropdown from '~/ide/components/file_templates/dropdown.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('IDE file templates dropdown component', () => {
+ let wrapper;
+ let element;
+ let fetchTemplateTypesMock;
+
+ const defaultProps = {
+ label: 'label',
+ };
+
+ const findItemButtons = () => wrapper.findAll('button');
+ const findSearch = () => wrapper.find('input[type="search"]');
+ const triggerDropdown = () => $(element).trigger('show.bs.dropdown');
+
+ const createComponent = ({ props, state } = {}) => {
+ fetchTemplateTypesMock = jest.fn();
+ const fakeStore = new Vuex.Store({
+ modules: {
+ fileTemplates: {
+ namespaced: true,
+ state: {
+ templates: [],
+ isLoading: false,
+ ...state,
+ },
+ actions: {
+ fetchTemplateTypes: fetchTemplateTypesMock,
+ },
+ },
+ },
+ });
+
+ wrapper = shallowMount(Dropdown, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ store: fakeStore,
+ localVue,
+ sync: false,
+ });
+
+ ({ element } = wrapper);
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('calls clickItem on click', () => {
+ const itemData = { name: 'test.yml ' };
+ createComponent({ props: { data: [itemData] } });
+ const item = findItemButtons().at(0);
+ item.trigger('click');
+
+ expect(wrapper.emitted().click[0][0]).toBe(itemData);
+ });
+
+ it('renders dropdown title', () => {
+ const title = 'Test title';
+ createComponent({ props: { title } });
+
+ expect(wrapper.find('.dropdown-title').text()).toContain(title);
+ });
+
+ describe('in async mode', () => {
+ const defaultAsyncProps = { ...defaultProps, isAsyncData: true };
+
+ it('calls `fetchTemplateTypes` on dropdown event', () => {
+ createComponent({ props: defaultAsyncProps });
+
+ triggerDropdown();
+
+ expect(fetchTemplateTypesMock).toHaveBeenCalled();
+ });
+
+ it('does not call `fetchTemplateTypes` on dropdown event if destroyed', () => {
+ createComponent({ props: defaultAsyncProps });
+ wrapper.destroy();
+
+ triggerDropdown();
+
+ expect(fetchTemplateTypesMock).not.toHaveBeenCalled();
+ });
+
+ it('shows loader when isLoading is true', () => {
+ createComponent({ props: defaultAsyncProps, state: { isLoading: true } });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders templates', () => {
+ const templates = [{ name: 'file-1' }, { name: 'file-2' }];
+ createComponent({
+ props: { ...defaultAsyncProps, data: [{ name: 'should-never-appear ' }] },
+ state: {
+ templates,
+ },
+ });
+ const items = findItemButtons();
+
+ expect(items.wrappers.map(x => x.text())).toEqual(templates.map(x => x.name));
+ });
+
+ it('searches template data', () => {
+ const templates = [{ name: 'match 1' }, { name: 'other' }, { name: 'match 2' }];
+ const matches = ['match 1', 'match 2'];
+ createComponent({
+ props: { ...defaultAsyncProps, data: matches, searchable: true },
+ state: { templates },
+ });
+ findSearch().setValue('match');
+ return wrapper.vm.$nextTick().then(() => {
+ const items = findItemButtons();
+
+ expect(items.length).toBe(matches.length);
+ expect(items.wrappers.map(x => x.text())).toEqual(matches);
+ });
+ });
+
+ it('does not render input when `searchable` is true & `showLoading` is true', () => {
+ createComponent({
+ props: { ...defaultAsyncProps, searchable: true },
+ state: { isLoading: true },
+ });
+
+ expect(findSearch().exists()).toBe(false);
+ });
+ });
+
+ describe('in sync mode', () => {
+ it('renders props data', () => {
+ const data = [{ name: 'file-1' }, { name: 'file-2' }];
+ createComponent({
+ props: { data },
+ state: {
+ templates: [{ name: 'should-never-appear ' }],
+ },
+ });
+
+ const items = findItemButtons();
+
+ expect(items.length).toBe(data.length);
+ expect(items.wrappers.map(x => x.text())).toEqual(data.map(x => x.name));
+ });
+
+ it('renders input when `searchable` is true', () => {
+ createComponent({ props: { searchable: true } });
+
+ expect(findSearch().exists()).toBe(true);
+ });
+
+ it('searches data', () => {
+ const data = [{ name: 'match 1' }, { name: 'other' }, { name: 'match 2' }];
+ const matches = ['match 1', 'match 2'];
+ createComponent({ props: { searchable: true, data } });
+ findSearch().setValue('match');
+ return wrapper.vm.$nextTick().then(() => {
+ const items = findItemButtons();
+
+ expect(items.length).toBe(matches.length);
+ expect(items.wrappers.map(x => x.text())).toEqual(matches);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/jobs/list_spec.js b/spec/frontend/ide/components/jobs/list_spec.js
new file mode 100644
index 00000000000..ec2e5b05048
--- /dev/null
+++ b/spec/frontend/ide/components/jobs/list_spec.js
@@ -0,0 +1,115 @@
+import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import Vuex from 'vuex';
+import StageList from '~/ide/components/jobs/list.vue';
+import Stage from '~/ide/components/jobs/stage.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+const storeActions = {
+ fetchJobs: jest.fn(),
+ toggleStageCollapsed: jest.fn(),
+ setDetailJob: jest.fn(),
+};
+
+const store = new Vuex.Store({
+ modules: {
+ pipelines: {
+ namespaced: true,
+ actions: storeActions,
+ },
+ },
+});
+
+describe('IDE stages list', () => {
+ let wrapper;
+
+ const defaultProps = {
+ stages: [],
+ loading: false,
+ };
+
+ const stages = ['build', 'test', 'deploy'].map((name, id) => ({
+ id,
+ name,
+ jobs: [],
+ status: { icon: 'status_success' },
+ }));
+
+ const createComponent = props => {
+ wrapper = shallowMount(StageList, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ localVue,
+ store,
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ Object.values(storeActions).forEach(actionMock => actionMock.mockClear());
+ });
+
+ afterAll(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders loading icon when no stages & loading', () => {
+ createComponent({ loading: true, stages: [] });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders stages components for each stage', () => {
+ createComponent({ stages });
+ expect(wrapper.findAll(Stage).length).toBe(stages.length);
+ });
+
+ it('triggers fetchJobs action when stage emits fetch event', () => {
+ createComponent({ stages });
+ wrapper.find(Stage).vm.$emit('fetch');
+ expect(storeActions.fetchJobs).toHaveBeenCalled();
+ });
+
+ it('triggers toggleStageCollapsed action when stage emits toggleCollapsed event', () => {
+ createComponent({ stages });
+ wrapper.find(Stage).vm.$emit('toggleCollapsed');
+ expect(storeActions.toggleStageCollapsed).toHaveBeenCalled();
+ });
+
+ it('triggers setDetailJob action when stage emits clickViewLog event', () => {
+ createComponent({ stages });
+ wrapper.find(Stage).vm.$emit('clickViewLog');
+ expect(storeActions.setDetailJob).toHaveBeenCalled();
+ });
+
+ describe('integration tests', () => {
+ const findCardHeader = () => wrapper.find('.card-header');
+
+ beforeEach(() => {
+ wrapper = mount(StageList, {
+ propsData: { ...defaultProps, stages },
+ store,
+ sync: false,
+ localVue,
+ });
+ });
+
+ it('calls toggleStageCollapsed when clicking stage header', () => {
+ findCardHeader().trigger('click');
+
+ expect(storeActions.toggleStageCollapsed).toHaveBeenCalledWith(
+ expect.any(Object),
+ 0,
+ undefined,
+ );
+ });
+
+ it('calls fetchJobs when stage is mounted', () => {
+ expect(storeActions.fetchJobs.mock.calls.map(([, stage]) => stage)).toEqual(stages);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/merge_requests/list_spec.js b/spec/frontend/ide/components/merge_requests/list_spec.js
new file mode 100644
index 00000000000..86a311acad4
--- /dev/null
+++ b/spec/frontend/ide/components/merge_requests/list_spec.js
@@ -0,0 +1,214 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import List from '~/ide/components/merge_requests/list.vue';
+import Item from '~/ide/components/merge_requests/item.vue';
+import TokenedInput from '~/ide/components/shared/tokened_input.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { mergeRequests as mergeRequestsMock } from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('IDE merge requests list', () => {
+ let wrapper;
+ let fetchMergeRequestsMock;
+
+ const findSearchTypeButtons = () => wrapper.findAll('button');
+ const findTokenedInput = () => wrapper.find(TokenedInput);
+
+ const createComponent = (state = {}) => {
+ const { mergeRequests = {}, ...restOfState } = state;
+ const fakeStore = new Vuex.Store({
+ state: {
+ currentMergeRequestId: '1',
+ currentProjectId: 'project/master',
+ ...restOfState,
+ },
+ modules: {
+ mergeRequests: {
+ namespaced: true,
+ state: {
+ isLoading: false,
+ mergeRequests: [],
+ ...mergeRequests,
+ },
+ actions: {
+ fetchMergeRequests: fetchMergeRequestsMock,
+ },
+ },
+ },
+ });
+
+ wrapper = shallowMount(List, {
+ store: fakeStore,
+ localVue,
+ sync: false,
+ });
+ };
+
+ beforeEach(() => {
+ fetchMergeRequestsMock = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('calls fetch on mounted', () => {
+ createComponent();
+ expect(fetchMergeRequestsMock).toHaveBeenCalledWith(
+ expect.any(Object),
+ {
+ search: '',
+ type: '',
+ },
+ undefined,
+ );
+ });
+
+ it('renders loading icon when merge request is loading', () => {
+ createComponent({ mergeRequests: { isLoading: true } });
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders no search results text when search is not empty', () => {
+ createComponent();
+ findTokenedInput().vm.$emit('input', 'something');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.text()).toContain('No merge requests found');
+ });
+ });
+
+ it('clicking on search type, sets currentSearchType and loads merge requests', () => {
+ createComponent();
+ findTokenedInput().vm.$emit('focus');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ findSearchTypeButtons()
+ .at(0)
+ .trigger('click');
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ const searchType = wrapper.vm.$options.searchTypes[0];
+
+ expect(findTokenedInput().props('tokens')).toEqual([searchType]);
+ expect(fetchMergeRequestsMock).toHaveBeenCalledWith(
+ expect.any(Object),
+ {
+ type: searchType.type,
+ search: '',
+ },
+ undefined,
+ );
+ });
+ });
+
+ describe('with merge requests', () => {
+ let defaultStateWithMergeRequests;
+
+ beforeAll(() => {
+ defaultStateWithMergeRequests = {
+ mergeRequests: {
+ isLoading: false,
+ mergeRequests: [
+ { ...mergeRequestsMock[0], projectPathWithNamespace: 'gitlab-org/gitlab-foss' },
+ ],
+ },
+ };
+ });
+
+ it('renders list', () => {
+ createComponent(defaultStateWithMergeRequests);
+
+ expect(wrapper.findAll(Item).length).toBe(1);
+ expect(wrapper.find(Item).props('item')).toBe(
+ defaultStateWithMergeRequests.mergeRequests.mergeRequests[0],
+ );
+ });
+
+ describe('when searching merge requests', () => {
+ it('calls `loadMergeRequests` on input in search field', () => {
+ createComponent(defaultStateWithMergeRequests);
+ const input = findTokenedInput();
+ input.vm.$emit('input', 'something');
+ fetchMergeRequestsMock.mockClear();
+
+ jest.runAllTimers();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(fetchMergeRequestsMock).toHaveBeenCalledWith(
+ expect.any(Object),
+ {
+ search: 'something',
+ type: '',
+ },
+ undefined,
+ );
+ });
+ });
+ });
+ });
+
+ describe('on search focus', () => {
+ let input;
+
+ beforeEach(() => {
+ createComponent();
+ input = findTokenedInput();
+ });
+
+ describe('without search value', () => {
+ beforeEach(() => {
+ input.vm.$emit('focus');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('shows search types', () => {
+ const buttons = findSearchTypeButtons();
+ expect(buttons.wrappers.map(x => x.text().trim())).toEqual(
+ wrapper.vm.$options.searchTypes.map(x => x.label),
+ );
+ });
+
+ it('hides search types when search changes', () => {
+ input.vm.$emit('input', 'something');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findSearchTypeButtons().exists()).toBe(false);
+ });
+ });
+
+ describe('with search type', () => {
+ beforeEach(() => {
+ findSearchTypeButtons()
+ .at(0)
+ .trigger('click');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => input.vm.$emit('focus'))
+ .then(() => wrapper.vm.$nextTick());
+ });
+
+ it('does not show search types', () => {
+ expect(findSearchTypeButtons().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with search value', () => {
+ beforeEach(() => {
+ input.vm.$emit('input', 'something');
+ input.vm.$emit('focus');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('does not show search types', () => {
+ expect(findSearchTypeButtons().exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap
new file mode 100644
index 00000000000..5fbe6af750d
--- /dev/null
+++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`IDE pipelines list when loaded renders empty state when no latestPipeline 1`] = `
+<div
+ class="ide-pipeline"
+>
+ <!---->
+
+ <emptystate-stub
+ cansetci="true"
+ emptystatesvgpath="http://test.host"
+ helppagepath="http://test.host"
+ />
+</div>
+`;
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
new file mode 100644
index 00000000000..a974139a8f9
--- /dev/null
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -0,0 +1,193 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import List from '~/ide/components/pipelines/list.vue';
+import JobsList from '~/ide/components/jobs/list.vue';
+import Tab from '~/vue_shared/components/tabs/tab.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { TEST_HOST } from 'helpers/test_constants';
+import { pipelines } from '../../../../javascripts/ide/mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('IDE pipelines list', () => {
+ let wrapper;
+
+ const defaultState = {
+ links: { ciHelpPagePath: TEST_HOST },
+ pipelinesEmptyStateSvgPath: TEST_HOST,
+ pipelines: {
+ stages: [],
+ failedStages: [],
+ isLoadingJobs: false,
+ },
+ };
+
+ const fetchLatestPipelineMock = jest.fn();
+ const failedStagesGetterMock = jest.fn().mockReturnValue([]);
+
+ const createComponent = (state = {}) => {
+ const { pipelines: pipelinesState, ...restOfState } = state;
+ const { defaultPipelines, ...defaultRestOfState } = defaultState;
+
+ const fakeStore = new Vuex.Store({
+ getters: { currentProject: () => ({ web_url: 'some/url ' }) },
+ state: {
+ ...defaultRestOfState,
+ ...restOfState,
+ },
+ modules: {
+ pipelines: {
+ namespaced: true,
+ state: {
+ ...defaultPipelines,
+ ...pipelinesState,
+ },
+ actions: {
+ fetchLatestPipeline: fetchLatestPipelineMock,
+ },
+ getters: {
+ jobsCount: () => 1,
+ failedJobsCount: () => 1,
+ failedStages: failedStagesGetterMock,
+ pipelineFailed: () => false,
+ },
+ methods: {
+ fetchLatestPipeline: jest.fn(),
+ },
+ },
+ },
+ });
+
+ wrapper = shallowMount(List, {
+ localVue,
+ store: fakeStore,
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('fetches latest pipeline', () => {
+ createComponent();
+
+ expect(fetchLatestPipelineMock).toHaveBeenCalled();
+ });
+
+ describe('when loading', () => {
+ let defaultPipelinesLoadingState;
+ beforeAll(() => {
+ defaultPipelinesLoadingState = {
+ ...defaultState.pipelines,
+ isLoadingPipeline: true,
+ };
+ });
+
+ it('does not render when pipeline has loaded before', () => {
+ createComponent({
+ pipelines: {
+ ...defaultPipelinesLoadingState,
+ hasLoadedPipeline: true,
+ },
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ });
+
+ it('renders loading state', () => {
+ createComponent({
+ pipelines: {
+ ...defaultPipelinesLoadingState,
+ hasLoadedPipeline: false,
+ },
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('when loaded', () => {
+ let defaultPipelinesLoadedState;
+ beforeAll(() => {
+ defaultPipelinesLoadedState = {
+ ...defaultState.pipelines,
+ isLoadingPipeline: false,
+ hasLoadedPipeline: true,
+ };
+ });
+
+ it('renders empty state when no latestPipeline', () => {
+ createComponent({ pipelines: { ...defaultPipelinesLoadedState, latestPipeline: null } });
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('with latest pipeline loaded', () => {
+ let withLatestPipelineState;
+ beforeAll(() => {
+ withLatestPipelineState = {
+ ...defaultPipelinesLoadedState,
+ latestPipeline: pipelines[0],
+ };
+ });
+
+ it('renders ci icon', () => {
+ createComponent({ pipelines: withLatestPipelineState });
+ expect(wrapper.find(CiIcon).exists()).toBe(true);
+ });
+
+ it('renders pipeline data', () => {
+ createComponent({ pipelines: withLatestPipelineState });
+
+ expect(wrapper.text()).toContain('#1');
+ });
+
+ it('renders list of jobs', () => {
+ const stages = [];
+ const isLoadingJobs = true;
+ createComponent({ pipelines: { ...withLatestPipelineState, stages, isLoadingJobs } });
+
+ const jobProps = wrapper
+ .findAll(Tab)
+ .at(0)
+ .find(JobsList)
+ .props();
+ expect(jobProps.stages).toBe(stages);
+ expect(jobProps.loading).toBe(isLoadingJobs);
+ });
+
+ it('renders list of failed jobs', () => {
+ const failedStages = [];
+ failedStagesGetterMock.mockReset().mockReturnValue(failedStages);
+ const isLoadingJobs = true;
+ createComponent({ pipelines: { ...withLatestPipelineState, isLoadingJobs } });
+
+ const jobProps = wrapper
+ .findAll(Tab)
+ .at(1)
+ .find(JobsList)
+ .props();
+ expect(jobProps.stages).toBe(failedStages);
+ expect(jobProps.loading).toBe(isLoadingJobs);
+ });
+
+ describe('with YAML error', () => {
+ it('renders YAML error', () => {
+ const yamlError = 'test yaml error';
+ createComponent({
+ pipelines: {
+ ...defaultPipelinesLoadedState,
+ latestPipeline: { ...pipelines[0], yamlError },
+ },
+ });
+
+ expect(wrapper.text()).toContain('Found errors in your .gitlab-ci.yml:');
+ expect(wrapper.text()).toContain(yamlError);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/files_spec.js b/spec/frontend/ide/lib/files_spec.js
index 08a31318544..654dc6c13c8 100644
--- a/spec/frontend/ide/lib/files_spec.js
+++ b/spec/frontend/ide/lib/files_spec.js
@@ -1,6 +1,6 @@
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
-import { decorateFiles, splitParent, escapeFileUrl } from '~/ide/lib/files';
-import { decorateData } from '~/ide/stores/utils';
+import { decorateFiles, splitParent } from '~/ide/lib/files';
+import { decorateData, escapeFileUrl } from '~/ide/stores/utils';
const TEST_BRANCH_ID = 'lorem-ipsum';
const TEST_PROJECT_ID = 10;
diff --git a/spec/frontend/ide/mock_data.js b/spec/frontend/ide/mock_data.js
new file mode 100644
index 00000000000..80eb15fe5a6
--- /dev/null
+++ b/spec/frontend/ide/mock_data.js
@@ -0,0 +1,228 @@
+import { TEST_HOST } from 'spec/test_constants';
+
+export const projectData = {
+ id: 1,
+ name: 'abcproject',
+ web_url: '',
+ avatar_url: '',
+ path: '',
+ name_with_namespace: 'namespace/abcproject',
+ branches: {
+ master: {
+ treeId: 'abcproject/master',
+ can_push: true,
+ commit: {
+ id: '123',
+ },
+ },
+ },
+ mergeRequests: {},
+ merge_requests_enabled: true,
+ default_branch: 'master',
+};
+
+export const pipelines = [
+ {
+ id: 1,
+ ref: 'master',
+ sha: '123',
+ details: {
+ status: {
+ icon: 'status_failed',
+ group: 'failed',
+ text: 'Failed',
+ },
+ },
+ commit: { id: '123' },
+ },
+ {
+ id: 2,
+ ref: 'master',
+ sha: '213',
+ details: {
+ status: {
+ icon: 'status_failed',
+ group: 'failed',
+ text: 'Failed',
+ },
+ },
+ commit: { id: '213' },
+ },
+];
+
+export const stages = [
+ {
+ dropdown_path: `${TEST_HOST}/testing`,
+ name: 'build',
+ status: {
+ icon: 'status_failed',
+ group: 'failed',
+ text: 'failed',
+ },
+ },
+ {
+ dropdown_path: 'testing',
+ name: 'test',
+ status: {
+ icon: 'status_failed',
+ group: 'failed',
+ text: 'failed',
+ },
+ },
+];
+
+export const jobs = [
+ {
+ id: 1,
+ name: 'test',
+ path: 'testing',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ },
+ stage: 'test',
+ duration: 1,
+ started: new Date(),
+ },
+ {
+ id: 2,
+ name: 'test 2',
+ path: 'testing2',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ },
+ stage: 'test',
+ duration: 1,
+ started: new Date(),
+ },
+ {
+ id: 3,
+ name: 'test 3',
+ path: 'testing3',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ },
+ stage: 'test',
+ duration: 1,
+ started: new Date(),
+ },
+ {
+ id: 4,
+ name: 'test 4',
+ path: 'testing4',
+ status: {
+ icon: 'status_failed',
+ text: 'failed',
+ },
+ stage: 'build',
+ duration: 1,
+ started: new Date(),
+ },
+];
+
+export const fullPipelinesResponse = {
+ data: {
+ count: {
+ all: 2,
+ },
+ pipelines: [
+ {
+ id: '51',
+ path: 'test',
+ commit: {
+ id: '123',
+ },
+ details: {
+ status: {
+ icon: 'status_failed',
+ text: 'failed',
+ },
+ stages: [...stages],
+ },
+ },
+ {
+ id: '50',
+ commit: {
+ id: 'abc123def456ghi789jkl',
+ },
+ details: {
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ },
+ stages: [...stages],
+ },
+ },
+ ],
+ },
+};
+
+export const mergeRequests = [
+ {
+ id: 1,
+ iid: 1,
+ title: 'Test merge request',
+ project_id: 1,
+ web_url: `${TEST_HOST}/namespace/project-path/merge_requests/1`,
+ },
+];
+
+export const branches = [
+ {
+ id: 1,
+ name: 'master',
+ commit: {
+ message: 'Update master branch',
+ committed_date: '2018-08-01T00:20:05Z',
+ },
+ can_push: true,
+ protected: true,
+ default: true,
+ },
+ {
+ id: 2,
+ name: 'protected/no-access',
+ commit: {
+ message: 'Update some stuff',
+ committed_date: '2018-08-02T00:00:05Z',
+ },
+ can_push: false,
+ protected: true,
+ default: false,
+ },
+ {
+ id: 3,
+ name: 'protected/access',
+ commit: {
+ message: 'Update some stuff',
+ committed_date: '2018-08-02T00:00:05Z',
+ },
+ can_push: true,
+ protected: true,
+ default: false,
+ },
+ {
+ id: 4,
+ name: 'regular',
+ commit: {
+ message: 'Update some more stuff',
+ committed_date: '2018-06-30T00:20:05Z',
+ },
+ can_push: true,
+ protected: false,
+ default: false,
+ },
+ {
+ id: 5,
+ name: 'regular/no-access',
+ commit: {
+ message: 'Update some more stuff',
+ committed_date: '2018-06-30T00:20:05Z',
+ },
+ can_push: false,
+ protected: false,
+ default: false,
+ },
+];
diff --git a/spec/frontend/ide/stores/integration_spec.js b/spec/frontend/ide/stores/integration_spec.js
new file mode 100644
index 00000000000..443de18f288
--- /dev/null
+++ b/spec/frontend/ide/stores/integration_spec.js
@@ -0,0 +1,100 @@
+import { decorateFiles } from '~/ide/lib/files';
+import { createStore } from '~/ide/stores';
+
+const TEST_BRANCH = 'test_branch';
+const TEST_NAMESPACE = 'test_namespace';
+const TEST_PROJECT_ID = `${TEST_NAMESPACE}/test_project`;
+const TEST_PATH_DIR = 'src';
+const TEST_PATH = `${TEST_PATH_DIR}/foo.js`;
+const TEST_CONTENT = `Lorem ipsum dolar sit
+Lorem ipsum dolar
+Lorem ipsum
+Lorem
+`;
+
+jest.mock('~/ide/ide_router');
+
+describe('IDE store integration', () => {
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ store.replaceState({
+ ...store.state,
+ projects: {
+ [TEST_PROJECT_ID]: {
+ web_url: 'test_web_url',
+ branches: [],
+ },
+ },
+ currentProjectId: TEST_PROJECT_ID,
+ currentBranchId: TEST_BRANCH,
+ });
+ });
+
+ describe('with project and files', () => {
+ beforeEach(() => {
+ const { entries, treeList } = decorateFiles({
+ data: [`${TEST_PATH_DIR}/`, TEST_PATH, 'README.md'],
+ projectId: TEST_PROJECT_ID,
+ branchId: TEST_BRANCH,
+ });
+
+ Object.assign(entries[TEST_PATH], {
+ raw: TEST_CONTENT,
+ });
+
+ store.replaceState({
+ ...store.state,
+ trees: {
+ [`${TEST_PROJECT_ID}/${TEST_BRANCH}`]: {
+ tree: treeList,
+ },
+ },
+ entries,
+ });
+ });
+
+ describe('when a file is deleted and readded', () => {
+ beforeEach(() => {
+ store.dispatch('deleteEntry', TEST_PATH);
+ store.dispatch('createTempEntry', { name: TEST_PATH, type: 'blob' });
+ });
+
+ it('has changed and staged', () => {
+ expect(store.state.changedFiles).toEqual([
+ expect.objectContaining({
+ path: TEST_PATH,
+ tempFile: true,
+ deleted: false,
+ }),
+ ]);
+
+ expect(store.state.stagedFiles).toEqual([
+ expect.objectContaining({
+ path: TEST_PATH,
+ deleted: true,
+ }),
+ ]);
+ });
+
+ it('cleans up after commit', () => {
+ const expected = expect.objectContaining({
+ path: TEST_PATH,
+ staged: false,
+ changed: false,
+ tempFile: false,
+ deleted: false,
+ });
+ store.dispatch('stageChange', TEST_PATH);
+
+ store.dispatch('commit/updateFilesAfterCommit', { data: {} });
+
+ expect(store.state.entries[TEST_PATH]).toEqual(expected);
+ expect(store.state.entries[TEST_PATH_DIR].tree.find(x => x.path === TEST_PATH)).toEqual(
+ expected,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js
index 17a998d0174..708f2758083 100644
--- a/spec/frontend/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_projects/components/import_projects_table_spec.js
@@ -93,7 +93,7 @@ describe('ImportProjectsTable', () => {
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull();
expect(vm.$el.querySelector('.table')).toBeNull();
- expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories available to import`);
+ expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories found`);
});
});
@@ -182,4 +182,10 @@ describe('ImportProjectsTable', () => {
expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull();
});
});
+
+ it('renders filtering input field', () => {
+ expect(
+ vm.$el.querySelector('input[data-qa-selector="githubish_import_filter_field"]'),
+ ).not.toBeNull();
+ });
});
diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js
index 6a7b90788dd..340b6f02d93 100644
--- a/spec/frontend/import_projects/store/actions_spec.js
+++ b/spec/frontend/import_projects/store/actions_spec.js
@@ -97,6 +97,7 @@ describe('import_projects store actions', () => {
describe('fetchRepos', () => {
let mock;
+ const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] };
beforeEach(() => {
localState.reposPath = `${TEST_HOST}/endpoint.json`;
@@ -105,8 +106,7 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
- it('dispatches requestRepos and receiveReposSuccess actions on a successful request', done => {
- const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] };
+ it('dispatches stopJobsPolling, requestRepos and receiveReposSuccess actions on a successful request', done => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload);
testAction(
@@ -115,6 +115,7 @@ describe('import_projects store actions', () => {
localState,
[],
[
+ { type: 'stopJobsPolling' },
{ type: 'requestRepos' },
{
type: 'receiveReposSuccess',
@@ -128,7 +129,7 @@ describe('import_projects store actions', () => {
);
});
- it('dispatches requestRepos and receiveReposSuccess actions on an unsuccessful request', done => {
+ it('dispatches stopJobsPolling, requestRepos and receiveReposError actions on an unsuccessful request', done => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
testAction(
@@ -136,10 +137,39 @@ describe('import_projects store actions', () => {
null,
localState,
[],
- [{ type: 'requestRepos' }, { type: 'receiveReposError' }],
+ [{ type: 'stopJobsPolling' }, { type: 'requestRepos' }, { type: 'receiveReposError' }],
done,
);
});
+
+ describe('when filtered', () => {
+ beforeEach(() => {
+ localState.filter = 'filter';
+ });
+
+ it('fetches repos with filter applied', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload);
+
+ testAction(
+ fetchRepos,
+ null,
+ localState,
+ [],
+ [
+ { type: 'stopJobsPolling' },
+ { type: 'requestRepos' },
+ {
+ type: 'receiveReposSuccess',
+ payload: convertObjectPropsToCamelCase(payload, { deep: true }),
+ },
+ {
+ type: 'fetchJobs',
+ },
+ ],
+ done,
+ );
+ });
+ });
});
describe('requestImport', () => {
@@ -249,6 +279,7 @@ describe('import_projects store actions', () => {
describe('fetchJobs', () => {
let mock;
+ const updatedProjects = [{ name: 'imported/project' }, { name: 'provider/repo' }];
beforeEach(() => {
localState.jobsPath = `${TEST_HOST}/endpoint.json`;
@@ -263,7 +294,6 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
it('dispatches requestJobs and receiveJobsSuccess actions on a successful request', done => {
- const updatedProjects = [{ name: 'imported/project' }, { name: 'provider/repo' }];
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects);
testAction(
@@ -280,5 +310,29 @@ describe('import_projects store actions', () => {
done,
);
});
+
+ describe('when filtered', () => {
+ beforeEach(() => {
+ localState.filter = 'filter';
+ });
+
+ it('fetches realtime changes with filter applied', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, updatedProjects);
+
+ testAction(
+ fetchJobs,
+ null,
+ localState,
+ [],
+ [
+ {
+ type: 'receiveJobsSuccess',
+ payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }),
+ },
+ ],
+ done,
+ );
+ });
+ });
});
});
diff --git a/spec/frontend/issue_show/store_spec.js b/spec/frontend/issue_show/store_spec.js
new file mode 100644
index 00000000000..b7fd70bf00e
--- /dev/null
+++ b/spec/frontend/issue_show/store_spec.js
@@ -0,0 +1,39 @@
+import Store from '~/issue_show/stores';
+import updateDescription from '~/issue_show/utils/update_description';
+
+jest.mock('~/issue_show/utils/update_description');
+
+describe('Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new Store({
+ descriptionHtml: '<p>This is a description</p>',
+ });
+ });
+
+ describe('updateState', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+ <div class="detail-page-description content-block">
+ <details open>
+ <summary>One</summary>
+ </details>
+ <details>
+ <summary>Two</summary>
+ </details>
+ </div>
+ `;
+ });
+
+ afterEach(() => {
+ document.getElementsByTagName('html')[0].innerHTML = '';
+ });
+
+ it('calls updateDetailsState', () => {
+ store.updateState({ description: '' });
+
+ expect(updateDescription).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/utils/update_description_spec.js b/spec/frontend/issue_show/utils/update_description_spec.js
new file mode 100644
index 00000000000..b2c6bd3c302
--- /dev/null
+++ b/spec/frontend/issue_show/utils/update_description_spec.js
@@ -0,0 +1,24 @@
+import updateDescription from '~/issue_show/utils/update_description';
+
+describe('updateDescription', () => {
+ it('returns the correct value to be set as descriptionHtml', () => {
+ const actual = updateDescription(
+ '<details><summary>One</summary></details><details><summary>Two</summary></details>',
+ [{ open: true }, { open: false }], // mocking NodeList from the dom.
+ );
+
+ expect(actual).toEqual(
+ '<details open="true"><summary>One</summary></details><details><summary>Two</summary></details>',
+ );
+ });
+
+ describe('when description details returned from api is different then whats currently on the dom', () => {
+ it('returns the description from the api', () => {
+ const dataDescription = '<details><summary>One</summary></details>';
+
+ const actual = updateDescription(dataDescription, []);
+
+ expect(actual).toEqual(dataDescription);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js
new file mode 100644
index 00000000000..01184a51193
--- /dev/null
+++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js
@@ -0,0 +1,73 @@
+import { mount } from '@vue/test-utils';
+import CollpasibleSection from '~/jobs/components/log/collapsible_section.vue';
+import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data';
+
+describe('Job Log Collapsible Section', () => {
+ let wrapper;
+
+ const traceEndpoint = 'jobs/335';
+
+ const findCollapsibleLine = () => wrapper.find('.collapsible-line');
+ const findCollapsibleLineSvg = () => wrapper.find('.collapsible-line svg');
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(CollpasibleSection, {
+ sync: true,
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with closed section', () => {
+ beforeEach(() => {
+ createComponent({
+ section: collapsibleSectionClosed,
+ traceEndpoint,
+ });
+ });
+
+ it('renders clickable header line', () => {
+ expect(findCollapsibleLine().attributes('role')).toBe('button');
+ });
+
+ it('renders an icon with the closed state', () => {
+ expect(findCollapsibleLineSvg().classes()).toContain('ic-angle-right');
+ });
+ });
+
+ describe('with opened section', () => {
+ beforeEach(() => {
+ createComponent({
+ section: collapsibleSectionOpened,
+ traceEndpoint,
+ });
+ });
+
+ it('renders clickable header line', () => {
+ expect(findCollapsibleLine().attributes('role')).toBe('button');
+ });
+
+ it('renders an icon with the open state', () => {
+ expect(findCollapsibleLineSvg().classes()).toContain('ic-angle-down');
+ });
+
+ it('renders collapsible lines content', () => {
+ expect(wrapper.findAll('.js-line').length).toEqual(collapsibleSectionOpened.lines.length);
+ });
+ });
+
+ it('emits onClickCollapsibleLine on click', () => {
+ createComponent({
+ section: collapsibleSectionOpened,
+ traceEndpoint,
+ });
+
+ findCollapsibleLine().trigger('click');
+ expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1);
+ });
+});
diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js
index db42644de77..d375d82d3ca 100644
--- a/spec/frontend/jobs/components/log/mock_data.js
+++ b/spec/frontend/jobs/components/log/mock_data.js
@@ -14,13 +14,13 @@ export const jobLog = [
text: 'Using Docker executor with image dev.gitlab.org3',
},
],
- sections: ['prepare-executor'],
+ section: 'prepare-executor',
section_header: true,
},
{
offset: 1003,
content: [{ text: 'Starting service postgres:9.6.14 ...', style: 'text-green' }],
- sections: ['prepare-executor'],
+ section: 'prepare-executor',
},
];
@@ -37,23 +37,23 @@ export const utilsMockData = [
'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.3-golang-1.11-git-2.22-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33',
},
],
- sections: ['prepare-executor'],
+ section: 'prepare-executor',
section_header: true,
},
{
offset: 1003,
content: [{ text: 'Starting service postgres:9.6.14 ...' }],
- sections: ['prepare-executor'],
+ section: 'prepare-executor',
},
{
offset: 1004,
content: [{ text: 'Pulling docker image postgres:9.6.14 ...', style: 'term-fg-l-green' }],
- sections: ['prepare-executor'],
+ section: 'prepare-executor',
},
{
offset: 1005,
content: [],
- sections: ['prepare-executor'],
+ section: 'prepare-executor',
section_duration: '10:00',
},
];
@@ -100,7 +100,7 @@ export const headerTrace = [
text: 'log line',
},
],
- sections: ['section'],
+ section: 'section',
},
];
@@ -113,7 +113,7 @@ export const headerTraceIncremental = [
text: 'updated log line',
},
],
- sections: ['section'],
+ section: 'section',
},
];
@@ -126,7 +126,7 @@ export const collapsibleTrace = [
text: 'log line',
},
],
- sections: ['section'],
+ section: 'section',
},
{
offset: 2,
@@ -135,7 +135,7 @@ export const collapsibleTrace = [
text: 'log line',
},
],
- sections: ['section'],
+ section: 'section',
},
];
@@ -147,6 +147,48 @@ export const collapsibleTraceIncremental = [
text: 'updated log line',
},
],
- sections: ['section'],
+ section: 'section',
},
];
+
+export const collapsibleSectionClosed = {
+ offset: 5,
+ section_header: true,
+ isHeader: true,
+ isClosed: true,
+ line: {
+ content: [{ text: 'foo' }],
+ section: 'prepare-script',
+ lineNumber: 1,
+ },
+ section_duration: '00:03',
+ lines: [
+ {
+ offset: 80,
+ content: [{ text: 'this is a collapsible nested section' }],
+ section: 'prepare-script',
+ lineNumber: 3,
+ },
+ ],
+};
+
+export const collapsibleSectionOpened = {
+ offset: 5,
+ section_header: true,
+ isHeader: true,
+ isClosed: false,
+ line: {
+ content: [{ text: 'foo' }],
+ section: 'prepare-script',
+ lineNumber: 1,
+ },
+ section_duration: '00:03',
+ lines: [
+ {
+ offset: 80,
+ content: [{ text: 'this is a collapsible nested section' }],
+ section: 'prepare-script',
+ lineNumber: 3,
+ },
+ ],
+};
diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js
index 8e5ab4b229a..d1ab152330e 100644
--- a/spec/frontend/jobs/store/mutations_spec.js
+++ b/spec/frontend/jobs/store/mutations_spec.js
@@ -73,12 +73,88 @@ describe('Jobs Store Mutations', () => {
html,
size: 511846,
complete: true,
+ lines: [],
});
expect(stateCopy.trace).toEqual(html);
expect(stateCopy.traceSize).toEqual(511846);
expect(stateCopy.isTraceComplete).toEqual(true);
});
+
+ describe('with new job log', () => {
+ let stateWithNewLog;
+ beforeEach(() => {
+ gon.features = gon.features || {};
+ gon.features.jobLogJson = true;
+
+ stateWithNewLog = state();
+ });
+
+ afterEach(() => {
+ gon.features.jobLogJson = false;
+ });
+
+ describe('log.lines', () => {
+ describe('when append is true', () => {
+ it('sets the parsed log ', () => {
+ mutations[types.RECEIVE_TRACE_SUCCESS](stateWithNewLog, {
+ append: true,
+ size: 511846,
+ complete: true,
+ lines: [
+ {
+ offset: 1,
+ content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }],
+ },
+ ],
+ });
+
+ expect(stateWithNewLog.trace).toEqual([
+ {
+ offset: 1,
+ content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }],
+ lineNumber: 0,
+ },
+ ]);
+ });
+ });
+
+ describe('when it is defined', () => {
+ it('sets the parsed log ', () => {
+ mutations[types.RECEIVE_TRACE_SUCCESS](stateWithNewLog, {
+ append: false,
+ size: 511846,
+ complete: true,
+ lines: [
+ { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }] },
+ ],
+ });
+
+ expect(stateWithNewLog.trace).toEqual([
+ {
+ offset: 0,
+ content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }],
+ lineNumber: 0,
+ },
+ ]);
+ });
+ });
+
+ describe('when it is null', () => {
+ it('sets the default value', () => {
+ mutations[types.RECEIVE_TRACE_SUCCESS](stateWithNewLog, {
+ append: true,
+ html,
+ size: 511846,
+ complete: false,
+ lines: null,
+ });
+
+ expect(stateWithNewLog.trace).toEqual([]);
+ });
+ });
+ });
+ });
});
describe('STOP_POLLING_TRACE', () => {
diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js
index 780d42fd6a1..43dacfe622c 100644
--- a/spec/frontend/jobs/store/utils_spec.js
+++ b/spec/frontend/jobs/store/utils_spec.js
@@ -1,4 +1,13 @@
-import { logLinesParser, updateIncrementalTrace } from '~/jobs/store/utils';
+import {
+ logLinesParser,
+ updateIncrementalTrace,
+ parseHeaderLine,
+ parseLine,
+ addDurationToHeader,
+ isCollapsibleSection,
+ findOffsetAndRemove,
+ getIncrementalLineNumber,
+} from '~/jobs/store/utils';
import {
utilsMockData,
originalTrace,
@@ -11,6 +20,153 @@ import {
} from '../components/log/mock_data';
describe('Jobs Store Utils', () => {
+ describe('parseHeaderLine', () => {
+ it('returns a new object with the header keys and the provided line parsed', () => {
+ const headerLine = { content: [{ text: 'foo' }] };
+ const parsedHeaderLine = parseHeaderLine(headerLine, 2);
+
+ expect(parsedHeaderLine).toEqual({
+ isClosed: true,
+ isHeader: true,
+ line: {
+ ...headerLine,
+ lineNumber: 2,
+ },
+ lines: [],
+ });
+ });
+ });
+
+ describe('parseLine', () => {
+ it('returns a new object with the lineNumber key added to the provided line object', () => {
+ const line = { content: [{ text: 'foo' }] };
+ const parsed = parseLine(line, 1);
+ expect(parsed.content).toEqual(line.content);
+ expect(parsed.lineNumber).toEqual(1);
+ });
+ });
+
+ describe('addDurationToHeader', () => {
+ const duration = {
+ offset: 106,
+ content: [],
+ section: 'prepare-script',
+ section_duration: '00:03',
+ };
+
+ it('adds the section duration to the correct header', () => {
+ const parsed = [
+ {
+ isClosed: true,
+ isHeader: true,
+ line: {
+ section: 'prepare-script',
+ content: [{ text: 'foo' }],
+ },
+ lines: [],
+ },
+ {
+ isClosed: true,
+ isHeader: true,
+ line: {
+ section: 'foo-bar',
+ content: [{ text: 'foo' }],
+ },
+ lines: [],
+ },
+ ];
+
+ addDurationToHeader(parsed, duration);
+
+ expect(parsed[0].line.section_duration).toEqual(duration.section_duration);
+ expect(parsed[1].line.section_duration).toEqual(undefined);
+ });
+
+ it('does not add the section duration when the headers do not match', () => {
+ const parsed = [
+ {
+ isClosed: true,
+ isHeader: true,
+ line: {
+ section: 'bar-foo',
+ content: [{ text: 'foo' }],
+ },
+ lines: [],
+ },
+ {
+ isClosed: true,
+ isHeader: true,
+ line: {
+ section: 'foo-bar',
+ content: [{ text: 'foo' }],
+ },
+ lines: [],
+ },
+ ];
+ addDurationToHeader(parsed, duration);
+
+ expect(parsed[0].line.section_duration).toEqual(undefined);
+ expect(parsed[1].line.section_duration).toEqual(undefined);
+ });
+
+ it('does not add when content has no headers', () => {
+ const parsed = [
+ {
+ section: 'bar-foo',
+ content: [{ text: 'foo' }],
+ lineNumber: 1,
+ },
+ {
+ section: 'foo-bar',
+ content: [{ text: 'foo' }],
+ lineNumber: 2,
+ },
+ ];
+
+ addDurationToHeader(parsed, duration);
+
+ expect(parsed[0].line).toEqual(undefined);
+ expect(parsed[1].line).toEqual(undefined);
+ });
+ });
+
+ describe('isCollapsibleSection', () => {
+ const header = {
+ isHeader: true,
+ line: {
+ section: 'foo',
+ },
+ };
+ const line = {
+ lineNumber: 1,
+ section: 'foo',
+ content: [],
+ };
+
+ it('returns true when line belongs to the last section', () => {
+ expect(isCollapsibleSection([header], header, { section: 'foo', content: [] })).toEqual(true);
+ });
+
+ it('returns false when last line was not an header', () => {
+ expect(isCollapsibleSection([line], line, { section: 'bar' })).toEqual(false);
+ });
+
+ it('returns false when accumulator is empty', () => {
+ expect(isCollapsibleSection([], { isHeader: true }, { section: 'bar' })).toEqual(false);
+ });
+
+ it('returns false when section_duration is defined', () => {
+ expect(isCollapsibleSection([header], header, { section_duration: '10:00' })).toEqual(false);
+ });
+
+ it('returns false when `section` is not a match', () => {
+ expect(isCollapsibleSection([header], header, { section: 'bar' })).toEqual(false);
+ });
+
+ it('returns false when no parameters are provided', () => {
+ expect(isCollapsibleSection()).toEqual(false);
+ });
+ });
describe('logLinesParser', () => {
let result;
@@ -43,7 +199,7 @@ describe('Jobs Store Utils', () => {
describe('section duration', () => {
it('adds the section information to the header section', () => {
- expect(result[1].section_duration).toEqual(utilsMockData[4].section_duration);
+ expect(result[1].line.section_duration).toEqual(utilsMockData[4].section_duration);
});
it('does not add section duration as a line', () => {
@@ -52,11 +208,183 @@ describe('Jobs Store Utils', () => {
});
});
+ describe('findOffsetAndRemove', () => {
+ describe('when last item is header', () => {
+ const existingLog = [
+ {
+ isHeader: true,
+ isClosed: true,
+ line: { content: [{ text: 'bar' }], offset: 10, lineNumber: 1 },
+ },
+ ];
+
+ describe('and matches the offset', () => {
+ it('returns an array with the item removed', () => {
+ const newData = [{ offset: 10, content: [{ text: 'foobar' }] }];
+ const result = findOffsetAndRemove(newData, existingLog);
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('and does not match the offset', () => {
+ it('returns the provided existing log', () => {
+ const newData = [{ offset: 110, content: [{ text: 'foobar' }] }];
+ const result = findOffsetAndRemove(newData, existingLog);
+
+ expect(result).toEqual(existingLog);
+ });
+ });
+ });
+
+ describe('when last item is a regular line', () => {
+ const existingLog = [{ content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }];
+
+ describe('and matches the offset', () => {
+ it('returns an array with the item removed', () => {
+ const newData = [{ offset: 10, content: [{ text: 'foobar' }] }];
+ const result = findOffsetAndRemove(newData, existingLog);
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('and does not match the fofset', () => {
+ it('returns the provided old log', () => {
+ const newData = [{ offset: 101, content: [{ text: 'foobar' }] }];
+ const result = findOffsetAndRemove(newData, existingLog);
+
+ expect(result).toEqual(existingLog);
+ });
+ });
+ });
+
+ describe('when last item is nested', () => {
+ const existingLog = [
+ {
+ isHeader: true,
+ isClosed: true,
+ lines: [{ offset: 101, content: [{ text: 'foobar' }], lineNumber: 2 }],
+ line: {
+ offset: 10,
+ lineNumber: 1,
+ section_duration: '10:00',
+ },
+ },
+ ];
+
+ describe('and matches the offset', () => {
+ it('returns an array with the last nested line item removed', () => {
+ const newData = [{ offset: 101, content: [{ text: 'foobar' }] }];
+
+ const result = findOffsetAndRemove(newData, existingLog);
+ expect(result[0].lines).toEqual([]);
+ });
+ });
+
+ describe('and does not match the offset', () => {
+ it('returns the provided old log', () => {
+ const newData = [{ offset: 120, content: [{ text: 'foobar' }] }];
+
+ const result = findOffsetAndRemove(newData, existingLog);
+ expect(result).toEqual(existingLog);
+ });
+ });
+ });
+
+ describe('when no data is provided', () => {
+ it('returns an empty array', () => {
+ const result = findOffsetAndRemove();
+ expect(result).toEqual([]);
+ });
+ });
+ });
+
+ describe('getIncrementalLineNumber', () => {
+ describe('when last line is 0', () => {
+ it('returns 1', () => {
+ const log = [
+ {
+ content: [],
+ lineNumber: 0,
+ },
+ ];
+
+ expect(getIncrementalLineNumber(log)).toEqual(1);
+ });
+ });
+
+ describe('with unnested line', () => {
+ it('returns the lineNumber of the last item in the array', () => {
+ const log = [
+ {
+ content: [],
+ lineNumber: 10,
+ },
+ {
+ content: [],
+ lineNumber: 101,
+ },
+ ];
+
+ expect(getIncrementalLineNumber(log)).toEqual(102);
+ });
+ });
+
+ describe('when last line is the header section', () => {
+ it('returns the lineNumber of the last item in the array', () => {
+ const log = [
+ {
+ content: [],
+ lineNumber: 10,
+ },
+ {
+ isHeader: true,
+ line: {
+ lineNumber: 101,
+ content: [],
+ },
+ lines: [],
+ },
+ ];
+
+ expect(getIncrementalLineNumber(log)).toEqual(102);
+ });
+ });
+
+ describe('when last line is a nested line', () => {
+ it('returns the lineNumber of the last item in the nested array', () => {
+ const log = [
+ {
+ content: [],
+ lineNumber: 10,
+ },
+ {
+ isHeader: true,
+ line: {
+ lineNumber: 101,
+ content: [],
+ },
+ lines: [
+ {
+ lineNumber: 102,
+ content: [],
+ },
+ { lineNumber: 103, content: [] },
+ ],
+ },
+ ];
+
+ expect(getIncrementalLineNumber(log)).toEqual(104);
+ });
+ });
+ });
+
describe('updateIncrementalTrace', () => {
describe('without repeated section', () => {
it('concats and parses both arrays', () => {
const oldLog = logLinesParser(originalTrace);
- const result = updateIncrementalTrace(originalTrace, oldLog, regularIncremental);
+ const result = updateIncrementalTrace(regularIncremental, oldLog);
expect(result).toEqual([
{
@@ -84,7 +412,7 @@ describe('Jobs Store Utils', () => {
describe('with regular line repeated offset', () => {
it('updates the last line and formats with the incremental part', () => {
const oldLog = logLinesParser(originalTrace);
- const result = updateIncrementalTrace(originalTrace, oldLog, regularIncrementalRepeated);
+ const result = updateIncrementalTrace(regularIncrementalRepeated, oldLog);
expect(result).toEqual([
{
@@ -103,7 +431,7 @@ describe('Jobs Store Utils', () => {
describe('with header line repeated', () => {
it('updates the header line and formats with the incremental part', () => {
const oldLog = logLinesParser(headerTrace);
- const result = updateIncrementalTrace(headerTrace, oldLog, headerTraceIncremental);
+ const result = updateIncrementalTrace(headerTraceIncremental, oldLog);
expect(result).toEqual([
{
@@ -117,7 +445,7 @@ describe('Jobs Store Utils', () => {
text: 'updated log line',
},
],
- sections: ['section'],
+ section: 'section',
lineNumber: 0,
},
lines: [],
@@ -129,11 +457,7 @@ describe('Jobs Store Utils', () => {
describe('with collapsible line repeated', () => {
it('updates the collapsible line and formats with the incremental part', () => {
const oldLog = logLinesParser(collapsibleTrace);
- const result = updateIncrementalTrace(
- collapsibleTrace,
- oldLog,
- collapsibleTraceIncremental,
- );
+ const result = updateIncrementalTrace(collapsibleTraceIncremental, oldLog);
expect(result).toEqual([
{
@@ -147,7 +471,7 @@ describe('Jobs Store Utils', () => {
text: 'log line',
},
],
- sections: ['section'],
+ section: 'section',
lineNumber: 0,
},
lines: [
@@ -158,7 +482,7 @@ describe('Jobs Store Utils', () => {
text: 'updated log line',
},
],
- sections: ['section'],
+ section: 'section',
lineNumber: 1,
},
],
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 9f1700bb243..e2e71229320 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -388,20 +388,6 @@ describe('prettyTime methods', () => {
expect(datetimeUtility.stringifyTime(timeObject, true)).toEqual('1 week 1 hour');
});
});
-
- describe('abbreviateTime', () => {
- it('should abbreviate stringified times for weeks', () => {
- const fullTimeString = '1w 3d 4h 5m';
-
- expect(datetimeUtility.abbreviateTime(fullTimeString)).toBe('1w');
- });
-
- it('should abbreviate stringified times for non-weeks', () => {
- const fullTimeString = '0w 3d 4h 5m';
-
- expect(datetimeUtility.abbreviateTime(fullTimeString)).toBe('3d');
- });
- });
});
describe('calculateRemainingMilliseconds', () => {
@@ -440,3 +426,18 @@ describe('newDate', () => {
expect(initialDate instanceof Date).toBe(true);
});
});
+
+describe('getDateInPast', () => {
+ const date = new Date(1563235200000); // 2019-07-16T00:00:00.000Z;
+ const daysInPast = 90;
+
+ it('returns the correct date in the past', () => {
+ const dateInPast = datetimeUtility.getDateInPast(date, daysInPast);
+ expect(dateInPast).toBe('2019-04-17T00:00:00.000Z');
+ });
+
+ it('does not modifiy the original date', () => {
+ datetimeUtility.getDateInPast(date, daysInPast);
+ expect(date).toStrictEqual(new Date(1563235200000));
+ });
+});
diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js
index 77d7478d317..381d7c6f8d9 100644
--- a/spec/frontend/lib/utils/number_utility_spec.js
+++ b/spec/frontend/lib/utils/number_utility_spec.js
@@ -6,6 +6,7 @@ import {
numberToHumanSize,
sum,
isOdd,
+ median,
} from '~/lib/utils/number_utils';
describe('Number Utils', () => {
@@ -109,4 +110,16 @@ describe('Number Utils', () => {
expect(isOdd(1)).toEqual(1);
});
});
+
+ describe('median', () => {
+ it('computes the median for a given array with odd length', () => {
+ const items = [10, 27, 20, 5, 19];
+ expect(median(items)).toBe(19);
+ });
+
+ it('computes the median for a given array with even length', () => {
+ const items = [10, 27, 20, 5, 19, 4];
+ expect(median(items)).toBe(14.5);
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/set_spec.js b/spec/frontend/lib/utils/set_spec.js
new file mode 100644
index 00000000000..7636a1c634c
--- /dev/null
+++ b/spec/frontend/lib/utils/set_spec.js
@@ -0,0 +1,19 @@
+import { isSubset } from '~/lib/utils/set';
+
+describe('utils/set', () => {
+ describe('isSubset', () => {
+ it.each`
+ subset | superset | expected
+ ${new Set()} | ${new Set()} | ${true}
+ ${new Set()} | ${new Set([1])} | ${true}
+ ${new Set([1])} | ${new Set([1])} | ${true}
+ ${new Set([1, 3])} | ${new Set([1, 2, 3])} | ${true}
+ ${new Set([1])} | ${new Set()} | ${false}
+ ${new Set([1])} | ${new Set([2])} | ${false}
+ ${new Set([7, 8, 9])} | ${new Set([1, 2, 3])} | ${false}
+ ${new Set([1, 2, 3, 4])} | ${new Set([1, 2, 3])} | ${false}
+ `('isSubset($subset, $superset) === $expected', ({ subset, superset, expected }) => {
+ expect(isSubset(subset, superset)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/suppress_ajax_errors_during_navigation_spec.js b/spec/frontend/lib/utils/suppress_ajax_errors_during_navigation_spec.js
new file mode 100644
index 00000000000..89e8459d594
--- /dev/null
+++ b/spec/frontend/lib/utils/suppress_ajax_errors_during_navigation_spec.js
@@ -0,0 +1,37 @@
+import suppressAjaxErrorsDuringNavigation from '~/lib/utils/suppress_ajax_errors_during_navigation';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('suppressAjaxErrorsDuringNavigation', () => {
+ const OTHER_ERR_CODE = 'foo';
+ const NAV_ERR_CODE = 'ECONNABORTED';
+
+ it.each`
+ isFeatureFlagEnabled | isUserNavigating | code
+ ${false} | ${false} | ${OTHER_ERR_CODE}
+ ${false} | ${false} | ${NAV_ERR_CODE}
+ ${false} | ${true} | ${OTHER_ERR_CODE}
+ ${false} | ${true} | ${NAV_ERR_CODE}
+ ${true} | ${false} | ${OTHER_ERR_CODE}
+ ${true} | ${false} | ${NAV_ERR_CODE}
+ ${true} | ${true} | ${OTHER_ERR_CODE}
+ `('should return a rejected Promise', ({ isFeatureFlagEnabled, isUserNavigating, code }) => {
+ const err = { code };
+ const actual = suppressAjaxErrorsDuringNavigation(err, isUserNavigating, isFeatureFlagEnabled);
+
+ return expect(actual).rejects.toBe(err);
+ });
+
+ it('should return a Promise that never resolves', () => {
+ const err = { code: NAV_ERR_CODE };
+ const actual = suppressAjaxErrorsDuringNavigation(err, true, true);
+
+ const thenCallback = jest.fn();
+ const catchCallback = jest.fn();
+ actual.then(thenCallback).catch(catchCallback);
+
+ return waitForPromises().then(() => {
+ expect(thenCallback).not.toHaveBeenCalled();
+ expect(catchCallback).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 41df93c9a48..6edb2e2dce2 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -136,6 +136,24 @@ describe('URL utility', () => {
});
});
+ describe('doesHashExistInUrl', () => {
+ it('should return true when the given string exists in the URL hash', () => {
+ setWindowLocation({
+ href: 'https://gitlab.com/gitlab-org/gitlab-test/issues/1#note_1',
+ });
+
+ expect(urlUtils.doesHashExistInUrl('note_')).toBe(true);
+ });
+
+ it('should return false when the given string does not exist in the URL hash', () => {
+ setWindowLocation({
+ href: 'https://gitlab.com/gitlab-org/gitlab-test/issues/1#note_1',
+ });
+
+ expect(urlUtils.doesHashExistInUrl('doesnotexist')).toBe(false);
+ });
+ });
+
describe('setUrlFragment', () => {
it('should set fragment when url has no fragment', () => {
const url = urlUtils.setUrlFragment('/home/feature', 'usage');
diff --git a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_input_spec.js b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_input_spec.js
new file mode 100644
index 00000000000..1315e1226a4
--- /dev/null
+++ b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_input_spec.js
@@ -0,0 +1,66 @@
+import { mount } from '@vue/test-utils';
+import DateTimePickerInput from '~/monitoring/components/date_time_picker/date_time_picker_input.vue';
+
+const inputLabel = 'This is a label';
+const inputValue = 'something';
+
+describe('DateTimePickerInput', () => {
+ let wrapper;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(DateTimePickerInput, {
+ propsData: {
+ state: null,
+ value: '',
+ label: '',
+ ...propsData,
+ },
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders label above the input', () => {
+ createComponent({
+ label: inputLabel,
+ });
+
+ expect(wrapper.find('.gl-form-group label').text()).toBe(inputLabel);
+ });
+
+ it('renders the same `ID` for input and `for` for label', () => {
+ createComponent({ label: inputLabel });
+
+ expect(wrapper.find('.gl-form-group label').attributes('for')).toBe(
+ wrapper.find('input').attributes('id'),
+ );
+ });
+
+ it('renders valid input in gray color instead of green', () => {
+ createComponent({
+ state: true,
+ });
+
+ expect(wrapper.find('input').classes('is-valid')).toBe(false);
+ });
+
+ it('renders invalid input in red color', () => {
+ createComponent({
+ state: false,
+ });
+
+ expect(wrapper.find('input').classes('is-invalid')).toBe(true);
+ });
+
+ it('input event is emitted when focus is lost', () => {
+ createComponent();
+ jest.spyOn(wrapper.vm, '$emit');
+ wrapper.find('input').setValue(inputValue);
+ wrapper.find('input').trigger('blur');
+
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', inputValue);
+ });
+});
diff --git a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js
new file mode 100644
index 00000000000..be544435671
--- /dev/null
+++ b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js
@@ -0,0 +1,157 @@
+import { mount } from '@vue/test-utils';
+import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_picker.vue';
+import { timeWindows } from '~/monitoring/constants';
+
+const timeWindowsCount = Object.keys(timeWindows).length;
+const selectedTimeWindow = {
+ start: '2019-10-10T07:00:00.000Z',
+ end: '2019-10-13T07:00:00.000Z',
+};
+const selectedTimeWindowText = `3 days`;
+
+describe('DateTimePicker', () => {
+ let dateTimePicker;
+
+ const dropdownToggle = () => dateTimePicker.find('.dropdown-toggle');
+ const dropdownMenu = () => dateTimePicker.find('.dropdown-menu');
+ const applyButtonElement = () => dateTimePicker.find('button[variant="success"]').element;
+ const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element;
+ const fillInputAndBlur = (input, val) => {
+ dateTimePicker.find(input).setValue(val);
+ dateTimePicker.find(input).trigger('blur');
+ };
+
+ const createComponent = props => {
+ dateTimePicker = mount(DateTimePicker, {
+ propsData: {
+ timeWindows,
+ selectedTimeWindow,
+ ...props,
+ },
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ dateTimePicker.destroy();
+ });
+
+ it('renders dropdown toggle button with selected text', done => {
+ createComponent();
+ dateTimePicker.vm.$nextTick(() => {
+ expect(dropdownToggle().text()).toBe(selectedTimeWindowText);
+ done();
+ });
+ });
+
+ it('renders dropdown with 2 custom time range inputs', () => {
+ createComponent();
+ dateTimePicker.vm.$nextTick(() => {
+ expect(dateTimePicker.findAll('input').length).toBe(2);
+ });
+ });
+
+ it('renders inputs with h/m/s truncated if its all 0s', done => {
+ createComponent({
+ selectedTimeWindow: {
+ start: '2019-10-10T00:00:00.000Z',
+ end: '2019-10-14T00:10:00.000Z',
+ },
+ });
+ dateTimePicker.vm.$nextTick(() => {
+ expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10');
+ expect(dateTimePicker.find('#custom-time-to').element.value).toBe('2019-10-14 00:10:00');
+ done();
+ });
+ });
+
+ it(`renders dropdown with ${timeWindowsCount} items in quick range`, done => {
+ createComponent();
+ dropdownToggle().trigger('click');
+ dateTimePicker.vm.$nextTick(() => {
+ expect(dateTimePicker.findAll('.dropdown-item').length).toBe(timeWindowsCount);
+ done();
+ });
+ });
+
+ it(`renders dropdown with correct quick range item selected`, done => {
+ createComponent();
+ dropdownToggle().trigger('click');
+ dateTimePicker.vm.$nextTick(() => {
+ expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(selectedTimeWindowText);
+
+ expect(dateTimePicker.find('.dropdown-item.active svg').isVisible()).toBe(true);
+ done();
+ });
+ });
+
+ it('renders a disabled apply button on load', () => {
+ createComponent();
+
+ expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
+ });
+
+ it('displays inline error message if custom time range inputs are invalid', done => {
+ createComponent();
+ fillInputAndBlur('#custom-time-from', '2019-10-01abc');
+ fillInputAndBlur('#custom-time-to', '2019-10-10abc');
+
+ dateTimePicker.vm.$nextTick(() => {
+ expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2);
+ done();
+ });
+ });
+
+ it('keeps apply button disabled with invalid custom time range inputs', done => {
+ createComponent();
+ fillInputAndBlur('#custom-time-from', '2019-10-01abc');
+ fillInputAndBlur('#custom-time-to', '2019-09-19');
+
+ dateTimePicker.vm.$nextTick(() => {
+ expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
+ done();
+ });
+ });
+
+ it('enables apply button with valid custom time range inputs', done => {
+ createComponent();
+ fillInputAndBlur('#custom-time-from', '2019-10-01');
+ fillInputAndBlur('#custom-time-to', '2019-10-19');
+
+ dateTimePicker.vm.$nextTick(() => {
+ expect(applyButtonElement().getAttribute('disabled')).toBeNull();
+ done();
+ });
+ });
+
+ it('returns an object when apply is clicked', done => {
+ createComponent();
+ fillInputAndBlur('#custom-time-from', '2019-10-01');
+ fillInputAndBlur('#custom-time-to', '2019-10-19');
+
+ dateTimePicker.vm.$nextTick(() => {
+ jest.spyOn(dateTimePicker.vm, '$emit');
+ applyButtonElement().click();
+
+ expect(dateTimePicker.vm.$emit).toHaveBeenCalledWith('onApply', {
+ end: '2019-10-19T00:00:00Z',
+ start: '2019-10-01T00:00:00Z',
+ });
+ done();
+ });
+ });
+
+ it('hides the popover with cancel button', done => {
+ createComponent();
+ dropdownToggle().trigger('click');
+
+ dateTimePicker.vm.$nextTick(() => {
+ cancelButtonElement().click();
+
+ dateTimePicker.vm.$nextTick(() => {
+ expect(dropdownMenu().classes('show')).toBe(false);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/embed/embed_spec.js b/spec/frontend/monitoring/embed/embed_spec.js
index 1ce14e2418a..5de1a7c4c3b 100644
--- a/spec/frontend/monitoring/embed/embed_spec.js
+++ b/spec/frontend/monitoring/embed/embed_spec.js
@@ -74,5 +74,9 @@ describe('Embed', () => {
expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true);
expect(wrapper.findAll(MonitorTimeSeriesChart).length).toBe(2);
});
+
+ it('includes groupId with dashboardUrl', () => {
+ expect(wrapper.find(MonitorTimeSeriesChart).props('groupId')).toBe(TEST_HOST);
+ });
});
});
diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js
new file mode 100644
index 00000000000..1e8d5753885
--- /dev/null
+++ b/spec/frontend/monitoring/utils_spec.js
@@ -0,0 +1,54 @@
+import * as monitoringUtils from '~/monitoring/utils';
+
+describe('Snowplow Events', () => {
+ const generatedLink = 'http://chart.link.com';
+ const chartTitle = 'Some metric chart';
+
+ describe('trackGenerateLinkToChartEventOptions', () => {
+ it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
+ document.body.dataset.page = 'groups:clusters:show';
+
+ expect(monitoringUtils.generateLinkToChartOptions(generatedLink)).toEqual({
+ category: 'Cluster Monitoring',
+ action: 'generate_link_to_cluster_metric_chart',
+ label: 'Chart link',
+ property: generatedLink,
+ });
+ });
+
+ it('should return Incident Management event options if located on Metrics Dashboard', () => {
+ document.body.dataset.page = 'metrics:show';
+
+ expect(monitoringUtils.generateLinkToChartOptions(generatedLink)).toEqual({
+ category: 'Incident Management::Embedded metrics',
+ action: 'generate_link_to_metrics_chart',
+ label: 'Chart link',
+ property: generatedLink,
+ });
+ });
+ });
+
+ describe('trackDownloadCSVEvent', () => {
+ it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
+ document.body.dataset.page = 'groups:clusters:show';
+
+ expect(monitoringUtils.downloadCSVOptions(chartTitle)).toEqual({
+ category: 'Cluster Monitoring',
+ action: 'download_csv_of_cluster_metric_chart',
+ label: 'Chart title',
+ property: chartTitle,
+ });
+ });
+
+ it('should return Incident Management event options if located on Metrics Dashboard', () => {
+ document.body.dataset.page = 'metriss:show';
+
+ expect(monitoringUtils.downloadCSVOptions(chartTitle)).toEqual({
+ category: 'Incident Management::Embedded metrics',
+ action: 'download_csv_of_metrics_dashboard_chart',
+ label: 'Chart title',
+ property: chartTitle,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap b/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap
index 11d65ced180..b29d093130a 100644
--- a/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap
+++ b/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap
@@ -11,7 +11,6 @@ exports[`JumpToNextDiscussionButton matches the snapshot 1`] = `
title=""
>
<icon-stub
- cssclasses=""
name="comment-next"
size="16"
/>
diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
new file mode 100644
index 00000000000..78a736a9060
--- /dev/null
+++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
@@ -0,0 +1,63 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`User Operation confirmation modal renders modal with form included 1`] = `
+<div>
+ <p>
+ content
+ </p>
+
+ <p>
+ To confirm, type
+ <code>
+ username
+ </code>
+ </p>
+
+ <form
+ action="delete-url"
+ method="post"
+ >
+ <input
+ name="_method"
+ type="hidden"
+ value="delete"
+ />
+
+ <input
+ name="authenticity_token"
+ type="hidden"
+ value="csrf"
+ />
+
+ <glforminput-stub
+ autocomplete="off"
+ autofocus=""
+ name="username"
+ type="text"
+ value=""
+ />
+ </form>
+
+ <glbutton-stub
+ variant="secondary"
+ >
+ Cancel
+ </glbutton-stub>
+
+ <glbutton-stub
+ disabled="true"
+ variant="warning"
+ >
+
+ secondaryAction
+
+ </glbutton-stub>
+
+ <glbutton-stub
+ disabled="true"
+ variant="danger"
+ >
+ action
+ </glbutton-stub>
+</div>
+`;
diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap
new file mode 100644
index 00000000000..4a3989f5192
--- /dev/null
+++ b/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap
@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`User Operation confirmation modal renders modal with form included 1`] = `
+<glmodal-stub
+ modalclass=""
+ modalid="user-operation-modal"
+ ok-title="action"
+ ok-variant="warning"
+ title="title"
+ titletag="h4"
+>
+ <form
+ action="/url"
+ method="post"
+ >
+ <span>
+ content
+ </span>
+
+ <input
+ name="_method"
+ type="hidden"
+ value="method"
+ />
+
+ <input
+ name="authenticity_token"
+ type="hidden"
+ value="csrf"
+ />
+ </form>
+</glmodal-stub>
+`;
diff --git a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js
new file mode 100644
index 00000000000..57802a41bb5
--- /dev/null
+++ b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js
@@ -0,0 +1,85 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlFormInput } from '@gitlab/ui';
+import DeleteUserModal from '~/pages/admin/users/components/delete_user_modal.vue';
+import ModalStub from './stubs/modal_stub';
+
+describe('User Operation confirmation modal', () => {
+ let wrapper;
+
+ const findButton = variant =>
+ wrapper
+ .findAll(GlButton)
+ .filter(w => w.attributes('variant') === variant)
+ .at(0);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(DeleteUserModal, {
+ propsData: {
+ title: 'title',
+ content: 'content',
+ action: 'action',
+ secondaryAction: 'secondaryAction',
+ deleteUserUrl: 'delete-url',
+ blockUserUrl: 'block-url',
+ username: 'username',
+ csrfToken: 'csrf',
+ ...props,
+ },
+ stubs: {
+ GlModal: ModalStub,
+ },
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders modal with form included', () => {
+ createComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it.each`
+ variant | prop | action
+ ${'danger'} | ${'deleteUserUrl'} | ${'delete'}
+ ${'warning'} | ${'blockUserUrl'} | ${'block'}
+ `('closing modal with $variant button triggers $action', ({ variant, prop }) => {
+ createComponent();
+ const form = wrapper.find('form');
+ jest.spyOn(form.element, 'submit').mockReturnValue();
+ const modalButton = findButton(variant);
+ modalButton.vm.$emit('click');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(form.element.submit).toHaveBeenCalled();
+ expect(form.element.action).toContain(wrapper.props(prop));
+ expect(new FormData(form.element).get('authenticity_token')).toEqual(
+ wrapper.props('csrfToken'),
+ );
+ });
+ });
+
+ it('disables buttons by default', () => {
+ createComponent();
+ const blockButton = findButton('warning');
+ const deleteButton = findButton('danger');
+ expect(blockButton.attributes().disabled).toBeTruthy();
+ expect(deleteButton.attributes().disabled).toBeTruthy();
+ });
+
+ it('enables button when username is typed', () => {
+ createComponent({
+ username: 'some-username',
+ });
+ wrapper.find(GlFormInput).vm.$emit('input', 'some-username');
+ const blockButton = findButton('warning');
+ const deleteButton = findButton('danger');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(blockButton.attributes().disabled).toBeFalsy();
+ expect(deleteButton.attributes().disabled).toBeFalsy();
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/users/components/stubs/modal_stub.js b/spec/frontend/pages/admin/users/components/stubs/modal_stub.js
new file mode 100644
index 00000000000..4dc55e909a0
--- /dev/null
+++ b/spec/frontend/pages/admin/users/components/stubs/modal_stub.js
@@ -0,0 +1,23 @@
+const ModalStub = {
+ inheritAttrs: false,
+ name: 'glmodal-stub',
+ data() {
+ return {
+ showWasCalled: false,
+ };
+ },
+ methods: {
+ show() {
+ this.showWasCalled = true;
+ },
+ hide() {},
+ },
+ render(h) {
+ const children = [this.$slots.default, this.$slots['modal-footer']]
+ .filter(Boolean)
+ .reduce((acc, nodes) => acc.concat(nodes), []);
+ return h('div', children);
+ },
+};
+
+export default ModalStub;
diff --git a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js
new file mode 100644
index 00000000000..7653fffc502
--- /dev/null
+++ b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js
@@ -0,0 +1,148 @@
+import { shallowMount } from '@vue/test-utils';
+import UserModalManager from '~/pages/admin/users/components/user_modal_manager.vue';
+import ModalStub from './stubs/modal_stub';
+
+describe('Users admin page Modal Manager', () => {
+ const modalConfiguration = {
+ action1: {
+ title: 'action1',
+ content: 'Action Modal 1',
+ },
+ action2: {
+ title: 'action2',
+ content: 'Action Modal 2',
+ },
+ };
+
+ const actionModals = {
+ action1: ModalStub,
+ action2: ModalStub,
+ };
+
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(UserModalManager, {
+ propsData: {
+ actionModals,
+ modalConfiguration,
+ csrfToken: 'dummyCSRF',
+ ...props,
+ },
+ stubs: {
+ dummyComponent1: true,
+ dummyComponent2: true,
+ },
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('render behavior', () => {
+ it('does not renders modal when initialized', () => {
+ createComponent();
+ expect(wrapper.find({ ref: 'modal' }).exists()).toBeFalsy();
+ });
+
+ it('throws if non-existing action is requested', () => {
+ createComponent();
+ expect(() => wrapper.vm.show({ glModalAction: 'non-existing' })).toThrow();
+ });
+
+ it('throws if action has no proper configuration', () => {
+ createComponent({
+ modalConfiguration: {},
+ });
+ expect(() => wrapper.vm.show({ glModalAction: 'action1' })).toThrow();
+ });
+
+ it('renders modal with expected props when valid configuration is passed', () => {
+ createComponent();
+ wrapper.vm.show({
+ glModalAction: 'action1',
+ extraProp: 'extraPropValue',
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ const modal = wrapper.find({ ref: 'modal' });
+ expect(modal.exists()).toBeTruthy();
+ expect(modal.vm.$attrs.csrfToken).toEqual('dummyCSRF');
+ expect(modal.vm.$attrs.extraProp).toEqual('extraPropValue');
+ expect(modal.vm.showWasCalled).toBeTruthy();
+ });
+ });
+ });
+
+ describe('global listener', () => {
+ beforeEach(() => {
+ jest.spyOn(document, 'addEventListener');
+ jest.spyOn(document, 'removeEventListener');
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('registers global listener on mount', () => {
+ createComponent();
+ expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
+ });
+
+ it('removes global listener on destroy', () => {
+ createComponent();
+ wrapper.destroy();
+ expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function));
+ });
+ });
+
+ describe('click handling', () => {
+ let node;
+
+ beforeEach(() => {
+ node = document.createElement('div');
+ document.body.appendChild(node);
+ });
+
+ afterEach(() => {
+ node.remove();
+ node = null;
+ });
+
+ it('ignores wrong clicks', () => {
+ createComponent();
+ const event = new window.MouseEvent('click', {
+ bubbles: true,
+ cancellable: true,
+ });
+ jest.spyOn(event, 'preventDefault');
+ node.dispatchEvent(event);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ });
+
+ it('captures click with glModalAction', () => {
+ createComponent();
+ node.dataset.glModalAction = 'action1';
+ const event = new window.MouseEvent('click', {
+ bubbles: true,
+ cancellable: true,
+ });
+ jest.spyOn(event, 'preventDefault');
+ node.dispatchEvent(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ return wrapper.vm.$nextTick().then(() => {
+ const modal = wrapper.find({ ref: 'modal' });
+ expect(modal.exists()).toBeTruthy();
+ expect(modal.vm.showWasCalled).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js b/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js
new file mode 100644
index 00000000000..0ecdae2618c
--- /dev/null
+++ b/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js
@@ -0,0 +1,47 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import UserOperationConfirmationModal from '~/pages/admin/users/components/user_operation_confirmation_modal.vue';
+
+describe('User Operation confirmation modal', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(UserOperationConfirmationModal, {
+ propsData: {
+ title: 'title',
+ content: 'content',
+ action: 'action',
+ url: '/url',
+ username: 'username',
+ csrfToken: 'csrf',
+ method: 'method',
+ ...props,
+ },
+ sync: false,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders modal with form included', () => {
+ createComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('closing modal with ok button triggers form submit', () => {
+ createComponent();
+ const form = wrapper.find('form');
+ jest.spyOn(form.element, 'submit').mockReturnValue();
+ wrapper.find(GlModal).vm.$emit('ok');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(form.element.submit).toHaveBeenCalled();
+ expect(form.element.action).toContain(wrapper.props('url'));
+ expect(new FormData(form.element).get('authenticity_token')).toEqual(
+ wrapper.props('csrfToken'),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js
new file mode 100644
index 00000000000..74f242431a1
--- /dev/null
+++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js
@@ -0,0 +1,111 @@
+import DetailedMetric from '~/performance_bar/components/detailed_metric.vue';
+import RequestWarning from '~/performance_bar/components/request_warning.vue';
+import { shallowMount } from '@vue/test-utils';
+
+describe('detailedMetric', () => {
+ const createComponent = props =>
+ shallowMount(DetailedMetric, {
+ propsData: {
+ ...props,
+ },
+ });
+
+ describe('when the current request has no details', () => {
+ const wrapper = createComponent({
+ currentRequest: {},
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ details: 'details',
+ keys: ['feature', 'request'],
+ });
+
+ it('does not render the element', () => {
+ expect(wrapper.isEmpty()).toBe(true);
+ });
+ });
+
+ describe('when the current request has details', () => {
+ const requestDetails = [
+ { duration: '100', feature: 'find_commit', request: 'abcdef', backtrace: ['hello', 'world'] },
+ { duration: '23', feature: 'rebase_in_progress', request: '', backtrace: ['world', 'hello'] },
+ ];
+
+ describe('with a default metric name', () => {
+ const wrapper = createComponent({
+ currentRequest: {
+ details: {
+ gitaly: {
+ duration: '123ms',
+ calls: '456',
+ details: requestDetails,
+ warnings: ['gitaly calls: 456 over 30'],
+ },
+ },
+ },
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ keys: ['feature', 'request'],
+ });
+
+ it('displays details', () => {
+ expect(wrapper.text().replace(/\s+/g, ' ')).toContain('123ms / 456');
+ });
+
+ it('adds a modal with a table of the details', () => {
+ wrapper
+ .findAll('.performance-bar-modal td:nth-child(1)')
+ .wrappers.forEach((duration, index) => {
+ expect(duration.text()).toContain(requestDetails[index].duration);
+ });
+
+ wrapper
+ .findAll('.performance-bar-modal td:nth-child(2)')
+ .wrappers.forEach((feature, index) => {
+ expect(feature.text()).toContain(requestDetails[index].feature);
+ });
+
+ wrapper
+ .findAll('.performance-bar-modal td:nth-child(2)')
+ .wrappers.forEach((request, index) => {
+ expect(request.text()).toContain(requestDetails[index].request);
+ });
+
+ expect(wrapper.find('.text-expander.js-toggle-button')).not.toBeNull();
+
+ wrapper.findAll('.performance-bar-modal td:nth-child(2)').wrappers.forEach(request => {
+ expect(request.text()).toContain('world');
+ });
+ });
+
+ it('displays the metric title', () => {
+ expect(wrapper.text()).toContain('gitaly');
+ });
+
+ it('displays request warnings', () => {
+ expect(wrapper.find(RequestWarning).exists()).toBe(true);
+ });
+ });
+
+ describe('when using a custom metric title', () => {
+ const wrapper = createComponent({
+ currentRequest: {
+ details: {
+ gitaly: {
+ duration: '123ms',
+ calls: '456',
+ details: requestDetails,
+ },
+ },
+ },
+ metric: 'gitaly',
+ title: 'custom',
+ header: 'Gitaly calls',
+ keys: ['feature', 'request'],
+ });
+
+ it('displays the custom title', () => {
+ expect(wrapper.text()).toContain('custom');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/performance_bar/components/performance_bar_app_spec.js b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
new file mode 100644
index 00000000000..ba403dd6209
--- /dev/null
+++ b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
@@ -0,0 +1,20 @@
+import PerformanceBarApp from '~/performance_bar/components/performance_bar_app.vue';
+import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store';
+import { shallowMount } from '@vue/test-utils';
+
+describe('performance bar app', () => {
+ const store = new PerformanceBarStore();
+ const wrapper = shallowMount(PerformanceBarApp, {
+ propsData: {
+ store,
+ env: 'development',
+ requestId: '123',
+ peekUrl: '/-/peek/results',
+ profileUrl: '?lineprofiler=true',
+ },
+ });
+
+ it('sets the class to match the environment', () => {
+ expect(wrapper.element.getAttribute('class')).toContain('development');
+ });
+});
diff --git a/spec/frontend/performance_bar/components/request_selector_spec.js b/spec/frontend/performance_bar/components/request_selector_spec.js
new file mode 100644
index 00000000000..a4ed55fbf15
--- /dev/null
+++ b/spec/frontend/performance_bar/components/request_selector_spec.js
@@ -0,0 +1,64 @@
+import RequestSelector from '~/performance_bar/components/request_selector.vue';
+import { shallowMount } from '@vue/test-utils';
+
+describe('request selector', () => {
+ const requests = [
+ {
+ id: '123',
+ url: 'https://gitlab.com/',
+ hasWarnings: false,
+ },
+ {
+ id: '456',
+ url: 'https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/1',
+ hasWarnings: false,
+ },
+ {
+ id: '789',
+ url: 'https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/1.json?serializer=widget',
+ hasWarnings: false,
+ },
+ {
+ id: 'abc',
+ url: 'https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/1/discussions.json',
+ hasWarnings: true,
+ },
+ ];
+
+ const wrapper = shallowMount(RequestSelector, {
+ propsData: {
+ requests,
+ currentRequest: requests[1],
+ },
+ });
+
+ function optionText(requestId) {
+ return wrapper
+ .find(`[value='${requestId}']`)
+ .text()
+ .trim();
+ }
+
+ it('displays the last component of the path', () => {
+ expect(optionText(requests[2].id)).toEqual('1.json?serializer=widget');
+ });
+
+ it('keeps the last two components of the path when the last component is numeric', () => {
+ expect(optionText(requests[1].id)).toEqual('merge_requests/1');
+ });
+
+ it('ignores trailing slashes', () => {
+ expect(optionText(requests[0].id)).toEqual('gitlab.com');
+ });
+
+ it('has a warning icon if any requests have warnings', () => {
+ expect(wrapper.find('span > gl-emoji').element.dataset.name).toEqual('warning');
+ });
+
+ it('adds a warning glyph to requests with warnings', () => {
+ const requestValue = wrapper.find('[value="abc"]').text();
+
+ expect(requestValue).toContain('discussions.json');
+ expect(requestValue).toContain('(!)');
+ });
+});
diff --git a/spec/frontend/performance_bar/components/request_warning_spec.js b/spec/frontend/performance_bar/components/request_warning_spec.js
new file mode 100644
index 00000000000..6d8bfba56f6
--- /dev/null
+++ b/spec/frontend/performance_bar/components/request_warning_spec.js
@@ -0,0 +1,33 @@
+import RequestWarning from '~/performance_bar/components/request_warning.vue';
+import { shallowMount } from '@vue/test-utils';
+
+describe('request warning', () => {
+ const htmlId = 'request-123';
+
+ describe('when the request has warnings', () => {
+ const wrapper = shallowMount(RequestWarning, {
+ propsData: {
+ htmlId,
+ warnings: ['gitaly calls: 30 over 10', 'gitaly duration: 1500 over 1000'],
+ },
+ });
+
+ it('adds a warning emoji with the correct ID', () => {
+ expect(wrapper.find('span[id]').attributes('id')).toEqual(htmlId);
+ expect(wrapper.find('span[id] gl-emoji').element.dataset.name).toEqual('warning');
+ });
+ });
+
+ describe('when the request does not have warnings', () => {
+ const wrapper = shallowMount(RequestWarning, {
+ propsData: {
+ htmlId,
+ warnings: [],
+ },
+ });
+
+ it('does nothing', () => {
+ expect(wrapper.isEmpty()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/registry/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/registry/components/__snapshots__/group_empty_state_spec.js.snap
new file mode 100644
index 00000000000..3f13b7d4d76
--- /dev/null
+++ b/spec/frontend/registry/components/__snapshots__/group_empty_state_spec.js.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Registry Group Empty state to match the default snapshot 1`] = `
+<div
+ class="row container-message empty-state"
+>
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no container images available in this group"
+ class=""
+ src="imageUrl"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content"
+ >
+ <h4
+ class="center"
+ style=""
+ >
+ There are no container images available in this group
+ </h4>
+
+ <p
+ class="center"
+ style=""
+ >
+ <p
+ class="js-no-container-images-text"
+ >
+ With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here.
+ <a
+ href="help"
+ target="_blank"
+ >
+ More Information
+ </a>
+ </p>
+ </p>
+
+ <div
+ class="text-center"
+ >
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/registry/components/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/components/__snapshots__/project_empty_state_spec.js.snap
new file mode 100644
index 00000000000..3084462f5ae
--- /dev/null
+++ b/spec/frontend/registry/components/__snapshots__/project_empty_state_spec.js.snap
@@ -0,0 +1,186 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Registry Project Empty state to match the default snapshot 1`] = `
+<div
+ class="row container-message empty-state"
+>
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no container images stored for this project"
+ class=""
+ src="imageUrl"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content"
+ >
+ <h4
+ class="center"
+ style=""
+ >
+ There are no container images stored for this project
+ </h4>
+
+ <p
+ class="center"
+ style=""
+ >
+ <p
+ class="js-no-container-images-text"
+ >
+ With the Container Registry, every project can have its own space to store its Docker images.
+ <a
+ href="help"
+ target="_blank"
+ >
+ More Information
+ </a>
+ </p>
+
+ <h5>
+ Quick Start
+ </h5>
+
+ <p
+ class="js-not-logged-in-to-registry-text"
+ >
+ If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have
+ <a
+ href="help_link"
+ target="_blank"
+ >
+ Two-Factor Authentication
+ </a>
+ enabled, use a
+ <a
+ href="personal_token"
+ target="_blank"
+ >
+ Personal Access Token
+ </a>
+ instead of a password.
+ </p>
+
+ <div
+ class="input-group append-bottom-10"
+ >
+ <input
+ class="form-control monospace"
+ readonly="readonly"
+ type="text"
+ />
+
+ <span
+ class="input-group-append"
+ >
+ <button
+ class="btn input-group-text btn-secondary btn-default"
+ data-clipboard-text="docker login host"
+ data-original-title="Copy login command"
+ title=""
+ type="button"
+ >
+ <svg
+ aria-hidden="true"
+ class="s16 ic-duplicate"
+ >
+ <use
+ xlink:href="#duplicate"
+ />
+ </svg>
+ </button>
+ </span>
+ </div>
+
+ <p />
+
+ <p>
+
+ You can add an image to this registry with the following commands:
+
+ </p>
+
+ <div
+ class="input-group append-bottom-10"
+ >
+ <input
+ class="form-control monospace"
+ readonly="readonly"
+ type="text"
+ />
+
+ <span
+ class="input-group-append"
+ >
+ <button
+ class="btn input-group-text btn-secondary btn-default"
+ data-clipboard-text="docker build -t url ."
+ data-original-title="Copy build command"
+ title=""
+ type="button"
+ >
+ <svg
+ aria-hidden="true"
+ class="s16 ic-duplicate"
+ >
+ <use
+ xlink:href="#duplicate"
+ />
+ </svg>
+ </button>
+ </span>
+ </div>
+
+ <div
+ class="input-group"
+ >
+ <input
+ class="form-control monospace"
+ readonly="readonly"
+ type="text"
+ />
+
+ <span
+ class="input-group-append"
+ >
+ <button
+ class="btn input-group-text btn-secondary btn-default"
+ data-clipboard-text="docker push url"
+ data-original-title="Copy push command"
+ title=""
+ type="button"
+ >
+ <svg
+ aria-hidden="true"
+ class="s16 ic-duplicate"
+ >
+ <use
+ xlink:href="#duplicate"
+ />
+ </svg>
+ </button>
+ </span>
+ </div>
+ </p>
+
+ <div
+ class="text-center"
+ >
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/registry/components/app_spec.js b/spec/frontend/registry/components/app_spec.js
new file mode 100644
index 00000000000..a69c33c246d
--- /dev/null
+++ b/spec/frontend/registry/components/app_spec.js
@@ -0,0 +1,160 @@
+import Vue from 'vue';
+import { mount } from '@vue/test-utils';
+import registry from '~/registry/components/app.vue';
+import { TEST_HOST } from '../../helpers/test_constants';
+import { reposServerResponse, parsedReposServerResponse } from '../mock_data';
+
+describe('Registry List', () => {
+ let wrapper;
+
+ const findCollapsibleContainer = w => w.findAll({ name: 'CollapsibeContainerRegisty' });
+ const findProjectEmptyState = w => w.find({ name: 'ProjectEmptyState' });
+ const findGroupEmptyState = w => w.find({ name: 'GroupEmptyState' });
+ const findSpinner = w => w.find('.gl-spinner');
+ const findCharacterErrorText = w => w.find('.js-character-error-text');
+
+ const propsData = {
+ endpoint: `${TEST_HOST}/foo`,
+ helpPagePath: 'foo',
+ noContainersImage: 'foo',
+ containersErrorImage: 'foo',
+ repositoryUrl: 'foo',
+ registryHostUrlWithPort: 'foo',
+ personalAccessTokensHelpLink: 'foo',
+ twoFactorAuthHelpLink: 'foo',
+ };
+
+ const setMainEndpoint = jest.fn();
+ const fetchRepos = jest.fn();
+ const setIsDeleteDisabled = jest.fn();
+
+ const methods = {
+ setMainEndpoint,
+ fetchRepos,
+ setIsDeleteDisabled,
+ };
+
+ beforeEach(() => {
+ // This is needed due to console.error called by vue to emit a warning that stop the tests.
+ // See https://github.com/vuejs/vue-test-utils/issues/532.
+ Vue.config.silent = true;
+ wrapper = mount(registry, {
+ propsData,
+ computed: {
+ repos() {
+ return parsedReposServerResponse;
+ },
+ },
+ methods,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ Vue.config.silent = false;
+ wrapper.destroy();
+ });
+
+ describe('with data', () => {
+ it('should render a list of CollapsibeContainerRegisty', () => {
+ const containers = findCollapsibleContainer(wrapper);
+ expect(wrapper.vm.repos.length).toEqual(reposServerResponse.length);
+ expect(containers.length).toEqual(reposServerResponse.length);
+ });
+ });
+
+ describe('without data', () => {
+ let localWrapper;
+ beforeEach(() => {
+ localWrapper = mount(registry, {
+ propsData,
+ computed: {
+ repos() {
+ return [];
+ },
+ },
+ methods,
+ });
+ });
+
+ it('should render project empty message', () => {
+ const projectEmptyState = findProjectEmptyState(localWrapper);
+ expect(projectEmptyState.exists()).toBe(true);
+ });
+ });
+
+ describe('while loading data', () => {
+ let localWrapper;
+
+ beforeEach(() => {
+ localWrapper = mount(registry, {
+ propsData,
+ computed: {
+ repos() {
+ return [];
+ },
+ isLoading() {
+ return true;
+ },
+ },
+ methods,
+ });
+ });
+
+ it('should render a loading spinner', () => {
+ const spinner = findSpinner(localWrapper);
+ expect(spinner.exists()).toBe(true);
+ });
+ });
+
+ describe('invalid characters in path', () => {
+ let localWrapper;
+
+ beforeEach(() => {
+ localWrapper = mount(registry, {
+ propsData: {
+ ...propsData,
+ characterError: true,
+ },
+ computed: {
+ repos() {
+ return [];
+ },
+ },
+ methods,
+ });
+ });
+
+ it('should render invalid characters error message', () => {
+ const characterErrorText = findCharacterErrorText(localWrapper);
+ expect(characterErrorText.text()).toEqual(
+ 'We are having trouble connecting to Docker, which could be due to an issue with your project name or path. More Information',
+ );
+ });
+ });
+
+ describe('with groupId set', () => {
+ const isGroupPage = true;
+
+ beforeEach(() => {
+ wrapper = mount(registry, {
+ propsData: {
+ ...propsData,
+ endpoint: null,
+ isGroupPage,
+ },
+ methods,
+ });
+ });
+
+ it('call the right vuex setters', () => {
+ expect(methods.setMainEndpoint).toHaveBeenLastCalledWith(null);
+ expect(methods.setIsDeleteDisabled).toHaveBeenLastCalledWith(true);
+ });
+
+ it('should render groups empty message', () => {
+ const groupEmptyState = findGroupEmptyState(wrapper);
+ expect(groupEmptyState.exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/registry/components/collapsible_container_spec.js b/spec/frontend/registry/components/collapsible_container_spec.js
new file mode 100644
index 00000000000..f93ebab1a4d
--- /dev/null
+++ b/spec/frontend/registry/components/collapsible_container_spec.js
@@ -0,0 +1,127 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
+import collapsibleComponent from '~/registry/components/collapsible_container.vue';
+import { repoPropsData } from '../mock_data';
+import createFlash from '~/flash';
+import * as getters from '~/registry/stores/getters';
+
+jest.mock('~/flash.js');
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+
+describe('collapsible registry container', () => {
+ let wrapper;
+ let store;
+
+ const findDeleteBtn = w => w.find('.js-remove-repo');
+ const findContainerImageTags = w => w.find('.container-image-tags');
+ const findToggleRepos = w => w.findAll('.js-toggle-repo');
+
+ const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue });
+
+ beforeEach(() => {
+ createFlash.mockClear();
+ // This is needed due to console.error called by vue to emit a warning that stop the tests
+ // see https://github.com/vuejs/vue-test-utils/issues/532
+ Vue.config.silent = true;
+ store = new Vuex.Store({
+ state: {
+ isDeleteDisabled: false,
+ },
+ getters,
+ });
+
+ wrapper = mountWithStore({
+ propsData: {
+ repo: repoPropsData,
+ },
+ });
+ });
+
+ afterEach(() => {
+ Vue.config.silent = false;
+ wrapper.destroy();
+ });
+
+ describe('toggle', () => {
+ beforeEach(() => {
+ const fetchList = jest.fn();
+ wrapper.setMethods({ fetchList });
+ });
+
+ const expectIsClosed = () => {
+ const container = findContainerImageTags(wrapper);
+ expect(container.exists()).toBe(false);
+ expect(wrapper.vm.iconName).toEqual('angle-right');
+ };
+
+ it('should be closed by default', () => {
+ expectIsClosed();
+ });
+ it('should be open when user clicks on closed repo', () => {
+ const toggleRepos = findToggleRepos(wrapper);
+ toggleRepos.at(0).trigger('click');
+ const container = findContainerImageTags(wrapper);
+ expect(container.exists()).toBe(true);
+ expect(wrapper.vm.fetchList).toHaveBeenCalled();
+ });
+ it('should be closed when the user clicks on an opened repo', done => {
+ const toggleRepos = findToggleRepos(wrapper);
+ toggleRepos.at(0).trigger('click');
+ Vue.nextTick(() => {
+ toggleRepos.at(0).trigger('click');
+ Vue.nextTick(() => {
+ expectIsClosed();
+ done();
+ });
+ });
+ });
+ });
+
+ describe('delete repo', () => {
+ it('should be possible to delete a repo', () => {
+ const deleteBtn = findDeleteBtn(wrapper);
+ expect(deleteBtn.exists()).toBe(true);
+ });
+
+ it('should call deleteItem when confirming deletion', () => {
+ const deleteItem = jest.fn().mockResolvedValue();
+ const fetchRepos = jest.fn().mockResolvedValue();
+ wrapper.setMethods({ deleteItem, fetchRepos });
+ wrapper.vm.handleDeleteRepository();
+ expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(wrapper.vm.repo);
+ });
+
+ it('should show an error when there is API error', () => {
+ const deleteItem = jest.fn().mockRejectedValue('error');
+ wrapper.setMethods({ deleteItem });
+ return wrapper.vm.handleDeleteRepository().then(() => {
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('disabled delete', () => {
+ beforeEach(() => {
+ store = new Vuex.Store({
+ state: {
+ isDeleteDisabled: true,
+ },
+ getters,
+ });
+ wrapper = mountWithStore({
+ propsData: {
+ repo: repoPropsData,
+ },
+ });
+ });
+
+ it('should not render delete button', () => {
+ const deleteBtn = findDeleteBtn(wrapper);
+ expect(deleteBtn.exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/registry/components/group_empty_state_spec.js b/spec/frontend/registry/components/group_empty_state_spec.js
new file mode 100644
index 00000000000..f71074b5154
--- /dev/null
+++ b/spec/frontend/registry/components/group_empty_state_spec.js
@@ -0,0 +1,23 @@
+import { mount } from '@vue/test-utils';
+import groupEmptyState from '~/registry/components/group_empty_state.vue';
+
+describe('Registry Group Empty state', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(groupEmptyState, {
+ propsData: {
+ noContainersImage: 'imageUrl',
+ helpPagePath: 'help',
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('to match the default snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/registry/components/project_empty_state_spec.js b/spec/frontend/registry/components/project_empty_state_spec.js
new file mode 100644
index 00000000000..913524db3aa
--- /dev/null
+++ b/spec/frontend/registry/components/project_empty_state_spec.js
@@ -0,0 +1,27 @@
+import { mount } from '@vue/test-utils';
+import projectEmptyState from '~/registry/components/project_empty_state.vue';
+
+describe('Registry Project Empty state', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(projectEmptyState, {
+ propsData: {
+ noContainersImage: 'imageUrl',
+ helpPagePath: 'help',
+ repositoryUrl: 'url',
+ twoFactorAuthHelpLink: 'help_link',
+ personalAccessTokensHelpLink: 'personal_token',
+ registryHostUrlWithPort: 'host',
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('to match the default snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/registry/components/table_registry_spec.js b/spec/frontend/registry/components/table_registry_spec.js
new file mode 100644
index 00000000000..7cb7c012d9d
--- /dev/null
+++ b/spec/frontend/registry/components/table_registry_spec.js
@@ -0,0 +1,268 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import tableRegistry from '~/registry/components/table_registry.vue';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { repoPropsData } from '../mock_data';
+import * as getters from '~/registry/stores/getters';
+
+const [firstImage, secondImage] = repoPropsData.list;
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+
+describe('table registry', () => {
+ let wrapper;
+ let store;
+
+ const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input');
+ const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input');
+ const findDeleteButton = w => w.find('.js-delete-registry');
+ const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row');
+ const findPagination = w => w.find('.js-registry-pagination');
+ const bulkDeletePath = 'path';
+
+ const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue });
+
+ beforeEach(() => {
+ // This is needed due to console.error called by vue to emit a warning that stop the tests
+ // see https://github.com/vuejs/vue-test-utils/issues/532
+ Vue.config.silent = true;
+
+ store = new Vuex.Store({
+ state: {
+ isDeleteDisabled: false,
+ },
+ getters,
+ });
+
+ wrapper = mountWithStore({
+ propsData: {
+ repo: repoPropsData,
+ canDeleteRepo: true,
+ },
+ });
+ });
+
+ afterEach(() => {
+ Vue.config.silent = false;
+ wrapper.destroy();
+ });
+
+ describe('rendering', () => {
+ it('should render a table with the registry list', () => {
+ expect(wrapper.findAll('.registry-image-row').length).toEqual(repoPropsData.list.length);
+ });
+
+ it('should render registry tag', () => {
+ const tds = wrapper.findAll('.registry-image-row td');
+ expect(tds.at(0).classes()).toContain('check');
+ expect(tds.at(1).html()).toContain(repoPropsData.list[0].tag);
+ expect(tds.at(2).html()).toContain(repoPropsData.list[0].shortRevision);
+ expect(tds.at(3).html()).toContain(repoPropsData.list[0].layers);
+ expect(tds.at(3).html()).toContain(repoPropsData.list[0].size);
+ expect(tds.at(4).html()).toContain(wrapper.vm.timeFormated(repoPropsData.list[0].createdAt));
+ });
+ });
+
+ describe('multi select', () => {
+ it('selecting a row should enable delete button', done => {
+ const deleteBtn = findDeleteButton(wrapper);
+ const checkboxes = findSelectCheckboxes(wrapper);
+
+ expect(deleteBtn.attributes('disabled')).toBe('disabled');
+
+ checkboxes.at(0).trigger('click');
+ Vue.nextTick(() => {
+ expect(deleteBtn.attributes('disabled')).toEqual(undefined);
+ done();
+ });
+ });
+
+ it('selecting all checkbox should select all rows and enable delete button', done => {
+ const selectAll = findSelectAllCheckbox(wrapper);
+ const checkboxes = findSelectCheckboxes(wrapper);
+ selectAll.trigger('click');
+
+ Vue.nextTick(() => {
+ const checked = checkboxes.filter(w => w.element.checked);
+ expect(checked.length).toBe(checkboxes.length);
+ done();
+ });
+ });
+
+ it('deselecting select all checkbox should deselect all rows and disable delete button', done => {
+ const checkboxes = findSelectCheckboxes(wrapper);
+ const selectAll = findSelectAllCheckbox(wrapper);
+ selectAll.trigger('click');
+ selectAll.trigger('click');
+
+ Vue.nextTick(() => {
+ const checked = checkboxes.filter(w => !w.element.checked);
+ expect(checked.length).toBe(checkboxes.length);
+ done();
+ });
+ });
+
+ it('should delete multiple items when multiple items are selected', done => {
+ const multiDeleteItems = jest.fn().mockResolvedValue();
+ wrapper.setMethods({ multiDeleteItems });
+ const selectAll = findSelectAllCheckbox(wrapper);
+ selectAll.trigger('click');
+
+ Vue.nextTick(() => {
+ const deleteBtn = findDeleteButton(wrapper);
+ expect(wrapper.vm.selectedItems).toEqual([0, 1]);
+ expect(deleteBtn.attributes('disabled')).toEqual(undefined);
+ wrapper.setData({ itemsToBeDeleted: [...wrapper.vm.selectedItems] });
+ wrapper.vm.handleMultipleDelete();
+
+ Vue.nextTick(() => {
+ expect(wrapper.vm.selectedItems).toEqual([]);
+ expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
+ expect(wrapper.vm.multiDeleteItems).toHaveBeenCalledWith({
+ path: bulkDeletePath,
+ items: [firstImage.tag, secondImage.tag],
+ });
+ done();
+ });
+ });
+ });
+
+ it('should show an error message if bulkDeletePath is not set', () => {
+ const showError = jest.fn();
+ wrapper.setMethods({ showError });
+ wrapper.setProps({
+ repo: {
+ ...repoPropsData,
+ tagsPath: null,
+ },
+ });
+ wrapper.vm.handleMultipleDelete();
+ expect(wrapper.vm.showError).toHaveBeenCalled();
+ });
+ });
+
+ describe('delete registry', () => {
+ beforeEach(() => {
+ wrapper.setData({ selectedItems: [0] });
+ });
+
+ it('should be possible to delete a registry', () => {
+ const deleteBtn = findDeleteButton(wrapper);
+ const deleteBtns = findDeleteButtonsRow(wrapper);
+ expect(wrapper.vm.selectedItems).toEqual([0]);
+ expect(deleteBtn).toBeDefined();
+ expect(deleteBtn.attributes('disable')).toBe(undefined);
+ expect(deleteBtns.is('button')).toBe(true);
+ });
+
+ it('should allow deletion row by row', () => {
+ const deleteBtns = findDeleteButtonsRow(wrapper);
+ const deleteSingleItem = jest.fn();
+ const deleteItem = jest.fn().mockResolvedValue();
+ wrapper.setMethods({ deleteSingleItem, deleteItem });
+ deleteBtns.at(0).trigger('click');
+ expect(wrapper.vm.deleteSingleItem).toHaveBeenCalledWith(0);
+ wrapper.vm.handleSingleDelete(1);
+ expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('pagination', () => {
+ const repo = {
+ repoPropsData,
+ pagination: {
+ total: 20,
+ perPage: 2,
+ nextPage: 2,
+ },
+ };
+
+ beforeEach(() => {
+ wrapper = mount(tableRegistry, {
+ propsData: {
+ repo,
+ },
+ });
+ });
+
+ it('should exist', () => {
+ const pagination = findPagination(wrapper);
+ expect(pagination.exists()).toBe(true);
+ });
+ it('should be visible when pagination is needed', () => {
+ const pagination = findPagination(wrapper);
+ expect(pagination.isVisible()).toBe(true);
+ wrapper.setProps({
+ repo: {
+ pagination: {
+ total: 0,
+ perPage: 10,
+ },
+ },
+ });
+ expect(wrapper.vm.shouldRenderPagination).toBe(false);
+ });
+ it('should have a change function that update the list when run', () => {
+ const fetchList = jest.fn().mockResolvedValue();
+ wrapper.setMethods({ fetchList });
+ wrapper.vm.onPageChange(1);
+ expect(wrapper.vm.fetchList).toHaveBeenCalledWith({ repo, page: 1 });
+ });
+ });
+
+ describe('modal content', () => {
+ it('should show the singular title and image name when deleting a single image', () => {
+ wrapper.setData({ selectedItems: [1, 2, 3] });
+ wrapper.vm.deleteSingleItem(0);
+ expect(wrapper.vm.modalAction).toBe('Remove tag');
+ expect(wrapper.vm.modalDescription).toContain(firstImage.tag);
+ });
+
+ it('should show the plural title and image count when deleting more than one image', () => {
+ wrapper.setData({ selectedItems: [1, 2] });
+ wrapper.vm.deleteMultipleItems();
+
+ expect(wrapper.vm.modalAction).toBe('Remove tags');
+ expect(wrapper.vm.modalDescription).toContain('<b>2</b> tags');
+ });
+ });
+
+ describe('disabled delete', () => {
+ beforeEach(() => {
+ store = new Vuex.Store({
+ state: {
+ isDeleteDisabled: true,
+ },
+ getters,
+ });
+ wrapper = mountWithStore({
+ propsData: {
+ repo: repoPropsData,
+ canDeleteRepo: false,
+ },
+ });
+ });
+
+ it('should not render select all', () => {
+ const selectAll = findSelectAllCheckbox(wrapper);
+ expect(selectAll.exists()).toBe(false);
+ });
+
+ it('should not render any select checkbox', () => {
+ const selects = findSelectCheckboxes(wrapper);
+ expect(selects.length).toBe(0);
+ });
+
+ it('should not render delete registry button', () => {
+ const deleteBtn = findDeleteButton(wrapper);
+ expect(deleteBtn.exists()).toBe(false);
+ });
+
+ it('should not render delete row button', () => {
+ const deleteBtns = findDeleteButtonsRow(wrapper);
+ expect(deleteBtns.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/registry/mock_data.js b/spec/frontend/registry/mock_data.js
new file mode 100644
index 00000000000..130ab298e89
--- /dev/null
+++ b/spec/frontend/registry/mock_data.js
@@ -0,0 +1,134 @@
+export const defaultState = {
+ isLoading: false,
+ endpoint: '',
+ repos: [],
+};
+
+export const reposServerResponse = [
+ {
+ destroy_path: 'path',
+ id: '123',
+ location: 'location',
+ path: 'foo',
+ tags_path: 'tags_path',
+ },
+ {
+ destroy_path: 'path_',
+ id: '456',
+ location: 'location_',
+ path: 'bar',
+ tags_path: 'tags_path_',
+ },
+];
+
+export const registryServerResponse = [
+ {
+ name: 'centos7',
+ short_revision: 'b118ab5b0',
+ revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
+ total_size: 679,
+ layers: 19,
+ location: 'location',
+ created_at: 1505828744434,
+ destroy_path: 'path_',
+ },
+ {
+ name: 'centos6',
+ short_revision: 'b118ab5b0',
+ revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
+ total_size: 679,
+ layers: 19,
+ location: 'location',
+ created_at: 1505828744434,
+ },
+];
+
+export const parsedReposServerResponse = [
+ {
+ canDelete: true,
+ destroyPath: reposServerResponse[0].destroy_path,
+ id: reposServerResponse[0].id,
+ isLoading: false,
+ list: [],
+ location: reposServerResponse[0].location,
+ name: reposServerResponse[0].path,
+ tagsPath: reposServerResponse[0].tags_path,
+ },
+ {
+ canDelete: true,
+ destroyPath: reposServerResponse[1].destroy_path,
+ id: reposServerResponse[1].id,
+ isLoading: false,
+ list: [],
+ location: reposServerResponse[1].location,
+ name: reposServerResponse[1].path,
+ tagsPath: reposServerResponse[1].tags_path,
+ },
+];
+
+export const parsedRegistryServerResponse = [
+ {
+ tag: registryServerResponse[0].name,
+ revision: registryServerResponse[0].revision,
+ shortRevision: registryServerResponse[0].short_revision,
+ size: registryServerResponse[0].total_size,
+ layers: registryServerResponse[0].layers,
+ location: registryServerResponse[0].location,
+ createdAt: registryServerResponse[0].created_at,
+ destroyPath: registryServerResponse[0].destroy_path,
+ canDelete: true,
+ },
+ {
+ tag: registryServerResponse[1].name,
+ revision: registryServerResponse[1].revision,
+ shortRevision: registryServerResponse[1].short_revision,
+ size: registryServerResponse[1].total_size,
+ layers: registryServerResponse[1].layers,
+ location: registryServerResponse[1].location,
+ createdAt: registryServerResponse[1].created_at,
+ destroyPath: registryServerResponse[1].destroy_path,
+ canDelete: false,
+ },
+];
+
+export const repoPropsData = {
+ canDelete: true,
+ destroyPath: 'path',
+ id: '123',
+ isLoading: false,
+ list: [
+ {
+ tag: 'centos6',
+ revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
+ shortRevision: 'b118ab5b0',
+ size: 19,
+ layers: 10,
+ location: 'location',
+ createdAt: 1505828744434,
+ destroyPath: 'path',
+ canDelete: true,
+ },
+ {
+ tag: 'test-image',
+ revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4',
+ shortRevision: 'b969de599',
+ size: 19,
+ layers: 10,
+ location: 'location-2',
+ createdAt: 1505828744434,
+ destroyPath: 'path-2',
+ canDelete: true,
+ },
+ ],
+ location: 'location',
+ name: 'foo',
+ tagsPath: 'path',
+ pagination: {
+ perPage: 5,
+ page: 1,
+ total: 13,
+ totalPages: 1,
+ nextPage: null,
+ previousPage: null,
+ },
+};
diff --git a/spec/frontend/registry/stores/actions_spec.js b/spec/frontend/registry/stores/actions_spec.js
new file mode 100644
index 00000000000..7937fa82e80
--- /dev/null
+++ b/spec/frontend/registry/stores/actions_spec.js
@@ -0,0 +1,203 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import * as actions from '~/registry/stores/actions';
+import * as types from '~/registry/stores/mutation_types';
+import { TEST_HOST } from '../../helpers/test_constants';
+import testAction from '../../helpers/vuex_action_helper';
+import createFlash from '~/flash';
+
+import {
+ reposServerResponse,
+ registryServerResponse,
+ parsedReposServerResponse,
+} from '../mock_data';
+
+jest.mock('~/flash.js');
+
+describe('Actions Registry Store', () => {
+ let mock;
+ let state;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state = {
+ endpoint: `${TEST_HOST}/endpoint.json`,
+ };
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('fetchRepos', () => {
+ beforeEach(() => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
+ });
+
+ it('should set received repos', done => {
+ testAction(
+ actions.fetchRepos,
+ null,
+ state,
+ [
+ { type: types.TOGGLE_MAIN_LOADING },
+ { type: types.TOGGLE_MAIN_LOADING },
+ { type: types.SET_REPOS_LIST, payload: reposServerResponse },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('should create flash on API error', done => {
+ testAction(
+ actions.fetchRepos,
+ null,
+ {
+ endpoint: null,
+ },
+ [{ type: types.TOGGLE_MAIN_LOADING }, { type: types.TOGGLE_MAIN_LOADING }],
+ [],
+ () => {
+ expect(createFlash).toHaveBeenCalled();
+ done();
+ },
+ );
+ });
+ });
+
+ describe('fetchList', () => {
+ let repo;
+ beforeEach(() => {
+ state.repos = parsedReposServerResponse;
+ [, repo] = state.repos;
+ });
+
+ it('should set received list', done => {
+ mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
+ testAction(
+ actions.fetchList,
+ { repo },
+ state,
+ [
+ { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
+ { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
+ {
+ type: types.SET_REGISTRY_LIST,
+ payload: {
+ repo,
+ resp: registryServerResponse,
+ headers: expect.anything(),
+ },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('should create flash on API error', done => {
+ mock.onGet(repo.tagsPath).replyOnce(400);
+ const updatedRepo = {
+ ...repo,
+ tagsPath: null,
+ };
+ testAction(
+ actions.fetchList,
+ {
+ repo: updatedRepo,
+ },
+ state,
+ [
+ { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo },
+ { type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo },
+ ],
+ [],
+ () => {
+ expect(createFlash).toHaveBeenCalled();
+ done();
+ },
+ );
+ });
+ });
+
+ describe('setMainEndpoint', () => {
+ it('should commit set main endpoint', done => {
+ testAction(
+ actions.setMainEndpoint,
+ 'endpoint',
+ state,
+ [{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setIsDeleteDisabled', () => {
+ it('should commit set is delete disabled', done => {
+ testAction(
+ actions.setIsDeleteDisabled,
+ true,
+ state,
+ [{ type: types.SET_IS_DELETE_DISABLED, payload: true }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleLoading', () => {
+ it('should commit toggle main loading', done => {
+ testAction(
+ actions.toggleLoading,
+ null,
+ state,
+ [{ type: types.TOGGLE_MAIN_LOADING }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('deleteItem and multiDeleteItems', () => {
+ let deleted;
+ const destroyPath = `${TEST_HOST}/mygroup/myproject/container_registry/1.json`;
+
+ const expectDelete = done => {
+ expect(mock.history.delete.length).toBe(1);
+ expect(deleted).toBe(true);
+ done();
+ };
+
+ beforeEach(() => {
+ deleted = false;
+ mock.onDelete(destroyPath).replyOnce(() => {
+ deleted = true;
+ return [200];
+ });
+ });
+
+ it('deleteItem should perform DELETE request on destroyPath', done => {
+ testAction(
+ actions.deleteItem,
+ {
+ destroyPath,
+ },
+ state,
+ )
+ .then(() => {
+ expectDelete(done);
+ })
+ .catch(done.fail);
+ });
+
+ it('multiDeleteItems should perform DELETE request on path', done => {
+ testAction(actions.multiDeleteItems, { path: destroyPath, items: [1] }, state)
+ .then(() => {
+ expectDelete(done);
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/frontend/registry/getters_spec.js b/spec/frontend/registry/stores/getters_spec.js
index 839aa718997..c16f520223b 100644
--- a/spec/frontend/registry/getters_spec.js
+++ b/spec/frontend/registry/stores/getters_spec.js
@@ -7,6 +7,7 @@ describe('Getters Registry Store', () => {
state = {
isLoading: false,
endpoint: '/root/empty-project/container_registry.json',
+ isDeleteDisabled: false,
repos: [
{
canDelete: true,
@@ -43,4 +44,9 @@ describe('Getters Registry Store', () => {
expect(getters.repos(state)).toEqual(state.repos);
});
});
+ describe('isDeleteDisabled', () => {
+ it('should return isDeleteDisabled', () => {
+ expect(getters.isDeleteDisabled(state)).toEqual(state.isDeleteDisabled);
+ });
+ });
});
diff --git a/spec/frontend/registry/stores/mutations_spec.js b/spec/frontend/registry/stores/mutations_spec.js
new file mode 100644
index 00000000000..1d583028ca6
--- /dev/null
+++ b/spec/frontend/registry/stores/mutations_spec.js
@@ -0,0 +1,94 @@
+import mutations from '~/registry/stores/mutations';
+import * as types from '~/registry/stores/mutation_types';
+import {
+ defaultState,
+ reposServerResponse,
+ registryServerResponse,
+ parsedReposServerResponse,
+ parsedRegistryServerResponse,
+} from '../mock_data';
+
+describe('Mutations Registry Store', () => {
+ let mockState;
+ beforeEach(() => {
+ mockState = defaultState;
+ });
+
+ describe('SET_MAIN_ENDPOINT', () => {
+ it('should set the main endpoint', () => {
+ const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
+ mutations[types.SET_MAIN_ENDPOINT](mockState, 'foo');
+
+ expect(mockState.endpoint).toEqual(expectedState.endpoint);
+ });
+ });
+
+ describe('SET_IS_DELETE_DISABLED', () => {
+ it('should set the is delete disabled', () => {
+ const expectedState = Object.assign({}, mockState, { isDeleteDisabled: true });
+ mutations[types.SET_IS_DELETE_DISABLED](mockState, true);
+
+ expect(mockState.isDeleteDisabled).toEqual(expectedState.isDeleteDisabled);
+ });
+ });
+
+ describe('SET_REPOS_LIST', () => {
+ it('should set a parsed repository list', () => {
+ mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
+
+ expect(mockState.repos).toEqual(parsedReposServerResponse);
+ });
+ });
+
+ describe('TOGGLE_MAIN_LOADING', () => {
+ it('should set a parsed repository list', () => {
+ mutations[types.TOGGLE_MAIN_LOADING](mockState);
+
+ expect(mockState.isLoading).toEqual(true);
+ });
+ });
+
+ describe('SET_REGISTRY_LIST', () => {
+ it('should set a list of registries in a specific repository', () => {
+ mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
+ mutations[types.SET_REGISTRY_LIST](mockState, {
+ repo: mockState.repos[0],
+ resp: registryServerResponse,
+ headers: {
+ 'x-per-page': 2,
+ 'x-page': 1,
+ 'x-total': 10,
+ },
+ });
+
+ expect(mockState.repos[0].list).toEqual(parsedRegistryServerResponse);
+ expect(mockState.repos[0].pagination).toEqual({
+ perPage: 2,
+ page: 1,
+ total: 10,
+ totalPages: NaN,
+ nextPage: NaN,
+ previousPage: NaN,
+ });
+ });
+ });
+
+ describe('TOGGLE_REGISTRY_LIST_LOADING', () => {
+ it('should toggle isLoading property for a specific repository', () => {
+ mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
+ mutations[types.SET_REGISTRY_LIST](mockState, {
+ repo: mockState.repos[0],
+ resp: registryServerResponse,
+ headers: {
+ 'x-per-page': 2,
+ 'x-page': 1,
+ 'x-total': 10,
+ },
+ });
+
+ mutations[types.TOGGLE_REGISTRY_LIST_LOADING](mockState, mockState.repos[0]);
+
+ expect(mockState.repos[0].isLoading).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/milestone_list_spec.js b/spec/frontend/releases/components/milestone_list_spec.js
deleted file mode 100644
index f267177ddab..00000000000
--- a/spec/frontend/releases/components/milestone_list_spec.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlLink } from '@gitlab/ui';
-import MilestoneList from '~/releases/components/milestone_list.vue';
-import Icon from '~/vue_shared/components/icon.vue';
-import _ from 'underscore';
-import { milestones } from '../mock_data';
-
-describe('Milestone list', () => {
- let wrapper;
-
- const factory = milestonesProp => {
- wrapper = shallowMount(MilestoneList, {
- propsData: {
- milestones: milestonesProp,
- },
- sync: false,
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders the milestone icon', () => {
- factory(milestones);
-
- expect(wrapper.find(Icon).exists()).toBe(true);
- });
-
- it('renders the label as "Milestone" if only a single milestone is passed in', () => {
- factory(milestones.slice(0, 1));
-
- expect(wrapper.find('.js-label-text').text()).toEqual('Milestone');
- });
-
- it('renders the label as "Milestones" if more than one milestone is passed in', () => {
- factory(milestones);
-
- expect(wrapper.find('.js-label-text').text()).toEqual('Milestones');
- });
-
- it('renders a link to the milestone with a tooltip', () => {
- const milestone = _.first(milestones);
- factory([milestone]);
-
- const milestoneLink = wrapper.find(GlLink);
-
- expect(milestoneLink.exists()).toBe(true);
-
- expect(milestoneLink.text()).toBe(milestone.title);
-
- expect(milestoneLink.attributes('href')).toBe(milestone.web_url);
-
- expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description);
- });
-});
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
deleted file mode 100644
index 4be5d500fd9..00000000000
--- a/spec/frontend/releases/components/release_block_spec.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import { mount } from '@vue/test-utils';
-import ReleaseBlock from '~/releases/components/release_block.vue';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { first } from 'underscore';
-import { release } from '../mock_data';
-
-describe('Release block', () => {
- let wrapper;
-
- const factory = releaseProp => {
- wrapper = mount(ReleaseBlock, {
- propsData: {
- release: releaseProp,
- },
- sync: false,
- });
- };
-
- const milestoneListExists = () => wrapper.find('.js-milestone-list').exists();
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('with default props', () => {
- beforeEach(() => {
- factory(release);
- });
-
- it("renders the block with an id equal to the release's tag name", () => {
- expect(wrapper.attributes().id).toBe('v0.3');
- });
-
- it('renders release name', () => {
- expect(wrapper.text()).toContain(release.name);
- });
-
- it('renders commit sha', () => {
- expect(wrapper.text()).toContain(release.commit.short_id);
- });
-
- it('renders tag name', () => {
- expect(wrapper.text()).toContain(release.tag_name);
- });
-
- it('renders release date', () => {
- expect(wrapper.text()).toContain(timeagoMixin.methods.timeFormated(release.released_at));
- });
-
- it('renders number of assets provided', () => {
- expect(wrapper.find('.js-assets-count').text()).toContain(release.assets.count);
- });
-
- it('renders dropdown with the sources', () => {
- expect(wrapper.findAll('.js-sources-dropdown li').length).toEqual(
- release.assets.sources.length,
- );
-
- expect(wrapper.find('.js-sources-dropdown li a').attributes().href).toEqual(
- first(release.assets.sources).url,
- );
-
- expect(wrapper.find('.js-sources-dropdown li a').text()).toContain(
- first(release.assets.sources).format,
- );
- });
-
- it('renders list with the links provided', () => {
- expect(wrapper.findAll('.js-assets-list li').length).toEqual(release.assets.links.length);
-
- expect(wrapper.find('.js-assets-list li a').attributes().href).toEqual(
- first(release.assets.links).url,
- );
-
- expect(wrapper.find('.js-assets-list li a').text()).toContain(
- first(release.assets.links).name,
- );
- });
-
- it('renders author avatar', () => {
- expect(wrapper.find('.user-avatar-link').exists()).toBe(true);
- });
-
- describe('external label', () => {
- it('renders external label when link is external', () => {
- expect(wrapper.find('.js-assets-list li a').text()).toContain('external source');
- });
-
- it('does not render external label when link is not external', () => {
- expect(wrapper.find('.js-assets-list li:nth-child(2) a').text()).not.toContain(
- 'external source',
- );
- });
- });
-
- it('renders the milestone list if at least one milestone is associated to the release', () => {
- factory(release);
-
- expect(milestoneListExists()).toBe(true);
- });
- });
-
- it('does not render the milestone list if no milestones are associated to the release', () => {
- const releaseClone = JSON.parse(JSON.stringify(release));
- delete releaseClone.milestone;
-
- factory(releaseClone);
-
- expect(milestoneListExists()).toBe(false);
- });
-
- it('renders upcoming release badge', () => {
- const releaseClone = JSON.parse(JSON.stringify(release));
- releaseClone.upcoming_release = true;
-
- factory(releaseClone);
-
- expect(wrapper.text()).toContain('Upcoming Release');
- });
-});
diff --git a/spec/frontend/releases/detail/components/app_spec.js b/spec/frontend/releases/detail/components/app_spec.js
new file mode 100644
index 00000000000..f8eb33a69a8
--- /dev/null
+++ b/spec/frontend/releases/detail/components/app_spec.js
@@ -0,0 +1,70 @@
+import Vuex from 'vuex';
+import { mount } from '@vue/test-utils';
+import ReleaseDetailApp from '~/releases/detail/components/app';
+import { release } from '../../mock_data';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+describe('Release detail component', () => {
+ let wrapper;
+ let releaseClone;
+ let actions;
+
+ beforeEach(() => {
+ gon.api_version = 'v4';
+
+ releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release)));
+
+ const state = {
+ release: releaseClone,
+ markdownDocsPath: 'path/to/markdown/docs',
+ };
+
+ actions = {
+ fetchRelease: jest.fn(),
+ updateRelease: jest.fn(),
+ navigateToReleasesPage: jest.fn(),
+ };
+
+ const store = new Vuex.Store({ actions, state });
+
+ wrapper = mount(ReleaseDetailApp, { store });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('calls fetchRelease when the component is created', () => {
+ expect(actions.fetchRelease).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders the description text at the top of the page', () => {
+ expect(wrapper.find('.js-subtitle-text').text()).toBe(
+ 'Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example v1.0, v2.0-pre.',
+ );
+ });
+
+ it('renders the correct tag name in the "Tag name" field', () => {
+ expect(wrapper.find('#git-ref').element.value).toBe(releaseClone.tagName);
+ });
+
+ it('renders the correct release title in the "Release title" field', () => {
+ expect(wrapper.find('#release-title').element.value).toBe(releaseClone.name);
+ });
+
+ it('renders the release notes in the "Release notes" textarea', () => {
+ expect(wrapper.find('#release-notes').element.value).toBe(releaseClone.description);
+ });
+
+ it('renders the "Save changes" button as type="submit"', () => {
+ expect(wrapper.find('.js-submit-button').attributes('type')).toBe('submit');
+ });
+
+ it('calls updateRelease when the form is submitted', () => {
+ wrapper.find('form').trigger('submit');
+ expect(actions.updateRelease).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls navigateToReleasesPage when the "Cancel" button is clicked', () => {
+ wrapper.find('.js-cancel-button').vm.$emit('click');
+ expect(actions.navigateToReleasesPage).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/frontend/releases/detail/store/actions_spec.js b/spec/frontend/releases/detail/store/actions_spec.js
new file mode 100644
index 00000000000..f1c7f3c1048
--- /dev/null
+++ b/spec/frontend/releases/detail/store/actions_spec.js
@@ -0,0 +1,217 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import * as actions from '~/releases/detail/store/actions';
+import testAction from 'helpers/vuex_action_helper';
+import * as types from '~/releases/detail/store/mutation_types';
+import { release } from '../../mock_data';
+import state from '~/releases/detail/store/state';
+import createFlash from '~/flash';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+jest.mock('~/flash', () => jest.fn());
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ redirectTo: jest.fn(),
+ joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
+}));
+
+describe('Release detail actions', () => {
+ let stateClone;
+ let releaseClone;
+ let mock;
+ let error;
+
+ beforeEach(() => {
+ stateClone = state();
+ releaseClone = JSON.parse(JSON.stringify(release));
+ mock = new MockAdapter(axios);
+ gon.api_version = 'v4';
+ error = { message: 'An error occurred' };
+ createFlash.mockClear();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setInitialState', () => {
+ it(`commits ${types.SET_INITIAL_STATE} with the provided object`, () => {
+ const initialState = {};
+
+ return testAction(actions.setInitialState, initialState, stateClone, [
+ { type: types.SET_INITIAL_STATE, payload: initialState },
+ ]);
+ });
+ });
+
+ describe('requestRelease', () => {
+ it(`commits ${types.REQUEST_RELEASE}`, () =>
+ testAction(actions.requestRelease, undefined, stateClone, [{ type: types.REQUEST_RELEASE }]));
+ });
+
+ describe('receiveReleaseSuccess', () => {
+ it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () =>
+ testAction(actions.receiveReleaseSuccess, releaseClone, stateClone, [
+ { type: types.RECEIVE_RELEASE_SUCCESS, payload: releaseClone },
+ ]));
+ });
+
+ describe('receiveReleaseError', () => {
+ it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () =>
+ testAction(actions.receiveReleaseError, error, stateClone, [
+ { type: types.RECEIVE_RELEASE_ERROR, payload: error },
+ ]));
+
+ it('shows a flash with an error message', () => {
+ actions.receiveReleaseError({ commit: jest.fn() }, error);
+
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(
+ 'Something went wrong while getting the release details',
+ );
+ });
+ });
+
+ describe('fetchRelease', () => {
+ let getReleaseUrl;
+
+ beforeEach(() => {
+ stateClone.projectId = '18';
+ stateClone.tagName = 'v1.3';
+ getReleaseUrl = `/api/v4/projects/${stateClone.projectId}/releases/${stateClone.tagName}`;
+ });
+
+ it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => {
+ mock.onGet(getReleaseUrl).replyOnce(200, releaseClone);
+
+ return testAction(
+ actions.fetchRelease,
+ undefined,
+ stateClone,
+ [],
+ [
+ { type: 'requestRelease' },
+ {
+ type: 'receiveReleaseSuccess',
+ payload: convertObjectPropsToCamelCase(releaseClone, { deep: true }),
+ },
+ ],
+ );
+ });
+
+ it(`dispatches requestRelease and receiveReleaseError with an error object`, () => {
+ mock.onGet(getReleaseUrl).replyOnce(500);
+
+ return testAction(
+ actions.fetchRelease,
+ undefined,
+ stateClone,
+ [],
+ [{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }],
+ );
+ });
+ });
+
+ describe('updateReleaseTitle', () => {
+ it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => {
+ const newTitle = 'The new release title';
+ return testAction(actions.updateReleaseTitle, newTitle, stateClone, [
+ { type: types.UPDATE_RELEASE_TITLE, payload: newTitle },
+ ]);
+ });
+ });
+
+ describe('updateReleaseNotes', () => {
+ it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => {
+ const newReleaseNotes = 'The new release notes';
+ return testAction(actions.updateReleaseNotes, newReleaseNotes, stateClone, [
+ { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes },
+ ]);
+ });
+ });
+
+ describe('requestUpdateRelease', () => {
+ it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () =>
+ testAction(actions.requestUpdateRelease, undefined, stateClone, [
+ { type: types.REQUEST_UPDATE_RELEASE },
+ ]));
+ });
+
+ describe('receiveUpdateReleaseSuccess', () => {
+ it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () =>
+ testAction(
+ actions.receiveUpdateReleaseSuccess,
+ undefined,
+ stateClone,
+ [{ type: types.RECEIVE_UPDATE_RELEASE_SUCCESS }],
+ [{ type: 'navigateToReleasesPage' }],
+ ));
+ });
+
+ describe('receiveUpdateReleaseError', () => {
+ it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () =>
+ testAction(actions.receiveUpdateReleaseError, error, stateClone, [
+ { type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error },
+ ]));
+
+ it('shows a flash with an error message', () => {
+ actions.receiveUpdateReleaseError({ commit: jest.fn() }, error);
+
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith(
+ 'Something went wrong while saving the release details',
+ );
+ });
+ });
+
+ describe('updateRelease', () => {
+ let getReleaseUrl;
+
+ beforeEach(() => {
+ stateClone.release = releaseClone;
+ stateClone.projectId = '18';
+ stateClone.tagName = 'v1.3';
+ getReleaseUrl = `/api/v4/projects/${stateClone.projectId}/releases/${stateClone.tagName}`;
+ });
+
+ it(`dispatches requestUpdateRelease and receiveUpdateReleaseSuccess`, () => {
+ mock.onPut(getReleaseUrl).replyOnce(200);
+
+ return testAction(
+ actions.updateRelease,
+ undefined,
+ stateClone,
+ [],
+ [{ type: 'requestUpdateRelease' }, { type: 'receiveUpdateReleaseSuccess' }],
+ );
+ });
+
+ it(`dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object`, () => {
+ mock.onPut(getReleaseUrl).replyOnce(500);
+
+ return testAction(
+ actions.updateRelease,
+ undefined,
+ stateClone,
+ [],
+ [
+ { type: 'requestUpdateRelease' },
+ { type: 'receiveUpdateReleaseError', payload: expect.anything() },
+ ],
+ );
+ });
+ });
+
+ describe('navigateToReleasesPage', () => {
+ it(`calls redirectTo() with the URL to the releases page`, () => {
+ const releasesPagePath = 'path/to/releases/page';
+ stateClone.releasesPagePath = releasesPagePath;
+
+ actions.navigateToReleasesPage({ state: stateClone });
+
+ expect(redirectTo).toHaveBeenCalledTimes(1);
+ expect(redirectTo).toHaveBeenCalledWith(releasesPagePath);
+ });
+ });
+});
diff --git a/spec/frontend/releases/detail/store/mutations_spec.js b/spec/frontend/releases/detail/store/mutations_spec.js
new file mode 100644
index 00000000000..106a40c812e
--- /dev/null
+++ b/spec/frontend/releases/detail/store/mutations_spec.js
@@ -0,0 +1,119 @@
+/* eslint-disable jest/valid-describe */
+/*
+ * ESLint disable directive ↑ can be removed once
+ * https://github.com/jest-community/eslint-plugin-jest/issues/203
+ * is resolved
+ */
+
+import state from '~/releases/detail/store/state';
+import mutations from '~/releases/detail/store/mutations';
+import * as types from '~/releases/detail/store/mutation_types';
+import { release } from '../../mock_data';
+
+describe('Release detail mutations', () => {
+ let stateClone;
+ let releaseClone;
+
+ beforeEach(() => {
+ stateClone = state();
+ releaseClone = JSON.parse(JSON.stringify(release));
+ });
+
+ describe(types.SET_INITIAL_STATE, () => {
+ it('populates the state with initial values', () => {
+ const initialState = {
+ projectId: '18',
+ tagName: 'v1.3',
+ releasesPagePath: 'path/to/releases/page',
+ markdownDocsPath: 'path/to/markdown/docs',
+ markdownPreviewPath: 'path/to/markdown/preview',
+ };
+
+ mutations[types.SET_INITIAL_STATE](stateClone, initialState);
+
+ expect(stateClone).toEqual(expect.objectContaining(initialState));
+ });
+ });
+
+ describe(types.REQUEST_RELEASE, () => {
+ it('set state.isFetchingRelease to true', () => {
+ mutations[types.REQUEST_RELEASE](stateClone);
+
+ expect(stateClone.isFetchingRelease).toEqual(true);
+ });
+ });
+
+ describe(types.RECEIVE_RELEASE_SUCCESS, () => {
+ it('handles a successful response from the server', () => {
+ mutations[types.RECEIVE_RELEASE_SUCCESS](stateClone, releaseClone);
+
+ expect(stateClone.fetchError).toEqual(undefined);
+
+ expect(stateClone.isFetchingRelease).toEqual(false);
+
+ expect(stateClone.release).toEqual(releaseClone);
+ });
+ });
+
+ describe(types.RECEIVE_RELEASE_ERROR, () => {
+ it('handles an unsuccessful response from the server', () => {
+ const error = { message: 'An error occurred!' };
+ mutations[types.RECEIVE_RELEASE_ERROR](stateClone, error);
+
+ expect(stateClone.isFetchingRelease).toEqual(false);
+
+ expect(stateClone.release).toBeUndefined();
+
+ expect(stateClone.fetchError).toEqual(error);
+ });
+ });
+
+ describe(types.UPDATE_RELEASE_TITLE, () => {
+ it("updates the release's title", () => {
+ stateClone.release = releaseClone;
+ const newTitle = 'The new release title';
+ mutations[types.UPDATE_RELEASE_TITLE](stateClone, newTitle);
+
+ expect(stateClone.release.name).toEqual(newTitle);
+ });
+ });
+
+ describe(types.UPDATE_RELEASE_NOTES, () => {
+ it("updates the release's notes", () => {
+ stateClone.release = releaseClone;
+ const newNotes = 'The new release notes';
+ mutations[types.UPDATE_RELEASE_NOTES](stateClone, newNotes);
+
+ expect(stateClone.release.description).toEqual(newNotes);
+ });
+ });
+
+ describe(types.REQUEST_UPDATE_RELEASE, () => {
+ it('set state.isUpdatingRelease to true', () => {
+ mutations[types.REQUEST_UPDATE_RELEASE](stateClone);
+
+ expect(stateClone.isUpdatingRelease).toEqual(true);
+ });
+ });
+
+ describe(types.RECEIVE_UPDATE_RELEASE_SUCCESS, () => {
+ it('handles a successful response from the server', () => {
+ mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](stateClone, releaseClone);
+
+ expect(stateClone.updateError).toEqual(undefined);
+
+ expect(stateClone.isUpdatingRelease).toEqual(false);
+ });
+ });
+
+ describe(types.RECEIVE_UPDATE_RELEASE_ERROR, () => {
+ it('handles an unsuccessful response from the server', () => {
+ const error = { message: 'An error occurred!' };
+ mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](stateClone, error);
+
+ expect(stateClone.isUpdatingRelease).toEqual(false);
+
+ expect(stateClone.updateError).toEqual(error);
+ });
+ });
+});
diff --git a/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap b/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap
new file mode 100644
index 00000000000..8f2c0427c83
--- /dev/null
+++ b/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap
@@ -0,0 +1,332 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Release block with default props matches the snapshot 1`] = `
+<div
+ class="card release-block"
+ id="v0.3"
+>
+ <div
+ class="card-body"
+ >
+ <div
+ class="d-flex align-items-start"
+ >
+ <h2
+ class="card-title mt-0 mr-auto"
+ >
+
+ New release
+
+ <!---->
+ </h2>
+
+ <a
+ class="btn btn-default js-edit-button ml-2"
+ data-original-title="Edit this release"
+ href="http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit"
+ title=""
+ >
+ <svg
+ aria-hidden="true"
+ class="s16 ic-pencil"
+ >
+ <use
+ xlink:href="#pencil"
+ />
+ </svg>
+ </a>
+ </div>
+
+ <div
+ class="card-subtitle d-flex flex-wrap text-secondary"
+ >
+ <div
+ class="append-right-8"
+ >
+ <svg
+ aria-hidden="true"
+ class="align-middle s16 ic-commit"
+ >
+ <use
+ xlink:href="#commit"
+ />
+ </svg>
+
+ <span
+ data-original-title="Initial commit"
+ title=""
+ >
+ c22b0728
+ </span>
+ </div>
+
+ <div
+ class="append-right-8"
+ >
+ <svg
+ aria-hidden="true"
+ class="align-middle s16 ic-tag"
+ >
+ <use
+ xlink:href="#tag"
+ />
+ </svg>
+
+ <span
+ data-original-title="Tag"
+ title=""
+ >
+ v0.3
+ </span>
+ </div>
+
+ <div
+ class="js-milestone-list-label"
+ >
+ <svg
+ aria-hidden="true"
+ class="align-middle s16 ic-flag"
+ >
+ <use
+ xlink:href="#flag"
+ />
+ </svg>
+
+ <span
+ class="js-label-text"
+ >
+ Milestones
+ </span>
+ </div>
+
+ <a
+ class="append-right-4 prepend-left-4 js-milestone-link"
+ data-original-title="The 13.6 milestone!"
+ href="http://0.0.0.0:3001/root/release-test/-/milestones/2"
+ title=""
+ >
+
+ 13.6
+
+ </a>
+
+ •
+
+ <a
+ class="append-right-4 prepend-left-4 js-milestone-link"
+ data-original-title="The 13.5 milestone!"
+ href="http://0.0.0.0:3001/root/release-test/-/milestones/1"
+ title=""
+ >
+
+ 13.5
+
+ </a>
+
+ <!---->
+
+ <div
+ class="append-right-4"
+ >
+
+ •
+
+ <span
+ data-original-title="Aug 26, 2019 5:54pm GMT+0000"
+ title=""
+ >
+
+ released 1 month ago
+
+ </span>
+ </div>
+
+ <div
+ class="d-flex"
+ >
+
+ by
+
+ <a
+ class="user-avatar-link prepend-left-4"
+ href=""
+ >
+ <span>
+ <img
+ alt="root's avatar"
+ class="avatar s20 "
+ data-original-title=""
+ data-src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
+ height="20"
+ src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
+ title=""
+ width="20"
+ />
+
+ <div
+ aria-hidden="true"
+ class="js-user-avatar-image-toolip d-none"
+ style="display: none;"
+ >
+ <div>
+ root
+ </div>
+ </div>
+ </span>
+ <!---->
+ </a>
+ </div>
+ </div>
+
+ <div
+ class="card-text prepend-top-default"
+ >
+ <b>
+
+ Assets
+
+ <span
+ class="js-assets-count badge badge-pill"
+ >
+ 5
+ </span>
+ </b>
+
+ <ul
+ class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"
+ >
+ <li
+ class="append-bottom-8"
+ >
+ <a
+ class=""
+ data-original-title="Download asset"
+ href="https://google.com"
+ title=""
+ >
+ <svg
+ aria-hidden="true"
+ class="align-middle append-right-4 align-text-bottom s16 ic-package"
+ >
+ <use
+ xlink:href="#package"
+ />
+ </svg>
+
+ my link
+
+ <span>
+ (external source)
+ </span>
+ </a>
+ </li>
+ <li
+ class="append-bottom-8"
+ >
+ <a
+ class=""
+ data-original-title="Download asset"
+ href="https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50"
+ title=""
+ >
+ <svg
+ aria-hidden="true"
+ class="align-middle append-right-4 align-text-bottom s16 ic-package"
+ >
+ <use
+ xlink:href="#package"
+ />
+ </svg>
+
+ my second link
+
+ <!---->
+ </a>
+ </li>
+ </ul>
+
+ <div
+ class="dropdown"
+ >
+ <button
+ aria-expanded="false"
+ aria-haspopup="true"
+ class="btn btn-link"
+ data-toggle="dropdown"
+ type="button"
+ >
+ <svg
+ aria-hidden="true"
+ class="align-top append-right-4 s16 ic-doc-code"
+ >
+ <use
+ xlink:href="#doc-code"
+ />
+ </svg>
+
+ Source code
+
+ <svg
+ aria-hidden="true"
+ class="s16 ic-arrow-down"
+ >
+ <use
+ xlink:href="#arrow-down"
+ />
+ </svg>
+ </button>
+
+ <div
+ class="js-sources-dropdown dropdown-menu"
+ >
+ <li>
+ <a
+ class=""
+ href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip"
+ >
+ Download zip
+ </a>
+ </li>
+ <li>
+ <a
+ class=""
+ href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz"
+ >
+ Download tar.gz
+ </a>
+ </li>
+ <li>
+ <a
+ class=""
+ href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2"
+ >
+ Download tar.bz2
+ </a>
+ </li>
+ <li>
+ <a
+ class=""
+ href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar"
+ >
+ Download tar
+ </a>
+ </li>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="card-text prepend-top-default"
+ >
+ <div>
+ <p
+ data-sourcepos="1:1-1:21"
+ dir="auto"
+ >
+ A super nice release!
+ </p>
+ </div>
+ </div>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/releases/list/components/release_block_spec.js b/spec/frontend/releases/list/components/release_block_spec.js
new file mode 100644
index 00000000000..0b908d7d6bc
--- /dev/null
+++ b/spec/frontend/releases/list/components/release_block_spec.js
@@ -0,0 +1,266 @@
+import { mount } from '@vue/test-utils';
+import ReleaseBlock from '~/releases/list/components/release_block.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { first } from 'underscore';
+import { release } from '../../mock_data';
+import Icon from '~/vue_shared/components/icon.vue';
+import { scrollToElement } from '~/lib/utils/common_utils';
+
+let mockLocationHash;
+jest.mock('~/lib/utils/url_utility', () => ({
+ __esModule: true,
+ getLocationHash: jest.fn().mockImplementation(() => mockLocationHash),
+}));
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ __esModule: true,
+ scrollToElement: jest.fn(),
+}));
+
+describe('Release block', () => {
+ let wrapper;
+ let releaseClone;
+
+ const factory = (releaseProp, releaseEditPageFeatureFlag = true) => {
+ wrapper = mount(ReleaseBlock, {
+ propsData: {
+ release: releaseProp,
+ },
+ provide: {
+ glFeatures: {
+ releaseEditPage: releaseEditPageFeatureFlag,
+ },
+ },
+ sync: false,
+ });
+
+ return wrapper.vm.$nextTick();
+ };
+
+ const milestoneListLabel = () => wrapper.find('.js-milestone-list-label');
+ const editButton = () => wrapper.find('.js-edit-button');
+
+ beforeEach(() => {
+ releaseClone = JSON.parse(JSON.stringify(release));
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with default props', () => {
+ beforeEach(() => factory(release));
+
+ it('matches the snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it("renders the block with an id equal to the release's tag name", () => {
+ expect(wrapper.attributes().id).toBe('v0.3');
+ });
+
+ it('renders an edit button that links to the "Edit release" page', () => {
+ expect(editButton().exists()).toBe(true);
+ expect(editButton().attributes('href')).toBe(release._links.edit);
+ });
+
+ it('renders release name', () => {
+ expect(wrapper.text()).toContain(release.name);
+ });
+
+ it('renders release date', () => {
+ expect(wrapper.text()).toContain(timeagoMixin.methods.timeFormated(release.released_at));
+ });
+
+ it('renders number of assets provided', () => {
+ expect(wrapper.find('.js-assets-count').text()).toContain(release.assets.count);
+ });
+
+ it('renders dropdown with the sources', () => {
+ expect(wrapper.findAll('.js-sources-dropdown li').length).toEqual(
+ release.assets.sources.length,
+ );
+
+ expect(wrapper.find('.js-sources-dropdown li a').attributes().href).toEqual(
+ first(release.assets.sources).url,
+ );
+
+ expect(wrapper.find('.js-sources-dropdown li a').text()).toContain(
+ first(release.assets.sources).format,
+ );
+ });
+
+ it('renders list with the links provided', () => {
+ expect(wrapper.findAll('.js-assets-list li').length).toEqual(release.assets.links.length);
+
+ expect(wrapper.find('.js-assets-list li a').attributes().href).toEqual(
+ first(release.assets.links).url,
+ );
+
+ expect(wrapper.find('.js-assets-list li a').text()).toContain(
+ first(release.assets.links).name,
+ );
+ });
+
+ it('renders author avatar', () => {
+ expect(wrapper.find('.user-avatar-link').exists()).toBe(true);
+ });
+
+ describe('external label', () => {
+ it('renders external label when link is external', () => {
+ expect(wrapper.find('.js-assets-list li a').text()).toContain('external source');
+ });
+
+ it('does not render external label when link is not external', () => {
+ expect(wrapper.find('.js-assets-list li:nth-child(2) a').text()).not.toContain(
+ 'external source',
+ );
+ });
+ });
+
+ it('renders the milestone icon', () => {
+ expect(
+ milestoneListLabel()
+ .find(Icon)
+ .exists(),
+ ).toBe(true);
+ });
+
+ it('renders the label as "Milestones" if more than one milestone is passed in', () => {
+ expect(
+ milestoneListLabel()
+ .find('.js-label-text')
+ .text(),
+ ).toEqual('Milestones');
+ });
+
+ it('renders a link to the milestone with a tooltip', () => {
+ const milestone = first(release.milestones);
+ const milestoneLink = wrapper.find('.js-milestone-link');
+
+ expect(milestoneLink.exists()).toBe(true);
+
+ expect(milestoneLink.text()).toBe(milestone.title);
+
+ expect(milestoneLink.attributes('href')).toBe(milestone.web_url);
+
+ expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description);
+ });
+ });
+
+ it('renders commit sha', () => {
+ releaseClone.commit_path = '/commit/example';
+
+ return factory(releaseClone).then(() => {
+ expect(wrapper.text()).toContain(release.commit.short_id);
+
+ expect(wrapper.find('a[href="/commit/example"]').exists()).toBe(true);
+ });
+ });
+
+ it('renders tag name', () => {
+ releaseClone.tag_path = '/tag/example';
+
+ return factory(releaseClone).then(() => {
+ expect(wrapper.text()).toContain(release.tag_name);
+
+ expect(wrapper.find('a[href="/tag/example"]').exists()).toBe(true);
+ });
+ });
+
+ it("does not render an edit button if release._links.edit isn't a string", () => {
+ delete releaseClone._links;
+
+ return factory(releaseClone).then(() => {
+ expect(editButton().exists()).toBe(false);
+ });
+ });
+
+ it('does not render an edit button if the releaseEditPage feature flag is disabled', () =>
+ factory(releaseClone, false).then(() => {
+ expect(editButton().exists()).toBe(false);
+ }));
+
+ it('does not render the milestone list if no milestones are associated to the release', () => {
+ delete releaseClone.milestones;
+
+ return factory(releaseClone).then(() => {
+ expect(milestoneListLabel().exists()).toBe(false);
+ });
+ });
+
+ it('renders the label as "Milestone" if only a single milestone is passed in', () => {
+ releaseClone.milestones = releaseClone.milestones.slice(0, 1);
+
+ return factory(releaseClone).then(() => {
+ expect(
+ milestoneListLabel()
+ .find('.js-label-text')
+ .text(),
+ ).toEqual('Milestone');
+ });
+ });
+
+ it('renders upcoming release badge', () => {
+ releaseClone.upcoming_release = true;
+
+ return factory(releaseClone).then(() => {
+ expect(wrapper.text()).toContain('Upcoming Release');
+ });
+ });
+
+ it('slugifies the tag_name before setting it as the elements ID', () => {
+ releaseClone.tag_name = 'a dangerous tag name <script>alert("hello")</script>';
+
+ return factory(releaseClone).then(() => {
+ expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script-');
+ });
+ });
+
+ describe('anchor scrolling', () => {
+ beforeEach(() => {
+ scrollToElement.mockClear();
+ });
+
+ const hasTargetBlueBackground = () => wrapper.classes('bg-line-target-blue');
+
+ it('does not attempt to scroll the page if no anchor tag is included in the URL', () => {
+ mockLocationHash = '';
+ return factory(release).then(() => {
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+
+ it("does not attempt to scroll the page if the anchor tag doesn't match the release's tag name", () => {
+ mockLocationHash = 'v0.4';
+ return factory(release).then(() => {
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+
+ it("attempts to scroll itself into view if the anchor tag matches the release's tag name", () => {
+ mockLocationHash = release.tag_name;
+ return factory(release).then(() => {
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+
+ expect(scrollToElement).toHaveBeenCalledWith(wrapper.element);
+ });
+ });
+
+ it('renders with a light blue background if it is the target of the anchor', () => {
+ mockLocationHash = release.tag_name;
+
+ return factory(release).then(() => {
+ expect(hasTargetBlueBackground()).toBe(true);
+ });
+ });
+
+ it('does not render with a light blue background if it is not the target of the anchor', () => {
+ mockLocationHash = '';
+
+ return factory(release).then(() => {
+ expect(hasTargetBlueBackground()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js
index a0885813c7e..b2ebf1174d4 100644
--- a/spec/frontend/releases/mock_data.js
+++ b/spec/frontend/releases/mock_data.js
@@ -57,7 +57,7 @@ export const release = {
committed_date: '2019-08-26T17:47:07.000Z',
},
upcoming_release: false,
- milestone: milestones[0],
+ milestones,
assets: {
count: 5,
sources: [
@@ -89,9 +89,12 @@ export const release = {
id: 2,
name: 'my second link',
url:
- 'https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
+ 'https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
external: false,
},
],
},
+ _links: {
+ edit: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit',
+ },
};
diff --git a/spec/frontend/reports/store/utils_spec.js b/spec/frontend/reports/store/utils_spec.js
index 1679d120db2..f0141b9e162 100644
--- a/spec/frontend/reports/store/utils_spec.js
+++ b/spec/frontend/reports/store/utils_spec.js
@@ -30,7 +30,9 @@ describe('Reports store utils', () => {
const data = { failed: 3, total: 10 };
const result = utils.summaryTextBuilder(name, data);
- expect(result).toBe('Test summary contained 3 failed test results out of 10 total tests');
+ expect(result).toBe(
+ 'Test summary contained 3 failed/error test results out of 10 total tests',
+ );
});
it('should render text for multiple fixed results', () => {
@@ -47,7 +49,7 @@ describe('Reports store utils', () => {
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe(
- 'Test summary contained 3 failed test results and 4 fixed test results out of 10 total tests',
+ 'Test summary contained 3 failed/error test results and 4 fixed test results out of 10 total tests',
);
});
@@ -57,7 +59,7 @@ describe('Reports store utils', () => {
const result = utils.summaryTextBuilder(name, data);
expect(result).toBe(
- 'Test summary contained 1 failed test result and 1 fixed test result out of 10 total tests',
+ 'Test summary contained 1 failed/error test result and 1 fixed test result out of 10 total tests',
);
});
});
@@ -84,7 +86,7 @@ describe('Reports store utils', () => {
const data = { failed: 3, total: 10 };
const result = utils.reportTextBuilder(name, data);
- expect(result).toBe('Rspec found 3 failed test results out of 10 total tests');
+ expect(result).toBe('Rspec found 3 failed/error test results out of 10 total tests');
});
it('should render text for multiple fixed results', () => {
@@ -101,7 +103,7 @@ describe('Reports store utils', () => {
const result = utils.reportTextBuilder(name, data);
expect(result).toBe(
- 'Rspec found 3 failed test results and 4 fixed test results out of 10 total tests',
+ 'Rspec found 3 failed/error test results and 4 fixed test results out of 10 total tests',
);
});
@@ -111,7 +113,7 @@ describe('Reports store utils', () => {
const result = utils.reportTextBuilder(name, data);
expect(result).toBe(
- 'Rspec found 1 failed test result and 1 fixed test result out of 10 total tests',
+ 'Rspec found 1 failed/error test result and 1 fixed test result out of 10 total tests',
);
});
});
diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
index cd8372a8800..08173f4f0c4 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -60,6 +60,111 @@ exports[`Repository last commit component renders commit widget 1`] = `
<div
class="commit-actions flex-row"
>
+ <!---->
+
+ <gllink-stub
+ class="js-commit-pipeline"
+ data-original-title="Commit: failed"
+ href="https://test.com/pipeline"
+ title=""
+ >
+ <ciicon-stub
+ aria-label="Commit: failed"
+ cssclasses=""
+ size="24"
+ status="[object Object]"
+ />
+ </gllink-stub>
+
+ <div
+ class="commit-sha-group d-flex"
+ >
+ <div
+ class="label label-monospace monospace"
+ >
+
+ 12345678
+
+ </div>
+
+ <clipboardbutton-stub
+ cssclass="btn-default"
+ text="123456789"
+ title="Copy commit SHA"
+ tooltipplacement="bottom"
+ />
+ </div>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = `
+<div
+ class="info-well d-none d-sm-flex project-last-commit commit p-3"
+>
+ <useravatarlink-stub
+ class="avatar-cell"
+ imgalt=""
+ imgcssclasses=""
+ imgsize="40"
+ imgsrc="https://test.com"
+ linkhref="https://test.com/test"
+ tooltipplacement="top"
+ tooltiptext=""
+ username=""
+ />
+
+ <div
+ class="commit-detail flex-list"
+ >
+ <div
+ class="commit-content qa-commit-content"
+ >
+ <gllink-stub
+ class="commit-row-message item-title"
+ href="https://test.com/commit/123"
+ >
+
+ Commit title
+
+ </gllink-stub>
+
+ <!---->
+
+ <div
+ class="committer"
+ >
+ <gllink-stub
+ class="commit-author-link js-user-link"
+ href="https://test.com/test"
+ >
+
+ Test
+
+ </gllink-stub>
+
+ authored
+
+ <timeagotooltip-stub
+ cssclass=""
+ time="2019-01-01"
+ tooltipplacement="bottom"
+ />
+ </div>
+
+ <!---->
+ </div>
+
+ <div
+ class="commit-actions flex-row"
+ >
+ <div>
+ <button>
+ Verified
+ </button>
+ </div>
+
<gllink-stub
class="js-commit-pipeline"
data-original-title="Commit: failed"
@@ -88,7 +193,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
<clipboardbutton-stub
cssclass="btn-default"
text="123456789"
- title="Copy commit SHA to clipboard"
+ title="Copy commit SHA"
tooltipplacement="bottom"
/>
</div>
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index 14479f3c3a4..01b56d453e6 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -107,4 +107,10 @@ describe('Repository last commit component', () => {
expect(vm.find('.commit-row-description').isVisible()).toBe(true);
expect(vm.find('.text-expander').classes('open')).toBe(true);
});
+
+ it('renders the signature HTML as returned by the backend', () => {
+ factory(createCommitData({ signatureHtml: '<button>Verified</button>' }));
+
+ expect(vm.element).toMatchSnapshot();
+ });
});
diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
new file mode 100644
index 00000000000..1f93336e755
--- /dev/null
+++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
@@ -0,0 +1,229 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = false 1`] = `
+<div
+ class="block issuable-sidebar-item confidentiality"
+>
+ <div
+ class="sidebar-collapsed-icon"
+ data-boundary="viewport"
+ data-container="body"
+ data-original-title="Not confidential"
+ data-placement="left"
+ title=""
+ >
+ <icon-stub
+ aria-hidden="true"
+ name="eye"
+ size="16"
+ />
+ </div>
+
+ <div
+ class="title hide-collapsed"
+ >
+
+ Confidentiality
+
+ <!---->
+ </div>
+
+ <div
+ class="value sidebar-item-value hide-collapsed"
+ >
+ <!---->
+
+ <div
+ class="no-value sidebar-item-value"
+ >
+ <icon-stub
+ aria-hidden="true"
+ class="sidebar-item-icon inline"
+ name="eye"
+ size="16"
+ />
+
+ Not confidential
+
+ </div>
+ </div>
+
+ <!---->
+</div>
+`;
+
+exports[`Confidential Issue Sidebar Block renders for isConfidential = false and isEditable = true 1`] = `
+<div
+ class="block issuable-sidebar-item confidentiality"
+>
+ <div
+ class="sidebar-collapsed-icon"
+ data-boundary="viewport"
+ data-container="body"
+ data-original-title="Not confidential"
+ data-placement="left"
+ title=""
+ >
+ <icon-stub
+ aria-hidden="true"
+ name="eye"
+ size="16"
+ />
+ </div>
+
+ <div
+ class="title hide-collapsed"
+ >
+
+ Confidentiality
+
+ <a
+ class="float-right confidential-edit"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="confidentiality"
+ href="#"
+ >
+
+ Edit
+
+ </a>
+ </div>
+
+ <div
+ class="value sidebar-item-value hide-collapsed"
+ >
+ <!---->
+
+ <div
+ class="no-value sidebar-item-value"
+ >
+ <icon-stub
+ aria-hidden="true"
+ class="sidebar-item-icon inline"
+ name="eye"
+ size="16"
+ />
+
+ Not confidential
+
+ </div>
+ </div>
+
+ <!---->
+</div>
+`;
+
+exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = false 1`] = `
+<div
+ class="block issuable-sidebar-item confidentiality"
+>
+ <div
+ class="sidebar-collapsed-icon"
+ data-boundary="viewport"
+ data-container="body"
+ data-original-title="Confidential"
+ data-placement="left"
+ title=""
+ >
+ <icon-stub
+ aria-hidden="true"
+ name="eye-slash"
+ size="16"
+ />
+ </div>
+
+ <div
+ class="title hide-collapsed"
+ >
+
+ Confidentiality
+
+ <!---->
+ </div>
+
+ <div
+ class="value sidebar-item-value hide-collapsed"
+ >
+ <!---->
+
+ <div
+ class="value sidebar-item-value hide-collapsed"
+ >
+ <icon-stub
+ aria-hidden="true"
+ class="sidebar-item-icon inline is-active"
+ name="eye-slash"
+ size="16"
+ />
+
+ This issue is confidential
+
+ </div>
+ </div>
+
+ <!---->
+</div>
+`;
+
+exports[`Confidential Issue Sidebar Block renders for isConfidential = true and isEditable = true 1`] = `
+<div
+ class="block issuable-sidebar-item confidentiality"
+>
+ <div
+ class="sidebar-collapsed-icon"
+ data-boundary="viewport"
+ data-container="body"
+ data-original-title="Confidential"
+ data-placement="left"
+ title=""
+ >
+ <icon-stub
+ aria-hidden="true"
+ name="eye-slash"
+ size="16"
+ />
+ </div>
+
+ <div
+ class="title hide-collapsed"
+ >
+
+ Confidentiality
+
+ <a
+ class="float-right confidential-edit"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="confidentiality"
+ href="#"
+ >
+
+ Edit
+
+ </a>
+ </div>
+
+ <div
+ class="value sidebar-item-value hide-collapsed"
+ >
+ <!---->
+
+ <div
+ class="value sidebar-item-value hide-collapsed"
+ >
+ <icon-stub
+ aria-hidden="true"
+ class="sidebar-item-icon inline is-active"
+ name="eye-slash"
+ size="16"
+ />
+
+ This issue is confidential
+
+ </div>
+ </div>
+
+ <!---->
+</div>
+`;
diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap
new file mode 100644
index 00000000000..abcdf600a67
--- /dev/null
+++ b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SidebarTodo template renders component container element with proper data attributes 1`] = `
+<button
+ aria-label="Mark as done"
+ class="btn btn-default btn-todo issuable-header-btn float-right"
+ data-boundary="viewport"
+ data-container="body"
+ data-issuable-id="1"
+ data-issuable-type="epic"
+ data-original-title=""
+ data-placement="left"
+ title=""
+ type="button"
+>
+ <icon-stub
+ class="todo-undone"
+ name="todo-done"
+ size="16"
+ style="display: none;"
+ />
+
+ <span
+ class="issuable-todo-inner"
+ >
+ Mark as done
+ </span>
+
+ <glloadingicon-stub
+ color="orange"
+ inline="true"
+ label="Loading"
+ size="sm"
+ style="display: none;"
+ />
+</button>
+`;
diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
new file mode 100644
index 00000000000..1ec5a94ba68
--- /dev/null
+++ b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
@@ -0,0 +1,167 @@
+import { shallowMount } from '@vue/test-utils';
+import ConfidentialIssueSidebar from '~/sidebar/components/confidential/confidential_issue_sidebar.vue';
+import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
+import EditForm from '~/sidebar/components/confidential/edit_form.vue';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import createFlash from '~/flash';
+import RecaptchaModal from '~/vue_shared/components/recaptcha_modal';
+
+jest.mock('~/flash');
+jest.mock('~/sidebar/services/sidebar_service');
+
+describe('Confidential Issue Sidebar Block', () => {
+ let wrapper;
+
+ const findRecaptchaModal = () => wrapper.find(RecaptchaModal);
+
+ const triggerUpdateConfidentialAttribute = () => {
+ wrapper.setData({ edit: true });
+ return (
+ // wait for edit form to become visible
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ const editForm = wrapper.find(EditForm);
+ const { updateConfidentialAttribute } = editForm.props();
+ updateConfidentialAttribute();
+ })
+ // wait for reCAPTCHA modal to render
+ .then(() => wrapper.vm.$nextTick())
+ );
+ };
+
+ const createComponent = propsData => {
+ const service = new SidebarService();
+ wrapper = shallowMount(ConfidentialIssueSidebar, {
+ propsData: {
+ service,
+ ...propsData,
+ },
+ sync: false,
+ });
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(window.location, 'reload').mockImplementation();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ isConfidential | isEditable
+ ${false} | ${false}
+ ${false} | ${true}
+ ${true} | ${false}
+ ${true} | ${true}
+ `(
+ 'renders for isConfidential = $isConfidential and isEditable = $isEditable',
+ ({ isConfidential, isEditable }) => {
+ createComponent({
+ isConfidential,
+ isEditable,
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ },
+ );
+
+ describe('if editable', () => {
+ beforeEach(() => {
+ createComponent({
+ isConfidential: true,
+ isEditable: true,
+ });
+ });
+
+ it('displays the edit form when editable', () => {
+ wrapper.setData({ edit: false });
+
+ wrapper.find({ ref: 'editLink' }).trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(EditForm).exists()).toBe(true);
+ });
+ });
+
+ it('displays the edit form when opened from collapsed state', () => {
+ wrapper.setData({ edit: false });
+
+ wrapper.find({ ref: 'collapseIcon' }).trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(EditForm).exists()).toBe(true);
+ });
+ });
+
+ it('tracks the event when "Edit" is clicked', () => {
+ const spy = mockTracking('_category_', wrapper.element, jest.spyOn);
+
+ const editLink = wrapper.find({ ref: 'editLink' });
+ triggerEvent(editLink.element);
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
+ label: 'right_sidebar',
+ property: 'confidentiality',
+ });
+ });
+
+ describe('for successful update', () => {
+ beforeEach(() => {
+ SidebarService.prototype.update.mockResolvedValue({ data: 'irrelevant' });
+ });
+
+ it('reloads the page', () =>
+ triggerUpdateConfidentialAttribute().then(() => {
+ expect(window.location.reload).toHaveBeenCalled();
+ }));
+
+ it('does not show an error message', () =>
+ triggerUpdateConfidentialAttribute().then(() => {
+ expect(createFlash).not.toHaveBeenCalled();
+ }));
+ });
+
+ describe('for update error', () => {
+ beforeEach(() => {
+ SidebarService.prototype.update.mockRejectedValue(new Error('updating failed!'));
+ });
+
+ it('does not reload the page', () =>
+ triggerUpdateConfidentialAttribute().then(() => {
+ expect(window.location.reload).not.toHaveBeenCalled();
+ }));
+
+ it('shows an error message', () =>
+ triggerUpdateConfidentialAttribute().then(() => {
+ expect(createFlash).toHaveBeenCalled();
+ }));
+ });
+
+ describe('for spam error', () => {
+ beforeEach(() => {
+ SidebarService.prototype.update.mockRejectedValue({ name: 'SpamError' });
+ });
+
+ it('does not reload the page', () =>
+ triggerUpdateConfidentialAttribute().then(() => {
+ expect(window.location.reload).not.toHaveBeenCalled();
+ }));
+
+ it('does not show an error message', () =>
+ triggerUpdateConfidentialAttribute().then(() => {
+ expect(createFlash).not.toHaveBeenCalled();
+ }));
+
+ it('shows a reCAPTCHA modal', () => {
+ expect(findRecaptchaModal().exists()).toBe(false);
+
+ return triggerUpdateConfidentialAttribute().then(() => {
+ expect(findRecaptchaModal().exists()).toBe(true);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/todo_spec.js
new file mode 100644
index 00000000000..c93bbadc264
--- /dev/null
+++ b/spec/frontend/sidebar/todo_spec.js
@@ -0,0 +1,93 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+
+import SidebarTodos from '~/sidebar/components/todo_toggle/todo.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+const defaultProps = {
+ issuableId: 1,
+ issuableType: 'epic',
+};
+
+describe('SidebarTodo', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(SidebarTodos, {
+ sync: false,
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ state | classes
+ ${false} | ${['btn', 'btn-default', 'btn-todo', 'issuable-header-btn', 'float-right']}
+ ${true} | ${['btn-blank', 'btn-todo', 'sidebar-collapsed-icon', 'dont-change-state']}
+ `('returns todo button classes for when `collapsed` prop is `$state`', ({ state, classes }) => {
+ createComponent({ collapsed: state });
+ expect(wrapper.find('button').classes()).toStrictEqual(classes);
+ });
+
+ it.each`
+ isTodo | iconClass | label | icon
+ ${false} | ${''} | ${'Add a To Do'} | ${'todo-add'}
+ ${true} | ${'todo-undone'} | ${'Mark as done'} | ${'todo-done'}
+ `(
+ 'renders proper button when `isTodo` prop is `$isTodo`',
+ ({ isTodo, iconClass, label, icon }) => {
+ createComponent({ isTodo });
+
+ expect(
+ wrapper
+ .find(Icon)
+ .classes()
+ .join(' '),
+ ).toStrictEqual(iconClass);
+ expect(wrapper.find(Icon).props('name')).toStrictEqual(icon);
+ expect(wrapper.find('button').text()).toBe(label);
+ },
+ );
+
+ describe('template', () => {
+ it('emits `toggleTodo` event when clicked on button', () => {
+ createComponent();
+ wrapper.find('button').trigger('click');
+
+ expect(wrapper.emitted().toggleTodo).toBeTruthy();
+ });
+
+ it('renders component container element with proper data attributes', () => {
+ createComponent({
+ issuableId: 1,
+ issuableType: 'epic',
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders button label element when `collapsed` prop is `false`', () => {
+ createComponent({ collapsed: false });
+
+ expect(wrapper.find('span.issuable-todo-inner').text()).toBe('Mark as done');
+ });
+
+ it('renders button icon when `collapsed` prop is `true`', () => {
+ createComponent({ collapsed: true });
+
+ expect(wrapper.find(Icon).props('name')).toBe('todo-done');
+ });
+
+ it('renders loading icon when `isActionActive` prop is true', () => {
+ createComponent({ isActionActive: true });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 6e1f1038dcd..0b9cfa44409 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -8,6 +8,8 @@ import { getJSONFixture, loadHTMLFixture, setHTMLFixture } from './helpers/fixtu
import { setupManualMocks } from './mocks/mocks_helper';
import customMatchers from './matchers';
+import './helpers/dom_shims';
+
// Expose jQuery so specs using jQuery plugins can be imported nicely.
// Here is an issue to explore better alternatives:
// https://gitlab.com/gitlab-org/gitlab/issues/12448
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
index dfc068ab6ea..964f8b8787e 100644
--- a/spec/frontend/tracking_spec.js
+++ b/spec/frontend/tracking_spec.js
@@ -1,10 +1,9 @@
-import $ from 'jquery';
import { setHTMLFixture } from './helpers/fixtures';
-
import Tracking, { initUserTracking } from '~/tracking';
describe('Tracking', () => {
let snowplowSpy;
+ let bindDocumentSpy;
beforeEach(() => {
window.snowplow = window.snowplow || (() => {});
@@ -17,6 +16,10 @@ describe('Tracking', () => {
});
describe('initUserTracking', () => {
+ beforeEach(() => {
+ bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null);
+ });
+
it('calls through to get a new tracker with the expected options', () => {
initUserTracking();
expect(snowplowSpy).toHaveBeenCalledWith('newTracker', '_namespace_', 'app.gitfoo.com', {
@@ -50,6 +53,11 @@ describe('Tracking', () => {
expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking');
expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking');
});
+
+ it('binds the document event handling', () => {
+ initUserTracking();
+ expect(bindDocumentSpy).toHaveBeenCalled();
+ });
});
describe('.event', () => {
@@ -62,11 +70,15 @@ describe('Tracking', () => {
it('tracks to snowplow (our current tracking system)', () => {
Tracking.event('_category_', '_eventName_', { label: '_label_' });
- expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', '_category_', '_eventName_', {
- label: '_label_',
- property: '',
- value: '',
- });
+ expect(snowplowSpy).toHaveBeenCalledWith(
+ 'trackStructEvent',
+ '_category_',
+ '_eventName_',
+ '_label_',
+ undefined,
+ undefined,
+ undefined,
+ );
});
it('skips tracking if snowplow is unavailable', () => {
@@ -99,83 +111,70 @@ describe('Tracking', () => {
});
describe('tracking interface events', () => {
- let eventSpy = null;
- let subject = null;
+ let eventSpy;
+
+ const trigger = (selector, eventName = 'click') => {
+ const event = new Event(eventName, { bubbles: true });
+ document.querySelector(selector).dispatchEvent(event);
+ };
beforeEach(() => {
eventSpy = jest.spyOn(Tracking, 'event');
- subject = new Tracking('_category_');
+ Tracking.bindDocument('_category_'); // only happens once
setHTMLFixture(`
<input data-track-event="click_input1" data-track-label="_label_" value="_value_"/>
<input data-track-event="click_input2" data-track-value="_value_override_" value="_value_"/>
<input type="checkbox" data-track-event="toggle_checkbox" value="_value_" checked/>
<input class="dropdown" data-track-event="toggle_dropdown"/>
- <div class="js-projects-list-holder"></div>
+ <div data-track-event="nested_event"><span class="nested"></span></div>
`);
});
it('binds to clicks on elements matching [data-track-event]', () => {
- subject.bind(document);
- $('[data-track-event="click_input1"]').click();
+ trigger('[data-track-event="click_input1"]');
expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', {
label: '_label_',
value: '_value_',
- property: '',
});
});
it('allows value override with the data-track-value attribute', () => {
- subject.bind(document);
- $('[data-track-event="click_input2"]').click();
+ trigger('[data-track-event="click_input2"]');
expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', {
- label: '',
value: '_value_override_',
- property: '',
});
});
it('handles checkbox values correctly', () => {
- subject.bind(document);
- const $checkbox = $('[data-track-event="toggle_checkbox"]');
-
- $checkbox.click(); // unchecking
+ trigger('[data-track-event="toggle_checkbox"]'); // checking
expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', {
- label: '',
- property: '',
value: false,
});
- $checkbox.click(); // checking
+ trigger('[data-track-event="toggle_checkbox"]'); // unchecking
expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', {
- label: '',
- property: '',
value: '_value_',
});
});
it('handles bootstrap dropdowns', () => {
- new Tracking('_category_').bind(document);
- const $dropdown = $('[data-track-event="toggle_dropdown"]');
+ trigger('[data-track-event="toggle_dropdown"]', 'show.bs.dropdown'); // showing
- $dropdown.trigger('show.bs.dropdown'); // showing
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_show', {});
- expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_show', {
- label: '',
- property: '',
- value: '',
- });
+ trigger('[data-track-event="toggle_dropdown"]', 'hide.bs.dropdown'); // hiding
+
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_hide', {});
+ });
- $dropdown.trigger('hide.bs.dropdown'); // hiding
+ it('handles nested elements inside an element with tracking', () => {
+ trigger('span.nested', 'click');
- expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_hide', {
- label: '',
- property: '',
- value: '',
- });
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'nested_event', {});
});
});
});
diff --git a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js
new file mode 100644
index 00000000000..7d593a77bf3
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js
@@ -0,0 +1,121 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { TEST_HOST } from 'helpers/test_constants';
+import ArtifactsListApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue';
+import createStore from '~/vue_merge_request_widget/stores/artifacts_list';
+import { artifactsList } from './mock_data';
+
+describe('Merge Requests Artifacts list app', () => {
+ let wrapper;
+ let mock;
+ const store = createStore();
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+
+ const actionSpies = {
+ fetchArtifacts: jest.fn(),
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ const createComponent = () => {
+ wrapper = mount(localVue.extend(ArtifactsListApp), {
+ propsData: {
+ endpoint: TEST_HOST,
+ },
+ store,
+ methods: {
+ ...actionSpies,
+ },
+ localVue,
+ sync: false,
+ });
+ };
+
+ const findButtons = () => wrapper.findAll('button');
+ const findTitle = () => wrapper.find('.js-title');
+ const findErrorMessage = () => wrapper.find('.js-error-state');
+ const findTableRows = () => wrapper.findAll('tbody tr');
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ createComponent();
+ store.dispatch('requestArtifacts');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders a loading icon', () => {
+ const loadingIcon = wrapper.find(GlLoadingIcon);
+ expect(loadingIcon.exists()).toBe(true);
+ });
+
+ it('renders loading text', () => {
+ expect(findTitle().text()).toBe('Loading artifacts');
+ });
+
+ it('renders disabled buttons', () => {
+ const buttons = findButtons();
+ expect(buttons.at(0).attributes('disabled')).toBe('disabled');
+ expect(buttons.at(1).attributes('disabled')).toBe('disabled');
+ });
+ });
+
+ describe('with results', () => {
+ beforeEach(() => {
+ createComponent();
+ mock.onGet(wrapper.vm.$store.state.endpoint).reply(200, artifactsList, {});
+ store.dispatch('receiveArtifactsSuccess', {
+ data: artifactsList,
+ status: 200,
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders a title with the number of artifacts', () => {
+ expect(findTitle().text()).toBe('View 2 exposed artifacts');
+ });
+
+ it('renders both buttons enabled', () => {
+ const buttons = findButtons();
+ expect(buttons.at(0).attributes('disabled')).toBe(undefined);
+ expect(buttons.at(1).attributes('disabled')).toBe(undefined);
+ });
+
+ describe('on click', () => {
+ it('renders the list of artifacts', () => {
+ findTitle().trigger('click');
+ wrapper.vm.$nextTick(() => {
+ expect(findTableRows().length).toEqual(2);
+ });
+ });
+ });
+ });
+
+ describe('with error', () => {
+ beforeEach(() => {
+ createComponent();
+ mock.onGet(wrapper.vm.$store.state.endpoint).reply(500, {}, {});
+ store.dispatch('receiveArtifactsError');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders the error state', () => {
+ expect(findErrorMessage().text()).toBe('An error occurred while fetching the artifacts');
+ });
+
+ it('does not render buttons', () => {
+ const buttons = findButtons();
+ expect(buttons.exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/artifacts_list_spec.js b/spec/frontend/vue_mr_widget/components/artifacts_list_spec.js
new file mode 100644
index 00000000000..8c805faf574
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/artifacts_list_spec.js
@@ -0,0 +1,61 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlLink } from '@gitlab/ui';
+import ArtifactsList from '~/vue_merge_request_widget/components/artifacts_list.vue';
+import { artifactsList } from './mock_data';
+
+describe('Artifacts List', () => {
+ let wrapper;
+ const localVue = createLocalVue();
+
+ const data = {
+ artifacts: artifactsList,
+ };
+
+ const mountComponent = props => {
+ wrapper = shallowMount(localVue.extend(ArtifactsList), {
+ propsData: {
+ ...props,
+ },
+ sync: false,
+ localVue,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ beforeEach(() => {
+ mountComponent(data);
+ });
+
+ it('renders list of artifacts', () => {
+ expect(wrapper.findAll('tbody tr').length).toEqual(data.artifacts.length);
+ });
+
+ it('renders link for the artifact', () => {
+ expect(wrapper.find(GlLink).attributes('href')).toEqual(data.artifacts[0].url);
+ });
+
+ it('renders artifact name', () => {
+ expect(wrapper.find(GlLink).text()).toEqual(data.artifacts[0].text);
+ });
+
+ it('renders job url', () => {
+ expect(
+ wrapper
+ .findAll(GlLink)
+ .at(1)
+ .attributes('href'),
+ ).toEqual(data.artifacts[0].job_path);
+ });
+
+ it('renders job name', () => {
+ expect(
+ wrapper
+ .findAll(GlLink)
+ .at(1)
+ .text(),
+ ).toEqual(data.artifacts[0].job_name);
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mock_data.js b/spec/frontend/vue_mr_widget/components/mock_data.js
new file mode 100644
index 00000000000..39c7d75cda5
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/mock_data.js
@@ -0,0 +1,15 @@
+// eslint-disable-next-line import/prefer-default-export
+export const artifactsList = [
+ {
+ text: 'result.txt',
+ url: 'bar',
+ job_name: 'generate-artifact',
+ job_path: 'bar',
+ },
+ {
+ text: 'foo.txt',
+ url: 'foo',
+ job_name: 'foo-artifact',
+ job_path: 'foo',
+ },
+];
diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
new file mode 100644
index 00000000000..ee107f297ef
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
@@ -0,0 +1,99 @@
+import { mount } from '@vue/test-utils';
+import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+
+describe('Merge Request Collapsible Extension', () => {
+ let wrapper;
+ const data = {
+ title: 'View artifacts',
+ };
+
+ const mountComponent = props => {
+ wrapper = mount(MrCollapsibleSection, {
+ propsData: {
+ ...props,
+ },
+ slots: {
+ default: '<div class="js-slot">Foo</div>',
+ },
+ });
+ };
+
+ const findTitle = () => wrapper.find('.js-title');
+ const findErrorMessage = () => wrapper.find('.js-error-state');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('while collapsed', () => {
+ beforeEach(() => {
+ mountComponent(data);
+ });
+
+ it('renders provided title', () => {
+ expect(findTitle().text()).toBe(data.title);
+ });
+
+ it('renders angle-right icon', () => {
+ expect(wrapper.vm.arrowIconName).toBe('angle-right');
+ });
+
+ describe('onClick', () => {
+ beforeEach(() => {
+ wrapper.find('button').trigger('click');
+ });
+
+ it('rendes the provided slot', () => {
+ expect(wrapper.find('.js-slot').isVisible()).toBe(true);
+ });
+
+ it('renders `Collapse` as the title', () => {
+ expect(findTitle().text()).toBe('Collapse');
+ });
+
+ it('renders angle-down icon', () => {
+ expect(wrapper.vm.arrowIconName).toBe('angle-down');
+ });
+ });
+ });
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ mountComponent(Object.assign({}, data, { isLoading: true }));
+ });
+
+ it('renders the buttons disabled', () => {
+ expect(
+ wrapper
+ .findAll('button')
+ .at(0)
+ .attributes('disabled'),
+ ).toEqual('disabled');
+ expect(
+ wrapper
+ .findAll('button')
+ .at(1)
+ .attributes('disabled'),
+ ).toEqual('disabled');
+ });
+
+ it('renders loading spinner', () => {
+ expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
+ });
+ });
+
+ describe('with error', () => {
+ beforeEach(() => {
+ mountComponent(Object.assign({}, data, { hasError: true }));
+ });
+
+ it('does not render the buttons', () => {
+ expect(wrapper.findAll('button').exists()).toBe(false);
+ });
+
+ it('renders title message provided', () => {
+ expect(findErrorMessage().text()).toBe(data.title);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/getters_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/getters_spec.js
new file mode 100644
index 00000000000..62ee6f5f189
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/getters_spec.js
@@ -0,0 +1,32 @@
+import { title } from '~/vue_merge_request_widget/stores/artifacts_list/getters';
+import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
+import { artifactsList } from '../../components/mock_data';
+
+describe('Artifacts Store Getters', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ });
+
+ describe('title', () => {
+ describe('when is loading', () => {
+ it('returns loading message', () => {
+ localState.isLoading = true;
+ expect(title(localState)).toBe('Loading artifacts');
+ });
+ });
+ describe('when has error', () => {
+ it('returns error message', () => {
+ localState.hasError = true;
+ expect(title(localState)).toBe('An error occurred while fetching the artifacts');
+ });
+ });
+ describe('when it has artifacts', () => {
+ it('returns artifacts message', () => {
+ localState.artifacts = artifactsList;
+ expect(title(localState)).toBe('View 2 exposed artifacts');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/mutations_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/mutations_spec.js
new file mode 100644
index 00000000000..ea89fdb72e9
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/mutations_spec.js
@@ -0,0 +1,78 @@
+import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
+import mutations from '~/vue_merge_request_widget/stores/artifacts_list/mutations';
+import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types';
+
+describe('Artifacts Store Mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_ENDPOINT', () => {
+ it('should set endpoint', () => {
+ mutations[types.SET_ENDPOINT](stateCopy, 'endpoint.json');
+
+ expect(stateCopy.endpoint).toEqual('endpoint.json');
+ });
+ });
+
+ describe('REQUEST_ARTIFACTS', () => {
+ it('should set isLoading to true', () => {
+ mutations[types.REQUEST_ARTIFACTS](stateCopy);
+
+ expect(stateCopy.isLoading).toEqual(true);
+ });
+ });
+
+ describe('REECEIVE_ARTIFACTS_SUCCESS', () => {
+ const artifacts = [
+ {
+ text: 'result.txt',
+ url: 'asda',
+ job_name: 'generate-artifact',
+ job_path: 'asda',
+ },
+ {
+ text: 'file.txt',
+ url: 'asda',
+ job_name: 'generate-artifact',
+ job_path: 'asda',
+ },
+ ];
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_ARTIFACTS_SUCCESS](stateCopy, artifacts);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to false', () => {
+ expect(stateCopy.hasError).toEqual(false);
+ });
+
+ it('should set list of artifacts', () => {
+ expect(stateCopy.artifacts).toEqual(artifacts);
+ });
+ });
+
+ describe('RECEIVE_ARTIFACTS_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_ARTIFACTS_ERROR](stateCopy);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.hasError).toEqual(true);
+ });
+
+ it('should set list of artifacts as empty array', () => {
+ expect(stateCopy.artifacts).toEqual([]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/changed_file_icon_spec.js b/spec/frontend/vue_shared/components/changed_file_icon_spec.js
index d0586f9e63f..d5861b18318 100644
--- a/spec/frontend/vue_shared/components/changed_file_icon_spec.js
+++ b/spec/frontend/vue_shared/components/changed_file_icon_spec.js
@@ -28,10 +28,7 @@ describe('Changed file icon', () => {
const findIcon = () => wrapper.find(Icon);
const findIconName = () => findIcon().props('name');
- const findIconClasses = () =>
- findIcon()
- .props('cssClasses')
- .split(' ');
+ const findIconClasses = () => findIcon().classes();
const findTooltipText = () => wrapper.attributes('data-original-title');
it('with isCentered true, adds center class', () => {
diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js
index 328eec0a80a..f8f68a6a77a 100644
--- a/spec/frontend/vue_shared/components/file_icon_spec.js
+++ b/spec/frontend/vue_shared/components/file_icon_spec.js
@@ -49,7 +49,7 @@ describe('File Icon component', () => {
});
expect(findIcon().exists()).toBe(false);
- expect(wrapper.find(Icon).props('cssClasses')).toContain('folder-icon');
+ expect(wrapper.find(Icon).classes()).toContain('folder-icon');
});
it('should render a loading icon', () => {
diff --git a/spec/frontend/vue_shared/components/modal_copy_button_spec.js b/spec/frontend/vue_shared/components/modal_copy_button_spec.js
index f1943861523..d8c55bee8e0 100644
--- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js
+++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js
@@ -14,7 +14,7 @@ describe('modal copy button', () => {
wrapper = shallowMount(Component, {
propsData: {
text: 'copy me',
- title: 'Copy this value into Clipboard!',
+ title: 'Copy this value',
},
});
});
diff --git a/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js b/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js
new file mode 100644
index 00000000000..d86d627886f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js
@@ -0,0 +1,21 @@
+import { eventHub, callbackName } from '~/vue_shared/components/recaptcha_eventhub';
+
+describe('reCAPTCHA event hub', () => {
+ // the following test case currently crashes
+ // see https://gitlab.com/gitlab-org/gitlab/issues/29192#note_217840035
+ // eslint-disable-next-line jest/no-disabled-tests
+ it.skip('throws an error for overriding the callback', () => {
+ expect(() => {
+ window[callbackName] = 'something';
+ }).toThrow();
+ });
+
+ it('triggering callback emits a submit event', () => {
+ const eventHandler = jest.fn();
+ eventHub.$once('submit', eventHandler);
+
+ window[callbackName]();
+
+ expect(eventHandler).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js b/spec/frontend/vue_shared/components/recaptcha_modal_spec.js
new file mode 100644
index 00000000000..e509fe09d94
--- /dev/null
+++ b/spec/frontend/vue_shared/components/recaptcha_modal_spec.js
@@ -0,0 +1,36 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { eventHub } from '~/vue_shared/components/recaptcha_eventhub';
+
+import RecaptchaModal from '~/vue_shared/components/recaptcha_modal';
+
+describe('RecaptchaModal', () => {
+ const recaptchaFormId = 'recaptcha-form';
+ const recaptchaHtml = `<form id="${recaptchaFormId}"></form>`;
+
+ let wrapper;
+
+ const findRecaptchaForm = () => wrapper.find(`#${recaptchaFormId}`).element;
+
+ beforeEach(() => {
+ wrapper = shallowMount(RecaptchaModal, {
+ sync: false,
+ propsData: {
+ html: recaptchaHtml,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('submits the form if event hub emits submit event', () => {
+ const form = findRecaptchaForm();
+ jest.spyOn(form, 'submit').mockImplementation();
+
+ eventHub.$emit('submit');
+
+ expect(form.submit).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/directives/track_event_spec.js b/spec/frontend/vue_shared/directives/track_event_spec.js
new file mode 100644
index 00000000000..d63f6ae05b4
--- /dev/null
+++ b/spec/frontend/vue_shared/directives/track_event_spec.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Tracking from '~/tracking';
+import TrackEvent from '~/vue_shared/directives/track_event';
+
+jest.mock('~/tracking');
+
+const Component = Vue.component('dummy-element', {
+ directives: {
+ TrackEvent,
+ },
+ data() {
+ return {
+ trackingOptions: null,
+ };
+ },
+ template: '<button id="trackable" v-track-event="trackingOptions"></button>',
+});
+
+const localVue = createLocalVue();
+let wrapper;
+let button;
+
+describe('Error Tracking directive', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(localVue.extend(Component), {
+ localVue,
+ });
+ button = wrapper.find('#trackable');
+ });
+
+ it('should not track the event if required arguments are not provided', () => {
+ button.trigger('click');
+ expect(Tracking.event).not.toHaveBeenCalled();
+ });
+
+ it('should track event on click if tracking info provided', () => {
+ const trackingOptions = {
+ category: 'Tracking',
+ action: 'click_trackable_btn',
+ label: 'Trackable Info',
+ };
+
+ wrapper.setData({ trackingOptions });
+ const { category, action, label, property, value } = trackingOptions;
+ button.trigger('click');
+ expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property, value });
+ });
+});
diff --git a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js
new file mode 100644
index 00000000000..6ecc330b5af
--- /dev/null
+++ b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js
@@ -0,0 +1,42 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import GlFeatureFlags from '~/vue_shared/gl_feature_flags_plugin';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+const localVue = createLocalVue();
+
+describe('GitLab Feature Flags Plugin', () => {
+ beforeEach(() => {
+ window.gon = {
+ features: {
+ aFeature: true,
+ bFeature: false,
+ },
+ };
+
+ localVue.use(GlFeatureFlags);
+ });
+
+ it('should provide glFeatures to components', () => {
+ const component = {
+ template: `<span></span>`,
+ inject: ['glFeatures'],
+ };
+ const wrapper = shallowMount(component, { localVue });
+ expect(wrapper.vm.glFeatures).toEqual({
+ aFeature: true,
+ bFeature: false,
+ });
+ });
+
+ it('should integrate with the glFeatureMixin', () => {
+ const component = {
+ template: `<span></span>`,
+ mixins: [glFeatureFlagsMixin()],
+ };
+ const wrapper = shallowMount(component, { localVue });
+ expect(wrapper.vm.glFeatures).toEqual({
+ aFeature: true,
+ bFeature: false,
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/mixins/gl_feature_flags_mixin_spec.js b/spec/frontend/vue_shared/mixins/gl_feature_flags_mixin_spec.js
new file mode 100644
index 00000000000..a3e3270a4e8
--- /dev/null
+++ b/spec/frontend/vue_shared/mixins/gl_feature_flags_mixin_spec.js
@@ -0,0 +1,36 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+const localVue = createLocalVue();
+
+describe('GitLab Feature Flags Mixin', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ const gon = {
+ features: {
+ aFeature: true,
+ bFeature: false,
+ },
+ };
+
+ const component = {
+ template: `<span></span>`,
+ mixins: [glFeatureFlagsMixin()],
+ };
+
+ wrapper = shallowMount(component, {
+ localVue,
+ provide: {
+ glFeatures: { ...(gon.features || {}) },
+ },
+ });
+ });
+
+ it('should provide glFeatures to components', () => {
+ expect(wrapper.vm.glFeatures).toEqual({
+ aFeature: true,
+ bFeature: false,
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/plugins/global_toast_spec.js b/spec/frontend/vue_shared/plugins/global_toast_spec.js
index 551abe3cb41..89f43a5e556 100644
--- a/spec/frontend/vue_shared/plugins/global_toast_spec.js
+++ b/spec/frontend/vue_shared/plugins/global_toast_spec.js
@@ -1,24 +1,24 @@
-import toast from '~/vue_shared/plugins/global_toast';
import Vue from 'vue';
+import toast from '~/vue_shared/plugins/global_toast';
describe('Global toast', () => {
let spyFunc;
beforeEach(() => {
- spyFunc = jest.spyOn(Vue.toasted, 'show').mockImplementation(() => {});
+ spyFunc = jest.spyOn(Vue.prototype.$toast, 'show').mockImplementation(() => {});
});
afterEach(() => {
spyFunc.mockRestore();
});
- it('should pass all args to Vue toasted', () => {
+ it("should call GitLab UI's toast method", () => {
const arg1 = 'TestMessage';
const arg2 = { className: 'foo' };
toast(arg1, arg2);
- expect(Vue.toasted.show).toHaveBeenCalledTimes(1);
- expect(Vue.toasted.show).toHaveBeenCalledWith(arg1, arg2);
+ expect(Vue.prototype.$toast.show).toHaveBeenCalledTimes(1);
+ expect(Vue.prototype.$toast.show).toHaveBeenCalledWith(arg1, arg2);
});
});