diff options
Diffstat (limited to 'spec/frontend')
396 files changed, 13231 insertions, 5019 deletions
diff --git a/spec/frontend/__helpers__/mock_user_callout_dismisser.js b/spec/frontend/__helpers__/mock_user_callout_dismisser.js new file mode 100644 index 00000000000..652f36028dc --- /dev/null +++ b/spec/frontend/__helpers__/mock_user_callout_dismisser.js @@ -0,0 +1,16 @@ +/** + * Mock factory for the UserCalloutDismisser component. + * @param {slotProps} The slot props to pass to the default slot content. + * @returns {VueComponent} + */ +export const makeMockUserCalloutDismisser = ({ + dismiss = () => {}, + shouldShowCallout = true, +} = {}) => ({ + render() { + return this.$scopedSlots.default({ + dismiss, + shouldShowCallout, + }); + }, +}); diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js index a94cee84f74..2aae91f8a39 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper.js @@ -1,5 +1,5 @@ import * as testingLibrary from '@testing-library/dom'; -import { createWrapper, WrapperArray, mount, shallowMount } from '@vue/test-utils'; +import { createWrapper, WrapperArray, ErrorWrapper, mount, shallowMount } from '@vue/test-utils'; import { isArray, upperFirst } from 'lodash'; const vNodeContainsText = (vnode, text) => @@ -81,14 +81,9 @@ export const extendedWrapper = (wrapper) => { options, ); - // Return VTU `ErrorWrapper` if element is not found - // https://github.com/vuejs/vue-test-utils/blob/dev/packages/test-utils/src/error-wrapper.js - // VTU does not expose `ErrorWrapper` so, as of now, this is the best way to - // create an `ErrorWrapper` + // Element not found, return an `ErrorWrapper` if (!elements.length) { - const emptyElement = document.createElement('div'); - - return createWrapper(emptyElement).find('testing-library-element-not-found'); + return new ErrorWrapper(query); } return createWrapper(elements[0], this.options || {}); diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js index dfe5a483223..3bb228f94b8 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js @@ -4,6 +4,7 @@ import { shallowMount, Wrapper as VTUWrapper, WrapperArray as VTUWrapperArray, + ErrorWrapper as VTUErrorWrapper, } from '@vue/test-utils'; import { extendedWrapper, @@ -195,7 +196,7 @@ describe('Vue test utils helpers', () => { }); it('returns a VTU error wrapper', () => { - expect(wrapper[findMethod](text, options).exists()).toBe(false); + expect(wrapper[findMethod](text, options)).toBeInstanceOf(VTUErrorWrapper); }); }); }); diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index 826fb820d9b..20e8bc059ec 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -7,7 +7,6 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import mockAlerts from 'jest/vue_shared/alert_details/mocks/alerts.json'; import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; import { visitUrl } from '~/lib/utils/url_utility'; -import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import defaultProvideValues from '../mocks/alerts_provide_config.json'; @@ -41,8 +40,7 @@ describe('AlertManagementTable', () => { resolved: 11, all: 26, }; - const findDeprecationNotice = () => - wrapper.findComponent(AlertDeprecationWarning).findComponent(GlAlert); + const findDeprecationNotice = () => wrapper.findByTestId('alerts-deprecation-warning'); function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) { wrapper = extendedWrapper( @@ -239,19 +237,21 @@ describe('AlertManagementTable', () => { expect(visitUrl).toHaveBeenCalledWith('/1527542/details', true); }); - describe('deprecation notice', () => { - it('shows the deprecation notice when available', () => { - mountComponent({ provide: { hasManagedPrometheus: true } }); - - expect(findDeprecationNotice().exists()).toBe(true); - }); - - it('hides the deprecation notice when not available', () => { - mountComponent(); - - expect(findDeprecationNotice().exists()).toBe(false); - }); - }); + it.each` + managedAlertsDeprecation | hasManagedPrometheus | isVisible + ${false} | ${false} | ${false} + ${false} | ${true} | ${true} + ${true} | ${false} | ${false} + ${true} | ${true} | ${false} + `( + 'when the deprecation feature flag is $managedAlertsDeprecation and has managed prometheus is $hasManagedPrometheus', + ({ hasManagedPrometheus, managedAlertsDeprecation, isVisible }) => { + mountComponent({ + provide: { hasManagedPrometheus, glFeatures: { managedAlertsDeprecation } }, + }); + expect(findDeprecationNotice().exists()).toBe(isVisible); + }, + ); describe('alert issue links', () => { beforeEach(() => { diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index 85d21f231b1..3a374084dbc 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -11,6 +11,7 @@ exports[`Alert integration settings form default state should match the default <form> <gl-form-group-stub class="gl-pl-0" + labeldescription="" > <gl-form-checkbox-stub checked="true" @@ -26,22 +27,24 @@ exports[`Alert integration settings form default state should match the default class="col-8 col-md-9 gl-px-6" label-for="alert-integration-settings-issue-template" label-size="sm" + labeldescription="" > <label class="gl-display-inline-flex" for="alert-integration-settings-issue-template" > - Incident template (optional) + Incident template (optional). <gl-link-stub href="/help/user/project/description_templates#create-an-issue-template" target="_blank" > - <gl-icon-stub - name="question" - size="12" - /> + <span + class="gl-font-weight-normal gl-pl-2" + > + Learn more. + </span> </gl-link-stub> </label> @@ -75,6 +78,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-group-stub class="gl-pl-0 gl-mb-5" + labeldescription="" > <gl-form-checkbox-stub> <span> @@ -85,6 +89,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-group-stub class="gl-pl-0 gl-mb-5" + labeldescription="" > <gl-form-checkbox-stub checked="true" @@ -103,7 +108,7 @@ exports[`Alert integration settings form default state should match the default icon="" size="medium" type="submit" - variant="success" + variant="confirm" > Save changes diff --git a/spec/frontend/incidents_settings/components/alerts_form_spec.js b/spec/frontend/alerts_settings/components/alerts_form_spec.js index 2516e8afdfa..a045954dfb8 100644 --- a/spec/frontend/incidents_settings/components/alerts_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_form_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import AlertsSettingsForm from '~/incidents_settings/components/alerts_form.vue'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_form.vue'; describe('Alert integration settings form', () => { let wrapper; @@ -25,7 +25,6 @@ describe('Alert integration settings form', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); diff --git a/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js index c43d78a1cf3..3ffbb7ab60a 100644 --- a/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js @@ -80,7 +80,7 @@ describe('AlertIntegrationsList', () => { const cell = finsStatusCell().at(0); const activatedIcon = cell.find(GlIcon); expect(cell.text()).toBe(i18n.status.enabled.name); - expect(activatedIcon.attributes('name')).toBe('check-circle-filled'); + expect(activatedIcon.attributes('name')).toBe('check'); expect(activatedIcon.attributes('title')).toBe(i18n.status.enabled.tooltip); }); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 595c3f1a289..1c4dde39585 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -95,6 +95,10 @@ describe('AlertsSettingsWrapper', () => { }, provide: { ...provide, + alertSettings: { + templates: [], + }, + service: {}, }, mocks: { $apollo: { @@ -129,12 +133,17 @@ describe('AlertsSettingsWrapper', () => { wrapper = mount(AlertsSettingsWrapper, { localVue, apolloProvider: fakeApollo, + provide: { + alertSettings: { + templates: [], + }, + service: {}, + }, }); } afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('template', () => { diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 139128e6d4a..f708d8c7728 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -116,6 +116,24 @@ describe('Api', () => { }); }); }); + + describe('deleteProjectPackageFile', () => { + const packageFileId = 'package_file_id'; + + it('delete a package', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages/${packageId}/package_files/${packageFileId}`; + + jest.spyOn(axios, 'delete'); + mock.onDelete(expectedUrl).replyOnce(httpStatus.OK, true); + + return Api.deleteProjectPackageFile(projectId, packageId, packageFileId).then( + ({ data }) => { + expect(data).toEqual(true); + expect(axios.delete).toHaveBeenCalledWith(expectedUrl); + }, + ); + }); + }); }); describe('container registry', () => { @@ -1503,33 +1521,55 @@ describe('Api', () => { 'Content-Type': 'application/json', }; - describe('when usage data increment unique users is called with feature flag disabled', () => { + describe('when user is set', () => { beforeEach(() => { - gon.features = { ...gon.features, usageDataApi: false }; + window.gon.current_user_id = 1; }); - it('returns null', () => { - jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true); + describe('when usage data increment unique users is called with feature flag disabled', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: false }; + }); - expect(axios.post).toHaveBeenCalledTimes(0); - expect(Api.trackRedisHllUserEvent(event)).toEqual(null); + it('returns null and does not call the endpoint', () => { + jest.spyOn(axios, 'post'); + + const result = Api.trackRedisHllUserEvent(event); + + expect(result).toEqual(null); + expect(axios.post).toHaveBeenCalledTimes(0); + }); + }); + + describe('when usage data increment unique users is called', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: true }; + }); + + it('resolves the Promise', () => { + jest.spyOn(axios, 'post'); + mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); + + return Api.trackRedisHllUserEvent(event).then(({ data }) => { + expect(data).toEqual(true); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers }); + }); + }); }); }); - describe('when usage data increment unique users is called', () => { + describe('when user is not set and feature flag enabled', () => { beforeEach(() => { gon.features = { ...gon.features, usageDataApi: true }; }); - it('resolves the Promise', () => { + it('returns null and does not call the endpoint', () => { jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); - return Api.trackRedisHllUserEvent(event).then(({ data }) => { - expect(data).toEqual(true); - expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers }); - }); + const result = Api.trackRedisHllUserEvent(event); + + expect(result).toEqual(null); + expect(axios.post).toHaveBeenCalledTimes(0); }); }); }); diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js index 03a28ce8001..cb71edd1238 100644 --- a/spec/frontend/batch_comments/components/preview_item_spec.js +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -104,7 +104,7 @@ describe('Batch comments draft preview item component', () => { notes: [ { author: { - name: 'Author Name', + name: "Author 'Nick' Name", }, }, ], @@ -114,7 +114,7 @@ describe('Batch comments draft preview item component', () => { it('renders title', () => { expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain( - "Author Name's thread", + "Author 'Nick' Name's thread", ); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index da19265ce82..b0e9e5dd00b 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -139,9 +139,14 @@ describe('Batch comments store actions', () => { it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', (done) => { const commit = jest.fn(); + const dispatch = jest.fn(); const context = { getters, commit, + dispatch, + state: { + drafts: [{ line_code: '123' }, { line_code: null, discussion_id: '1' }], + }, }; res = { id: 1 }; mock.onAny().reply(200, res); @@ -150,6 +155,7 @@ describe('Batch comments store actions', () => { .fetchDrafts(context) .then(() => { expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); + expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true }); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/behaviors/shortcuts/keybindings_spec.js b/spec/frontend/behaviors/shortcuts/keybindings_spec.js index 53ce06e78c6..3ad44a16ae1 100644 --- a/spec/frontend/behaviors/shortcuts/keybindings_spec.js +++ b/spec/frontend/behaviors/shortcuts/keybindings_spec.js @@ -5,6 +5,7 @@ import { getCustomizations, keybindingGroups, TOGGLE_PERFORMANCE_BAR, + HIDE_APPEARING_CONTENT, LOCAL_STORAGE_KEY, WEB_IDE_COMMIT, } from '~/behaviors/shortcuts/keybindings'; @@ -95,4 +96,14 @@ describe('~/behaviors/shortcuts/keybindings', () => { expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']); }); }); + + describe('when tooltips or popovers are visible', () => { + beforeEach(() => { + setupCustomizations(); + }); + + it('returns the default keybinding for the command', () => { + expect(keysFor(HIDE_APPEARING_CONTENT)).toEqual(['esc']); + }); + }); }); diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js new file mode 100644 index 00000000000..09633dc5d5d --- /dev/null +++ b/spec/frontend/blob/components/table_contents_spec.js @@ -0,0 +1,67 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import TableContents from '~/blob/components/table_contents.vue'; + +let wrapper; + +function createComponent() { + wrapper = shallowMount(TableContents); +} + +async function setLoaded(loaded) { + document.querySelector('.blob-viewer').setAttribute('data-loaded', loaded); + + await nextTick(); +} + +describe('Markdown table of contents component', () => { + beforeEach(() => { + setFixtures(` + <div class="blob-viewer" data-type="rich" data-loaded="false"> + <h1><a href="#1"></a>Hello</h1> + <h2><a href="#2"></a>World</h2> + <h3><a href="#3"></a>Testing</h3> + <h2><a href="#4"></a>GitLab</h2> + </div> + `); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('not loaded', () => { + it('does not populate dropdown', () => { + createComponent(); + + expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(false); + }); + }); + + describe('loaded', () => { + it('populates dropdown', async () => { + createComponent(); + + await setLoaded(true); + + const dropdownItems = wrapper.findAllComponents(GlDropdownItem); + + expect(dropdownItems.exists()).toBe(true); + expect(dropdownItems.length).toBe(4); + }); + + it('sets padding for dropdown items', async () => { + createComponent(); + + await setLoaded(true); + + const dropdownLinks = wrapper.findAll('[data-testid="tableContentsLink"]'); + + expect(dropdownLinks.at(0).element.style.paddingLeft).toBe('0px'); + expect(dropdownLinks.at(1).element.style.paddingLeft).toBe('8px'); + expect(dropdownLinks.at(2).element.style.paddingLeft).toBe('16px'); + expect(dropdownLinks.at(3).element.style.paddingLeft).toBe('8px'); + }); + }); +}); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 36043b09636..15ea5d4eec4 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,4 +1,4 @@ -import { GlLabel } from '@gitlab/ui'; +import { GlLabel, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { range } from 'lodash'; import Vuex from 'vuex'; @@ -63,6 +63,7 @@ describe('Board card component', () => { }, stubs: { GlLabel: true, + GlLoadingIcon: true, }, mocks: { $apollo: { @@ -121,6 +122,10 @@ describe('Board card component', () => { expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); }); + it('does not render loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + }); + describe('blocked', () => { it('renders blocked icon if issue is blocked', async () => { createWrapper({ @@ -399,4 +404,17 @@ describe('Board card component', () => { }); }); }); + + describe('loading', () => { + it('renders loading icon', async () => { + createWrapper({ + item: { + ...issue, + isLoading: true, + }, + }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index bf39c3f3e42..76629c96f22 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -80,6 +80,7 @@ const createComponent = ({ rootPath: '/', weightFeatureAvailable: false, boardWeight: null, + canAdminList: true, }, stubs: { BoardCard, @@ -181,12 +182,6 @@ describe('Board list component', () => { }); }); - it('loads more issues after scrolling', () => { - wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - - expect(actions.fetchItemsForList).toHaveBeenCalled(); - }); - it('does not load issues if already loading', () => { wrapper = createComponent({ state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js index 0feb1411003..289905a1948 100644 --- a/spec/frontend/boards/boards_util_spec.js +++ b/spec/frontend/boards/boards_util_spec.js @@ -1,17 +1,103 @@ -import { transformNotFilters } from '~/boards/boards_util'; +import { filterVariables } from '~/boards/boards_util'; -describe('transformNotFilters', () => { - const filters = { - 'not[labelName]': ['label'], - 'not[assigneeUsername]': 'assignee', - }; - - it('formats not filters, transforms epicId to fullEpicId', () => { - const result = transformNotFilters(filters); - - expect(result).toEqual({ - labelName: ['label'], - assigneeUsername: 'assignee', +describe('filterVariables', () => { + it.each([ + [ + 'correctly processes array filter values', + { + filters: { + 'not[filterA]': ['val1', 'val2'], + }, + expected: { + not: { + filterA: ['val1', 'val2'], + }, + }, + }, + ], + [ + "renames a filter if 'remap' method is available", + { + filters: { + filterD: 'some value', + }, + expected: { + filterA: 'some value', + not: {}, + }, + }, + ], + [ + 'correctly processes a negated filter that supports negation', + { + filters: { + 'not[filterA]': 'some value 1', + 'not[filterB]': 'some value 2', + }, + expected: { + not: { + filterA: 'some value 1', + }, + }, + }, + ], + [ + 'correctly removes an unsupported filter depending on issuableType', + { + issuableType: 'epic', + filters: { + filterA: 'some value 1', + filterE: 'some value 2', + }, + expected: { + filterE: 'some value 2', + not: {}, + }, + }, + ], + [ + 'applies a transform when the filter value needs to be modified', + { + filters: { + filterC: 'abc', + 'not[filterC]': 'def', + }, + expected: { + filterC: 'ABC', + not: { + filterC: 'DEF', + }, + }, + }, + ], + ])('%s', (_, { filters, issuableType = 'issue', expected }) => { + const result = filterVariables({ + filters, + issuableType, + filterInfo: { + filterA: { + negatedSupport: true, + }, + filterB: { + negatedSupport: false, + }, + filterC: { + negatedSupport: true, + transform: (val) => val.toUpperCase(), + }, + filterD: { + remap: () => 'filterA', + }, + filterE: { + negatedSupport: true, + }, + }, + filterFields: { + issue: ['filterA', 'filterB', 'filterC', 'filterD'], + epic: ['filterE'], + }, }); + + expect(result).toEqual(expected); }); }); diff --git a/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap b/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap index c000f300e4d..3fb0706fd10 100644 --- a/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap +++ b/spec/frontend/boards/components/__snapshots__/board_blocked_icon_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`BoardBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = ` -"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issue-blocked-icon\\" aria-hidden=\\"true\\" class=\\"issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-icon s16\\" id=\\"blocked-icon-uniqueId\\"> +"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issue-blocked-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-icon s16\\" id=\\"blocked-icon-uniqueId\\"> <use href=\\"#issue-block\\"></use> </svg> <div class=\\"gl-popover\\"> diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index ceafa6ead94..9a9ce7b8dc1 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -1,5 +1,6 @@ import { GlLabel } from '@gitlab/ui'; -import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import BoardCard from '~/boards/components/board_card.vue'; @@ -12,8 +13,7 @@ describe('Board card', () => { let store; let mockActions; - const localVue = createLocalVue(); - localVue.use(Vuex); + Vue.use(Vuex); const createStore = ({ initialState = {} } = {}) => { mockActions = { @@ -41,14 +41,14 @@ describe('Board card', () => { provide = {}, mountFn = shallowMount, stubs = { BoardCardInner }, + item = mockIssue, } = {}) => { wrapper = mountFn(BoardCard, { - localVue, stubs, store, propsData: { list: mockLabelList, - item: mockIssue, + item, disabled: false, index: 0, ...propsData, @@ -72,6 +72,10 @@ describe('Board card', () => { await wrapper.vm.$nextTick(); }; + beforeEach(() => { + window.gon = { features: {} }; + }); + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -140,6 +144,10 @@ describe('Board card', () => { }); describe('when using multi-select', () => { + beforeEach(() => { + window.gon = { features: { boardMultiSelect: true } }; + }); + it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => { await multiSelectCard(); @@ -151,4 +159,24 @@ describe('Board card', () => { }); }); }); + + describe('when card is loading', () => { + it('card is disabled and user cannot drag', () => { + createStore(); + mountComponent({ item: { ...mockIssue, isLoading: true } }); + + expect(wrapper.classes()).toContain('is-disabled'); + expect(wrapper.classes()).not.toContain('user-can-drag'); + }); + }); + + describe('when card is not loading', () => { + it('user can drag', () => { + createStore(); + mountComponent(); + + expect(wrapper.classes()).not.toContain('is-disabled'); + expect(wrapper.classes()).toContain('user-can-drag'); + }); + }); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 01c99a02db2..10d739c65f5 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -1,13 +1,13 @@ import { GlDrawer } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; +import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import { stubComponent } from 'helpers/stub_component'; import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; -import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; -import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; +import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; @@ -68,6 +68,9 @@ describe('BoardContentSidebar', () => { iterations: { loading: false, }, + attributesList: { + loading: false, + }, }, }, }, @@ -84,38 +87,41 @@ describe('BoardContentSidebar', () => { }); it('confirms we render GlDrawer', () => { - expect(wrapper.find(GlDrawer).exists()).toBe(true); + expect(wrapper.findComponent(GlDrawer).exists()).toBe(true); }); it('does not render GlDrawer when isSidebarOpen is false', () => { createStore({ mockGetters: { isSidebarOpen: () => false } }); createComponent(); - expect(wrapper.find(GlDrawer).exists()).toBe(false); + expect(wrapper.findComponent(GlDrawer).exists()).toBe(false); }); it('applies an open attribute', () => { - expect(wrapper.find(GlDrawer).props('open')).toBe(true); + expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true); }); it('renders BoardSidebarLabelsSelect', () => { - expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true); + expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true); }); it('renders BoardSidebarTitle', () => { - expect(wrapper.find(BoardSidebarTitle).exists()).toBe(true); + expect(wrapper.findComponent(BoardSidebarTitle).exists()).toBe(true); }); - it('renders BoardSidebarDueDate', () => { - expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true); + it('renders SidebarDateWidget', () => { + expect(wrapper.findComponent(SidebarDateWidget).exists()).toBe(true); }); it('renders BoardSidebarSubscription', () => { - expect(wrapper.find(SidebarSubscriptionsWidget).exists()).toBe(true); + expect(wrapper.findComponent(SidebarSubscriptionsWidget).exists()).toBe(true); }); - it('renders BoardSidebarMilestoneSelect', () => { - expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true); + it('renders SidebarDropdownWidget for milestones', () => { + expect(wrapper.findComponent(SidebarDropdownWidget).exists()).toBe(true); + expect(wrapper.findComponent(SidebarDropdownWidget).props('issuableAttribute')).toEqual( + 'milestone', + ); }); describe('when we emit close', () => { @@ -128,7 +134,7 @@ describe('BoardContentSidebar', () => { }); it('calls toggleBoardItem with correct parameters', async () => { - wrapper.find(GlDrawer).vm.$emit('close'); + wrapper.findComponent(GlDrawer).vm.$emit('close'); expect(toggleBoardItem).toHaveBeenCalledTimes(1); expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index e27badca9de..6ac5d16e5a3 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -105,9 +105,9 @@ describe('BoardFilteredSearch', () => { beforeEach(() => { store = createStore(); - jest.spyOn(store, 'dispatch'); - createComponent(); + + jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(); }); it('sets the url params to the correct results', async () => { diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 24fcdd528d5..80d740458dc 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -9,14 +9,12 @@ import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql' import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql'; import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql'; import { createStore } from '~/boards/stores'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), stripFinalUrlSegment: jest.requireActual('~/lib/utils/url_utility').stripFinalUrlSegment, })); -jest.mock('~/flash'); const currentBoard = { id: 1, @@ -194,9 +192,11 @@ describe('BoardForm', () => { expect(visitUrl).toHaveBeenCalledWith('test-path'); }); - it('shows an error flash if GraphQL mutation fails', async () => { + it('shows a GlAlert if GraphQL mutation fails', async () => { mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); createComponent({ canAdminBoard: true, currentPage: formType.new }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); + fillForm(); await waitForPromises(); @@ -205,7 +205,7 @@ describe('BoardForm', () => { await waitForPromises(); expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); }); @@ -290,9 +290,11 @@ describe('BoardForm', () => { expect(visitUrl).toHaveBeenCalledWith('test-path?group_by=epic'); }); - it('shows an error flash if GraphQL mutation fails', async () => { + it('shows a GlAlert if GraphQL mutation fails', async () => { mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); createComponent({ canAdminBoard: true, currentPage: formType.edit }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); + findInput().trigger('keyup.enter', { metaKey: true }); await waitForPromises(); @@ -301,7 +303,7 @@ describe('BoardForm', () => { await waitForPromises(); expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); @@ -335,9 +337,11 @@ describe('BoardForm', () => { expect(visitUrl).toHaveBeenCalledWith('root'); }); - it('shows an error flash if GraphQL mutation fails', async () => { + it('dispatches `setError` action when GraphQL mutation fails', async () => { mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); createComponent({ canAdminBoard: true, currentPage: formType.delete }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); + findModal().vm.$emit('primary'); await waitForPromises(); @@ -346,7 +350,7 @@ describe('BoardForm', () => { await waitForPromises(); expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_deprecated_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js index fdc7cd2b1d4..db79e67fe78 100644 --- a/spec/frontend/boards/components/board_list_header_deprecated_spec.js +++ b/spec/frontend/boards/components/board_list_header_deprecated_spec.js @@ -31,6 +31,7 @@ describe('Board List Header Component', () => { listType = ListType.backlog, collapsed = false, withLocalStorage = true, + currentUserId = 1, } = {}) => { const boardId = '1'; @@ -62,6 +63,7 @@ describe('Board List Header Component', () => { }, provide: { boardId, + currentUserId, }, }); }; @@ -100,10 +102,12 @@ describe('Board List Header Component', () => { }); }); - it('does render when logged out', () => { - createComponent(); + it('does not render when logged out', () => { + createComponent({ + currentUserId: null, + }); - expect(findAddIssueButton().exists()).toBe(true); + expect(findAddIssueButton().exists()).toBe(false); }); }); @@ -143,7 +147,6 @@ describe('Board List Header Component', () => { it("when logged in it calls list update and doesn't set localStorage", () => { jest.spyOn(List.prototype, 'update'); - window.gon.current_user_id = 1; createComponent({ withLocalStorage: false }); @@ -158,7 +161,7 @@ describe('Board List Header Component', () => { it("when logged out it doesn't call list update and sets localStorage", () => { jest.spyOn(List.prototype, 'update'); - createComponent(); + createComponent({ currentUserId: null }); findCaret().vm.$emit('click'); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index d2dfb4148b3..0abb00e0fa5 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -28,7 +28,7 @@ describe('Board List Header Component', () => { listType = ListType.backlog, collapsed = false, withLocalStorage = true, - currentUserId = null, + currentUserId = 1, } = {}) => { const boardId = '1'; @@ -109,10 +109,12 @@ describe('Board List Header Component', () => { }); }); - it('does render when logged out', () => { - createComponent(); + it('does not render when logged out', () => { + createComponent({ + currentUserId: null, + }); - expect(findAddIssueButton().exists()).toBe(true); + expect(findAddIssueButton().exists()).toBe(false); }); }); @@ -153,7 +155,9 @@ describe('Board List Header Component', () => { }); it("when logged out it doesn't call list update and sets localStorage", async () => { - createComponent(); + createComponent({ + currentUserId: null, + }); findCaret().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js deleted file mode 100644 index 8fd178a0856..00000000000 --- a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js +++ /dev/null @@ -1,137 +0,0 @@ -import { GlDatepicker } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; -import { createStore } from '~/boards/stores'; -import createFlash from '~/flash'; - -const TEST_DUE_DATE = '2020-02-20'; -const TEST_FORMATTED_DUE_DATE = 'Feb 20, 2020'; -const TEST_PARSED_DATE = new Date(2020, 1, 20); -const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, dueDate: null, referencePath: 'h/b#2' }; - -jest.mock('~/flash'); - -describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { - let wrapper; - let store; - - afterEach(() => { - wrapper.destroy(); - store = null; - wrapper = null; - }); - - const createWrapper = ({ dueDate = null } = {}) => { - store = createStore(); - store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } }; - store.state.activeId = TEST_ISSUE.id; - - wrapper = shallowMount(BoardSidebarDueDate, { - store, - provide: { - canUpdate: true, - }, - stubs: { - 'board-editable-item': BoardEditableItem, - }, - }); - }; - - const findDatePicker = () => wrapper.find(GlDatepicker); - const findResetButton = () => wrapper.find('[data-testid="reset-button"]'); - const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); - - it('renders "None" when no due date is set', () => { - createWrapper(); - - expect(findCollapsed().text()).toBe('None'); - expect(findResetButton().exists()).toBe(false); - }); - - it('renders formatted due date with reset button when set', () => { - createWrapper({ dueDate: TEST_DUE_DATE }); - - expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); - expect(findResetButton().exists()).toBe(true); - }); - - describe('when due date is submitted', () => { - beforeEach(async () => { - createWrapper(); - - jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].dueDate = TEST_DUE_DATE; - }); - findDatePicker().vm.$emit('input', TEST_PARSED_DATE); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders formatted due date with reset button', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); - expect(findResetButton().exists()).toBe(true); - }); - - it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalledWith({ - dueDate: TEST_DUE_DATE, - projectPath: 'h/b', - }); - }); - }); - - describe('when due date is cleared', () => { - beforeEach(async () => { - createWrapper(); - - jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].dueDate = null; - }); - findDatePicker().vm.$emit('clear'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders "None"', () => { - expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled(); - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toBe('None'); - }); - }); - - describe('when due date is resetted', () => { - beforeEach(async () => { - createWrapper({ dueDate: TEST_DUE_DATE }); - - jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].dueDate = null; - }); - findResetButton().vm.$emit('click'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders "None"', () => { - expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled(); - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toBe('None'); - }); - }); - - describe('when the mutation fails', () => { - beforeEach(async () => { - createWrapper({ dueDate: TEST_DUE_DATE }); - - jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - throw new Error(['failed mutation']); - }); - findDatePicker().vm.$emit('input', 'Invalid date'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders former issue due date', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); - expect(createFlash).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index ad682774ee6..8992a5780f3 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -9,11 +9,8 @@ import { import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import { createStore } from '~/boards/stores'; -import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -jest.mock('~/flash'); - const TEST_LABELS_PAYLOAD = TEST_LABELS.map((label) => ({ ...label, set: true })); const TEST_LABELS_TITLES = TEST_LABELS.map((label) => label.title); @@ -154,6 +151,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => { throw new Error(['failed mutation']); }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]); await wrapper.vm.$nextTick(); }); @@ -161,7 +159,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { it('collapses sidebar and renders former issue weight', () => { expect(findCollapsed().isVisible()).toBe(true); expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js deleted file mode 100644 index 8706424a296..00000000000 --- a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js +++ /dev/null @@ -1,178 +0,0 @@ -import { GlLoadingIcon, GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { mockMilestone as TEST_MILESTONE } from 'jest/boards/mock_data'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; -import { createStore } from '~/boards/stores'; -import createFlash from '~/flash'; - -const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, referencePath: 'h/b#2' }; - -jest.mock('~/flash'); - -describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => { - let wrapper; - let store; - - afterEach(() => { - wrapper.destroy(); - store = null; - wrapper = null; - }); - - const createWrapper = ({ milestone = null, loading = false } = {}) => { - store = createStore(); - store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } }; - store.state.activeId = TEST_ISSUE.id; - - wrapper = shallowMount(BoardSidebarMilestoneSelect, { - store, - provide: { - canUpdate: true, - }, - data: () => ({ - milestones: [TEST_MILESTONE], - }), - stubs: { - 'board-editable-item': BoardEditableItem, - }, - mocks: { - $apollo: { - loading, - }, - }, - }); - }; - - const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); - const findLoader = () => wrapper.find(GlLoadingIcon); - const findDropdown = () => wrapper.find(GlDropdown); - const findBoardEditableItem = () => wrapper.find(BoardEditableItem); - const findDropdownItem = () => wrapper.find('[data-testid="milestone-item"]'); - const findUnsetMilestoneItem = () => wrapper.find('[data-testid="no-milestone-item"]'); - const findNoMilestonesFoundItem = () => wrapper.find('[data-testid="no-milestones-found"]'); - - describe('when not editing', () => { - it('opens the milestone dropdown on clicking edit', async () => { - createWrapper(); - wrapper.vm.$refs.dropdown.show = jest.fn(); - - await findBoardEditableItem().vm.$emit('open'); - - expect(wrapper.vm.$refs.dropdown.show).toHaveBeenCalledTimes(1); - }); - }); - - describe('when editing', () => { - beforeEach(() => { - createWrapper(); - jest.spyOn(wrapper.vm.$refs.sidebarItem, 'collapse'); - }); - - it('collapses BoardEditableItem on clicking edit', async () => { - await findBoardEditableItem().vm.$emit('close'); - - expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1); - }); - - it('collapses BoardEditableItem on hiding dropdown', async () => { - await findDropdown().vm.$emit('hide'); - - expect(wrapper.vm.$refs.sidebarItem.collapse).toHaveBeenCalledTimes(1); - }); - }); - - it('renders "None" when no milestone is selected', () => { - createWrapper(); - - expect(findCollapsed().text()).toBe('None'); - }); - - it('renders milestone title when set', () => { - createWrapper({ milestone: TEST_MILESTONE }); - - expect(findCollapsed().text()).toContain(TEST_MILESTONE.title); - }); - - it('shows loader while Apollo is loading', async () => { - createWrapper({ milestone: TEST_MILESTONE, loading: true }); - - expect(findLoader().exists()).toBe(true); - }); - - it('shows message when error or no milestones found', async () => { - createWrapper(); - - await wrapper.setData({ milestones: [] }); - - expect(findNoMilestonesFoundItem().text()).toBe('No milestones found'); - }); - - describe('when milestone is selected', () => { - beforeEach(async () => { - createWrapper(); - - jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].milestone = TEST_MILESTONE; - }); - findDropdownItem().vm.$emit('click'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders selected milestone', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toContain(TEST_MILESTONE.title); - }); - - it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueMilestone).toHaveBeenCalledWith({ - milestoneId: TEST_MILESTONE.id, - projectPath: 'h/b', - }); - }); - }); - - describe('when milestone is set to "None"', () => { - beforeEach(async () => { - createWrapper({ milestone: TEST_MILESTONE }); - - jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - store.state.boardItems[TEST_ISSUE.id].milestone = null; - }); - findUnsetMilestoneItem().vm.$emit('click'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders "None"', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toBe('None'); - }); - - it('commits change to the server', () => { - expect(wrapper.vm.setActiveIssueMilestone).toHaveBeenCalledWith({ - milestoneId: null, - projectPath: 'h/b', - }); - }); - }); - - describe('when the mutation fails', () => { - const testMilestone = { id: '1', title: 'Former milestone' }; - - beforeEach(async () => { - createWrapper({ milestone: testMilestone }); - - jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - throw new Error(['failed mutation']); - }); - findDropdownItem().vm.$emit('click'); - await wrapper.vm.$nextTick(); - }); - - it('collapses sidebar and renders former milestone', () => { - expect(findCollapsed().isVisible()).toBe(true); - expect(findCollapsed().text()).toContain(testMilestone.title); - expect(createFlash).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js index 7976e73ff2f..8847f626c1f 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js @@ -5,11 +5,8 @@ import Vuex from 'vuex'; import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; import { createStore } from '~/boards/stores'; import * as types from '~/boards/stores/mutation_types'; -import createFlash from '~/flash'; import { mockActiveIssue } from '../../mock_data'; -jest.mock('~/flash.js'); - Vue.use(Vuex); describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => { @@ -153,13 +150,15 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => { throw new Error(); }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); findToggle().trigger('click'); await wrapper.vm.$nextTick(); - expect(createFlash).toHaveBeenNthCalledWith(1, { - message: wrapper.vm.$options.i18n.updateSubscribedErrorMessage, - }); + expect(wrapper.vm.setError).toHaveBeenCalled(); + expect(wrapper.vm.setError.mock.calls[0][0].message).toBe( + wrapper.vm.$options.i18n.updateSubscribedErrorMessage, + ); }); }); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js index 03924bfa8d3..74441e147cf 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js @@ -26,6 +26,7 @@ describe('BoardSidebarTimeTracker', () => { store = createStore(); store.state.boardItems = { 1: { + iid: 1, timeEstimate: 3600, totalTimeSpent: 1800, humanTimeEstimate: '1h', @@ -46,12 +47,16 @@ describe('BoardSidebarTimeTracker', () => { createComponent({ provide: { timeTrackingLimitToHours } }); expect(wrapper.find(IssuableTimeTracker).props()).toEqual({ - timeEstimate: 3600, - timeSpent: 1800, - humanTimeEstimate: '1h', - humanTimeSpent: '30min', limitToHours: timeTrackingLimitToHours, showCollapsed: false, + issuableIid: '1', + fullPath: '', + initialTimeTracking: { + timeEstimate: 3600, + totalTimeSpent: 1800, + humanTimeEstimate: '1h', + humanTotalTimeSpent: '30min', + }, }); }, ); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js index c8ccd4c88a5..4a8eda298f2 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js @@ -3,7 +3,6 @@ import { shallowMount } from '@vue/test-utils'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { createStore } from '~/boards/stores'; -import createFlash from '~/flash'; const TEST_TITLE = 'New item title'; const TEST_ISSUE_A = { @@ -19,8 +18,6 @@ const TEST_ISSUE_B = { referencePath: 'h/b#2', }; -jest.mock('~/flash'); - describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { let wrapper; let store; @@ -168,6 +165,7 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => { throw new Error(['failed mutation']); }); + jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {}); findFormInput().vm.$emit('input', 'Invalid title'); findForm().vm.$emit('submit', { preventDefault: () => {} }); await wrapper.vm.$nextTick(); @@ -176,7 +174,7 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { it('collapses sidebar and renders former item title', () => { expect(findCollapsed().isVisible()).toBe(true); expect(findTitle().text()).toContain(TEST_ISSUE_B.title); - expect(createFlash).toHaveBeenCalled(); + expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js index 37f519ef5b9..4494de43083 100644 --- a/spec/frontend/boards/project_select_deprecated_spec.js +++ b/spec/frontend/boards/project_select_deprecated_spec.js @@ -5,7 +5,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import ProjectSelect from '~/boards/components/project_select_deprecated.vue'; import { ListType } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import httpStatus from '~/lib/utils/http_status'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; @@ -237,8 +237,10 @@ describe('ProjectSelect component', () => { await searchForProject('foobar'); - expect(flash).toHaveBeenCalledTimes(1); - expect(flash).toHaveBeenCalledWith('Something went wrong while fetching projects'); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while fetching projects', + }); }); describe('with non-empty search result', () => { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 09343b5704f..b28412f2127 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -15,6 +15,7 @@ import { formatIssueInput, formatIssue, getMoveData, + updateListPosition, } from '~/boards/boards_util'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; @@ -29,13 +30,13 @@ import { mockIssue2, rawIssue, mockIssues, - mockMilestone, labels, mockActiveIssue, mockGroupProjects, mockMoveIssueParams, mockMoveState, mockMoveData, + mockList, } from '../mock_data'; jest.mock('~/flash'); @@ -70,27 +71,28 @@ describe('setFilters', () => { [ 'with correct filters as payload', { - filters: { labelName: 'label' }, - updatedFilters: { labelName: 'label', not: {} }, + filters: { labelName: 'label', foobar: 'not-a-filter', search: 'quick brown fox' }, + filterVariables: { labelName: 'label', search: 'quick brown fox', not: {} }, }, ], [ - 'and updates assigneeWildcardId', + "and use 'assigneeWildcardId' as filter variable for 'assigneId' param", { filters: { assigneeId: 'None' }, - updatedFilters: { assigneeWildcardId: 'NONE', not: {} }, + filterVariables: { assigneeWildcardId: 'NONE', not: {} }, }, ], - ])('should commit mutation SET_FILTERS %s', (_, { filters, updatedFilters }) => { + ])('should commit mutation SET_FILTERS %s', (_, { filters, filterVariables }) => { const state = { filters: {}, + issuableType: issuableTypes.issue, }; testAction( actions.setFilters, filters, state, - [{ type: types.SET_FILTERS, payload: updatedFilters }], + [{ type: types.SET_FILTERS, payload: filterVariables }], [], ); }); @@ -373,6 +375,24 @@ describe('createIssueList', () => { }); }); +describe('addList', () => { + const getters = { + getListByTitle: jest.fn().mockReturnValue(mockList), + }; + + it('should commit RECEIVE_ADD_LIST_SUCCESS mutation and dispatch fetchItemsForList action', () => { + testAction({ + action: actions.addList, + payload: mockLists[1], + state: { ...getters }, + expectedMutations: [ + { type: types.RECEIVE_ADD_LIST_SUCCESS, payload: updateListPosition(mockLists[1]) }, + ], + expectedActions: [{ type: 'fetchItemsForList', payload: { listId: mockList.id } }], + }); + }); +}); + describe('fetchLabels', () => { it('should commit mutation RECEIVE_LABELS_SUCCESS on success', async () => { const queryResponse = { @@ -520,7 +540,8 @@ describe('toggleListCollapsed', () => { describe('removeList', () => { let state; - const list = mockLists[0]; + let getters; + const list = mockLists[1]; const listId = list.id; const mutationVariables = { mutation: destroyBoardListMutation, @@ -534,6 +555,9 @@ describe('removeList', () => { boardLists: mockListsById, issuableType: issuableTypes.issue, }; + getters = { + getListByTitle: jest.fn().mockReturnValue(mockList), + }; }); afterEach(() => { @@ -543,13 +567,15 @@ describe('removeList', () => { it('optimistically deletes the list', () => { const commit = jest.fn(); - actions.removeList({ commit, state }, listId); + actions.removeList({ commit, state, getters, dispatch: () => {} }, listId); expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]); }); it('keeps the updated list if remove succeeds', async () => { const commit = jest.fn(); + const dispatch = jest.fn(); + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { destroyBoardList: { @@ -558,17 +584,18 @@ describe('removeList', () => { }, }); - await actions.removeList({ commit, state }, listId); + await actions.removeList({ commit, state, getters, dispatch }, listId); expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]); + expect(dispatch.mock.calls).toEqual([['fetchItemsForList', { listId: mockList.id }]]); }); it('restores the list if update fails', async () => { const commit = jest.fn(); jest.spyOn(gqlClient, 'mutate').mockResolvedValue(Promise.reject()); - await actions.removeList({ commit, state }, listId); + await actions.removeList({ commit, state, getters, dispatch: () => {} }, listId); expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); expect(commit.mock.calls).toEqual([ @@ -587,7 +614,7 @@ describe('removeList', () => { }, }); - await actions.removeList({ commit, state }, listId); + await actions.removeList({ commit, state, getters, dispatch: () => {} }, listId); expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); expect(commit.mock.calls).toEqual([ @@ -649,6 +676,10 @@ describe('fetchItemsForList', () => { state, [ { + type: types.RESET_ITEMS_FOR_LIST, + payload: listId, + }, + { type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, }, @@ -671,6 +702,10 @@ describe('fetchItemsForList', () => { state, [ { + type: types.RESET_ITEMS_FOR_LIST, + payload: listId, + }, + { type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, }, @@ -1114,6 +1149,7 @@ describe('addListItem', () => { listId: mockLists[0].id, itemId: mockIssue.id, atIndex: 0, + inProgress: false, }, }, { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, @@ -1244,8 +1280,9 @@ describe('addListNewIssue', () => { type: 'addListItem', payload: { list: fakeList, - item: formatIssue({ ...mockIssue, id: 'tmp' }), + item: formatIssue({ ...mockIssue, id: 'tmp', isLoading: true }), position: 0, + inProgress: true, }, }, { type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } }, @@ -1286,8 +1323,9 @@ describe('addListNewIssue', () => { type: 'addListItem', payload: { list: fakeList, - item: formatIssue({ ...mockIssue, id: 'tmp' }), + item: formatIssue({ ...mockIssue, id: 'tmp', isLoading: true }), position: 0, + inProgress: true, }, }, { type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } }, @@ -1348,57 +1386,6 @@ describe('setActiveIssueLabels', () => { }); }); -describe('setActiveIssueDueDate', () => { - const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeBoardItem: mockIssue }; - const testDueDate = '2020-02-20'; - const input = { - dueDate: testDueDate, - projectPath: 'h/b', - }; - - it('should commit due date after setting the issue', (done) => { - jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ - data: { - updateIssue: { - issue: { - dueDate: testDueDate, - }, - errors: [], - }, - }, - }); - - const payload = { - itemId: getters.activeBoardItem.id, - prop: 'dueDate', - value: testDueDate, - }; - - testAction( - actions.setActiveIssueDueDate, - input, - { ...state, ...getters }, - [ - { - type: types.UPDATE_BOARD_ITEM_BY_ID, - payload, - }, - ], - [], - done, - ); - }); - - it('throws error if fails', async () => { - jest - .spyOn(gqlClient, 'mutate') - .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); - - await expect(actions.setActiveIssueDueDate({ getters }, input)).rejects.toThrow(Error); - }); -}); - describe('setActiveItemSubscribed', () => { const state = { boardItems: { @@ -1456,60 +1443,6 @@ describe('setActiveItemSubscribed', () => { }); }); -describe('setActiveIssueMilestone', () => { - const state = { boardItems: { [mockIssue.id]: mockIssue } }; - const getters = { activeBoardItem: mockIssue }; - const testMilestone = { - ...mockMilestone, - id: 'gid://gitlab/Milestone/1', - }; - const input = { - milestoneId: testMilestone.id, - projectPath: 'h/b', - }; - - it('should commit milestone after setting the issue', (done) => { - jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ - data: { - updateIssue: { - issue: { - milestone: testMilestone, - }, - errors: [], - }, - }, - }); - - const payload = { - itemId: getters.activeBoardItem.id, - prop: 'milestone', - value: testMilestone, - }; - - testAction( - actions.setActiveIssueMilestone, - input, - { ...state, ...getters }, - [ - { - type: types.UPDATE_BOARD_ITEM_BY_ID, - payload, - }, - ], - [], - done, - ); - }); - - it('throws error if fails', async () => { - jest - .spyOn(gqlClient, 'mutate') - .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); - - await expect(actions.setActiveIssueMilestone({ getters }, input)).rejects.toThrow(Error); - }); -}); - describe('setActiveItemTitle', () => { const state = { boardItems: { [mockIssue.id]: mockIssue }, diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index d89abcc79ae..5b38f04e77b 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -273,6 +273,53 @@ describe('Board Store Mutations', () => { }); }); + describe('RESET_ITEMS_FOR_LIST', () => { + it('should remove issues from boardItemsByListId state', () => { + const listId = 'gid://gitlab/List/1'; + const boardItemsByListId = { + [listId]: [mockIssue.id], + }; + + state = { + ...state, + boardItemsByListId, + }; + + mutations[types.RESET_ITEMS_FOR_LIST](state, listId); + + expect(state.boardItemsByListId[listId]).toEqual([]); + }); + }); + + describe('REQUEST_ITEMS_FOR_LIST', () => { + const listId = 'gid://gitlab/List/1'; + const boardItemsByListId = { + [listId]: [mockIssue.id], + }; + + it.each` + fetchNext | isLoading | isLoadingMore + ${true} | ${undefined} | ${true} + ${false} | ${true} | ${undefined} + `( + 'sets isLoading to $isLoading and isLoadingMore to $isLoadingMore when fetchNext is $fetchNext', + ({ fetchNext, isLoading, isLoadingMore }) => { + state = { + ...state, + boardItemsByListId, + listsFlags: { + [listId]: {}, + }, + }; + + mutations[types.REQUEST_ITEMS_FOR_LIST](state, { listId, fetchNext }); + + expect(state.listsFlags[listId].isLoading).toBe(isLoading); + expect(state.listsFlags[listId].isLoadingMore).toBe(isLoadingMore); + }, + ); + }); + describe('RECEIVE_ITEMS_FOR_LIST_SUCCESS', () => { it('updates boardItemsByListId and issues on state', () => { const listIssues = { diff --git a/spec/frontend/branches/components/delete_branch_button_spec.js b/spec/frontend/branches/components/delete_branch_button_spec.js new file mode 100644 index 00000000000..acbc83a9bdc --- /dev/null +++ b/spec/frontend/branches/components/delete_branch_button_spec.js @@ -0,0 +1,96 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DeleteBranchButton from '~/branches/components/delete_branch_button.vue'; +import eventHub from '~/branches/event_hub'; + +let wrapper; +let findDeleteButton; + +const createComponent = (props = {}) => { + wrapper = shallowMount(DeleteBranchButton, { + propsData: { + branchName: 'test', + deletePath: '/path/to/branch', + defaultBranchName: 'main', + ...props, + }, + }); +}; + +describe('Delete branch button', () => { + let eventHubSpy; + + beforeEach(() => { + findDeleteButton = () => wrapper.findComponent(GlButton); + eventHubSpy = jest.spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the button with default tooltip, style, and icon', () => { + createComponent(); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'Delete branch', + variant: 'danger', + icon: 'remove', + }); + }); + + it('renders a different tooltip for a protected branch', () => { + createComponent({ isProtectedBranch: true }); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'Delete protected branch', + variant: 'danger', + icon: 'remove', + }); + }); + + it('renders a different protected tooltip when it is both protected and disabled', () => { + createComponent({ isProtectedBranch: true, disabled: true }); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'Only a project maintainer or owner can delete a protected branch', + variant: 'default', + }); + }); + + it('emits the data to eventHub when button is clicked', () => { + createComponent({ merged: true }); + + findDeleteButton().vm.$emit('click'); + + expect(eventHubSpy).toHaveBeenCalledWith('openModal', { + branchName: 'test', + defaultBranchName: 'main', + deletePath: '/path/to/branch', + isProtectedBranch: false, + merged: true, + }); + }); + + describe('#disabled', () => { + it('does not disable the button by default when mounted', () => { + createComponent(); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'Delete branch', + variant: 'danger', + }); + }); + + // Used for unallowed users and for the default branch. + it('disables the button when mounted for a disabled modal', () => { + createComponent({ disabled: true, tooltip: 'The default branch cannot be deleted' }); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: 'The default branch cannot be deleted', + disabled: 'true', + variant: 'default', + }); + }); + }); +}); diff --git a/spec/frontend/branches/components/delete_branch_modal_spec.js b/spec/frontend/branches/components/delete_branch_modal_spec.js new file mode 100644 index 00000000000..0c6111bda9e --- /dev/null +++ b/spec/frontend/branches/components/delete_branch_modal_spec.js @@ -0,0 +1,157 @@ +import { GlButton, GlModal, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import DeleteBranchModal from '~/branches/components/delete_branch_modal.vue'; +import eventHub from '~/branches/event_hub'; + +let wrapper; + +const branchName = 'test_modal'; +const defaultBranchName = 'default'; +const deletePath = '/path/to/branch'; +const merged = false; +const isProtectedBranch = false; + +const createComponent = (data = {}) => { + wrapper = extendedWrapper( + shallowMount(DeleteBranchModal, { + data() { + return { + branchName, + deletePath, + defaultBranchName, + merged, + isProtectedBranch, + ...data, + }; + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + GlButton, + GlFormInput, + GlSprintf, + }, + }), + ); +}; + +const findModal = () => wrapper.findComponent(GlModal); +const findModalMessage = () => wrapper.findByTestId('modal-message'); +const findDeleteButton = () => wrapper.findByTestId('delete-branch-confirmation-button'); +const findCancelButton = () => wrapper.findByTestId('delete-branch-cancel-button'); +const findFormInput = () => wrapper.findComponent(GlFormInput); +const findForm = () => wrapper.find('form'); + +describe('Delete branch modal', () => { + const expectedUnmergedWarning = + 'This branch hasn’t been merged into default. To avoid data loss, consider merging this branch before deleting it.'; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Deleting a regular branch', () => { + const expectedTitle = 'Delete branch. Are you ABSOLUTELY SURE?'; + const expectedWarning = "You're about to permanently delete the branch test_modal."; + const expectedMessage = `${expectedWarning} ${expectedUnmergedWarning}`; + + beforeEach(() => { + createComponent(); + }); + + it('renders the modal correctly', () => { + expect(findModal().props('title')).toBe(expectedTitle); + expect(findModalMessage().text()).toMatchInterpolatedText(expectedMessage); + expect(findCancelButton().text()).toBe('Cancel, keep branch'); + expect(findDeleteButton().text()).toBe('Yes, delete branch'); + expect(findForm().attributes('action')).toBe(deletePath); + }); + + it('submits the form when the delete button is clicked', () => { + const submitFormSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit'); + + findDeleteButton().trigger('click'); + + expect(findForm().attributes('action')).toBe(deletePath); + expect(submitFormSpy).toHaveBeenCalled(); + }); + + it('calls show on the modal when a `openModal` event is received through the event hub', async () => { + const showSpy = jest.spyOn(wrapper.vm.$refs.modal, 'show'); + + eventHub.$emit('openModal', { + isProtectedBranch, + branchName, + defaultBranchName, + deletePath, + merged, + }); + + expect(showSpy).toHaveBeenCalled(); + }); + + it('calls hide on the modal when cancel button is clicked', () => { + const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); + + findCancelButton().trigger('click'); + + expect(closeModalSpy).toHaveBeenCalled(); + }); + }); + + describe('Deleting a protected branch (for owner or maintainer)', () => { + const expectedTitleProtected = 'Delete protected branch. Are you ABSOLUTELY SURE?'; + const expectedWarningProtected = + "You're about to permanently delete the protected branch test_modal."; + const expectedMessageProtected = `${expectedWarningProtected} ${expectedUnmergedWarning}`; + const expectedConfirmationText = + 'Once you confirm and press Yes, delete protected branch, it cannot be undone or recovered. Please type the following to confirm: test_modal'; + + beforeEach(() => { + createComponent({ isProtectedBranch: true }); + }); + + describe('rendering the modal correctly for a protected branch', () => { + it('sets the modal title for a protected branch', () => { + expect(findModal().props('title')).toBe(expectedTitleProtected); + }); + + it('renders the correct text in the modal message', () => { + expect(findModalMessage().text()).toMatchInterpolatedText(expectedMessageProtected); + }); + + it('renders the protected branch name confirmation form with expected text and action', () => { + expect(findForm().text()).toMatchInterpolatedText(expectedConfirmationText); + expect(findForm().attributes('action')).toBe(deletePath); + }); + + it('renders the buttons with the correct button text', () => { + expect(findCancelButton().text()).toBe('Cancel, keep branch'); + expect(findDeleteButton().text()).toBe('Yes, delete protected branch'); + }); + }); + + it('opens with the delete button disabled and enables it when branch name is confirmed', async () => { + expect(findDeleteButton().props('disabled')).toBe(true); + + findFormInput().vm.$emit('input', branchName); + + await waitForPromises(); + + expect(findDeleteButton().props('disabled')).not.toBe(true); + }); + }); + + describe('Deleting a merged branch', () => { + it('does not include the unmerged branch warning when merged is true', () => { + createComponent({ merged: true }); + + expect(findModalMessage().html()).not.toContain(expectedUnmergedWarning); + }); + }); +}); diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index be3640936dc..426e6cae8fb 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -5,7 +5,7 @@ import * as actions from '~/ci_variable_list/store/actions'; import * as types from '~/ci_variable_list/store/mutation_types'; import getInitialState from '~/ci_variable_list/store/state'; import { prepareDataForDisplay, prepareEnvironments } from '~/ci_variable_list/store/utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import mockData from '../services/mock_data'; @@ -240,7 +240,9 @@ describe('CI variable list store actions', () => { mock.onGet(state.endpoint).reply(500); testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }], () => { - expect(createFlash).toHaveBeenCalledWith('There was an error fetching the variables.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the variables.', + }); done(); }); }); @@ -278,9 +280,9 @@ describe('CI variable list store actions', () => { [], [{ type: 'requestEnvironments' }], () => { - expect(createFlash).toHaveBeenCalledWith( - 'There was an error fetching the environments information.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the environments information.', + }); done(); }, ); diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index 6047b404197..e5e336eb3d5 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -62,6 +62,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ aria-hidden="true" class="gl-icon s16 gl-new-dropdown-item-check-icon gl-mt-3 gl-align-self-start" data-testid="dropdown-item-checkbox" + role="img" > <use href="#mobile-issue-close" @@ -117,6 +118,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ aria-hidden="true" class="gl-icon s16 gl-new-dropdown-item-check-icon gl-visibility-hidden gl-mt-3 gl-align-self-start" data-testid="dropdown-item-checkbox" + role="img" > <use href="#mobile-issue-close" diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index db5915cb1eb..511f5fc1d89 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -2,8 +2,6 @@ import { shallowMount, mount } from '@vue/test-utils'; import ApplicationRow from '~/clusters/components/application_row.vue'; import Applications from '~/clusters/components/applications.vue'; import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; -import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue'; -import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue'; import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; import { CLUSTER_TYPE, PROVIDER_TYPE } from '~/clusters/constants'; import eventHub from '~/clusters/event_hub'; @@ -72,9 +70,6 @@ describe('Applications', () => { expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); }); - it('renders a row for Fluentd', () => { - expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); - }); it('renders a row for Cilium', () => { expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); }); @@ -117,10 +112,6 @@ describe('Applications', () => { expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); }); - it('renders a row for Fluentd', () => { - expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); - }); - it('renders a row for Cilium', () => { expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); }); @@ -163,10 +154,6 @@ describe('Applications', () => { expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); }); - it('renders a row for Fluentd', () => { - expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); - }); - it('renders a row for Cilium', () => { expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); }); @@ -185,24 +172,6 @@ describe('Applications', () => { expect(findByTestId('ingressCostWarning').element).toMatchSnapshot(); }); - describe('with nested component', () => { - const propsData = { - applications: { - ingress: { - title: 'Ingress', - status: 'installed', - }, - }, - }; - - beforeEach(() => createShallowComponent(propsData)); - - it('renders IngressModsecuritySettings', () => { - const modsecuritySettings = wrapper.find(IngressModsecuritySettings); - expect(modsecuritySettings.exists()).toBe(true); - }); - }); - describe('when installed', () => { describe('with ip address', () => { it('renders ip address with a clipboard button', () => { @@ -231,7 +200,6 @@ describe('Applications', () => { title: 'Ingress', status: 'installed', externalHostname: 'localhost.localdomain', - modsecurity_enabled: false, }, cert_manager: { title: 'Cert-Manager' }, crossplane: { title: 'Crossplane', stack: '' }, @@ -240,7 +208,6 @@ describe('Applications', () => { jupyter: { title: 'JupyterHub', hostname: '' }, knative: { title: 'Knative', hostname: '' }, elastic_stack: { title: 'Elastic Stack' }, - fluentd: { title: 'Fluentd' }, cilium: { title: 'GitLab Container Network Policies' }, }, }); @@ -534,14 +501,6 @@ describe('Applications', () => { }); }); - describe('Fluentd application', () => { - beforeEach(() => createShallowComponent()); - - it('renders the correct Component', () => { - expect(wrapper.find(FluentdOutputSettings).exists()).toBe(true); - }); - }); - describe('Cilium application', () => { it('shows the correct description', () => { createComponent({ propsData: { ciliumHelpPath: 'cilium-help-path' } }); diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js deleted file mode 100644 index 2c6e5bbd46a..00000000000 --- a/spec/frontend/clusters/components/fluentd_output_settings_spec.js +++ /dev/null @@ -1,186 +0,0 @@ -import { GlAlert, GlDropdown, GlFormCheckbox } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue'; -import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; -import eventHub from '~/clusters/event_hub'; - -const { UPDATING } = APPLICATION_STATUS; - -describe('FluentdOutputSettings', () => { - let wrapper; - - const defaultSettings = { - protocol: 'tcp', - host: '127.0.0.1', - port: 514, - wafLogEnabled: true, - ciliumLogEnabled: false, - }; - const defaultProps = { - status: 'installable', - updateFailed: false, - ...defaultSettings, - }; - - const createComponent = (props = {}) => { - wrapper = shallowMount(FluentdOutputSettings, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - const updateComponentPropsFromEvent = () => { - const { isEditingSettings, ...props } = eventHub.$emit.mock.calls[0][1]; - wrapper.setProps(props); - }; - const findSaveButton = () => wrapper.find({ ref: 'saveBtn' }); - const findCancelButton = () => wrapper.find({ ref: 'cancelBtn' }); - const findProtocolDropdown = () => wrapper.find(GlDropdown); - const findCheckbox = (name) => - wrapper.findAll(GlFormCheckbox).wrappers.find((x) => x.text() === name); - const findHost = () => wrapper.find('#fluentd-host'); - const findPort = () => wrapper.find('#fluentd-port'); - const changeCheckbox = (checkbox) => { - const currentValue = checkbox.attributes('checked')?.toString() === 'true'; - checkbox.vm.$emit('input', !currentValue); - }; - const changeInput = ({ element }, val) => { - element.value = val; - element.dispatchEvent(new Event('input')); - }; - const changePort = (val) => changeInput(findPort(), val); - const changeHost = (val) => changeInput(findHost(), val); - const changeProtocol = (idx) => findProtocolDropdown().vm.$children[idx].$emit('click'); - const toApplicationSettings = ({ wafLogEnabled, ciliumLogEnabled, ...settings }) => ({ - ...settings, - waf_log_enabled: wafLogEnabled, - cilium_log_enabled: ciliumLogEnabled, - }); - - describe('when fluentd is installed', () => { - beforeEach(() => { - createComponent({ status: 'installed' }); - jest.spyOn(eventHub, '$emit'); - }); - - it('does not render save and cancel buttons', () => { - expect(findSaveButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(false); - }); - - describe.each` - desc | changeFn | key | value - ${'when protocol dropdown is triggered'} | ${() => changeProtocol(1)} | ${'protocol'} | ${'udp'} - ${'when host is changed'} | ${() => changeHost('test-host')} | ${'host'} | ${'test-host'} - ${'when port is changed'} | ${() => changePort(123)} | ${'port'} | ${123} - ${'when wafLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send Web Application Firewall Logs'))} | ${'wafLogEnabled'} | ${!defaultSettings.wafLogEnabled} - ${'when ciliumLogEnabled changes'} | ${() => changeCheckbox(findCheckbox('Send Container Network Policies Logs'))} | ${'ciliumLogEnabled'} | ${!defaultSettings.ciliumLogEnabled} - `('$desc', ({ changeFn, key, value }) => { - beforeEach(() => { - changeFn(); - }); - - it('triggers set event to be propagated with the current value', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('setFluentdSettings', { - [key]: value, - isEditingSettings: true, - }); - }); - - describe('when value is updated from store', () => { - beforeEach(() => { - updateComponentPropsFromEvent(); - }); - - it('enables save and cancel buttons', () => { - expect(findSaveButton().exists()).toBe(true); - expect(findSaveButton().attributes().disabled).toBeUndefined(); - expect(findCancelButton().exists()).toBe(true); - expect(findCancelButton().attributes().disabled).toBeUndefined(); - }); - - describe('and the save changes button is clicked', () => { - beforeEach(() => { - eventHub.$emit.mockClear(); - findSaveButton().vm.$emit('click'); - }); - - it('triggers save event and pass current values', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', { - id: FLUENTD, - params: toApplicationSettings({ - ...defaultSettings, - [key]: value, - }), - }); - }); - }); - - describe('and the cancel button is clicked', () => { - beforeEach(() => { - eventHub.$emit.mockClear(); - findCancelButton().vm.$emit('click'); - }); - - it('triggers reset event', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('setFluentdSettings', { - ...defaultSettings, - isEditingSettings: false, - }); - }); - - describe('when value is updated from store', () => { - beforeEach(() => { - updateComponentPropsFromEvent(); - }); - - it('does not render save and cancel buttons', () => { - expect(findSaveButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(false); - }); - }); - }); - }); - }); - - describe(`when fluentd status is ${UPDATING}`, () => { - beforeEach(() => { - createComponent({ installed: true, status: UPDATING }); - }); - - it('renders loading spinner in save button', () => { - expect(findSaveButton().props('loading')).toBe(true); - }); - - it('renders disabled save button', () => { - expect(findSaveButton().props('disabled')).toBe(true); - }); - - it('renders save button with "Saving" label', () => { - expect(findSaveButton().text()).toBe('Saving'); - }); - }); - - describe('when fluentd fails to update', () => { - beforeEach(() => { - createComponent({ updateFailed: true }); - }); - - it('displays a error message', () => { - expect(wrapper.find(GlAlert).exists()).toBe(true); - }); - }); - }); - - describe('when fluentd is not installed', () => { - beforeEach(() => { - createComponent(); - }); - - it('does not render the save button', () => { - expect(findSaveButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js deleted file mode 100644 index f83a350a27c..00000000000 --- a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js +++ /dev/null @@ -1,192 +0,0 @@ -import { GlAlert, GlToggle, GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue'; -import { APPLICATION_STATUS, INGRESS } from '~/clusters/constants'; -import eventHub from '~/clusters/event_hub'; - -const { UPDATING } = APPLICATION_STATUS; - -describe('IngressModsecuritySettings', () => { - let wrapper; - - const defaultProps = { - modsecurity_enabled: false, - status: 'installable', - installed: false, - modsecurity_mode: 'logging', - updateAvailable: false, - }; - - const createComponent = (props = defaultProps) => { - wrapper = shallowMount(IngressModsecuritySettings, { - propsData: { - ingress: { - ...defaultProps, - ...props, - }, - }, - }); - }; - - const findSaveButton = () => - wrapper.find('[data-qa-selector="save_ingress_modsecurity_settings"]'); - const findCancelButton = () => - wrapper.find('[data-qa-selector="cancel_ingress_modsecurity_settings"]'); - const findModSecurityToggle = () => wrapper.find(GlToggle); - const findModSecurityDropdown = () => wrapper.find(GlDropdown); - - describe('when ingress is installed', () => { - beforeEach(() => { - createComponent({ installed: true, status: 'installed' }); - jest.spyOn(eventHub, '$emit'); - }); - - it('does not render save and cancel buttons', () => { - expect(findSaveButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(false); - }); - - describe('with toggle changed by the user', () => { - beforeEach(() => { - findModSecurityToggle().vm.$emit('change'); - wrapper.setProps({ - ingress: { - ...defaultProps, - installed: true, - status: 'installed', - modsecurity_enabled: true, - }, - }); - }); - - it('renders toggle with label', () => { - expect(findModSecurityToggle().props('label')).toBe( - IngressModsecuritySettings.i18n.modSecurityEnabled, - ); - }); - - it('renders save and cancel buttons', () => { - expect(findSaveButton().exists()).toBe(true); - expect(findCancelButton().exists()).toBe(true); - }); - - it('enables related toggle and buttons', () => { - expect(findSaveButton().attributes().disabled).toBeUndefined(); - expect(findCancelButton().attributes().disabled).toBeUndefined(); - }); - - describe('with dropdown changed by the user', () => { - beforeEach(() => { - findModSecurityDropdown().vm.$children[1].$emit('click'); - wrapper.setProps({ - ingress: { - ...defaultProps, - installed: true, - status: 'installed', - modsecurity_enabled: true, - modsecurity_mode: 'blocking', - }, - }); - }); - - it('renders both save and cancel buttons', () => { - expect(findSaveButton().exists()).toBe(true); - expect(findCancelButton().exists()).toBe(true); - }); - - describe('and the save changes button is clicked', () => { - beforeEach(() => { - findSaveButton().vm.$emit('click'); - }); - - it('triggers save event and pass current modsecurity value', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', { - id: INGRESS, - params: { modsecurity_enabled: true, modsecurity_mode: 'blocking' }, - }); - }); - }); - }); - - describe('and the cancel button is clicked', () => { - beforeEach(() => { - findCancelButton().vm.$emit('click'); - }); - - it('triggers reset event and hides both cancel and save changes button', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('resetIngressModSecurityChanges', INGRESS); - expect(findSaveButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(false); - }); - }); - - describe('with a new version available', () => { - beforeEach(() => { - wrapper.setProps({ - ingress: { - ...defaultProps, - installed: true, - status: 'installed', - modsecurity_enabled: true, - updateAvailable: true, - }, - }); - }); - - it('disables related toggle and buttons', () => { - expect(findSaveButton().attributes().disabled).toBe('true'); - expect(findCancelButton().attributes().disabled).toBe('true'); - }); - }); - }); - - it('triggers set event to be propagated with the current modsecurity value', () => { - wrapper.setData({ modSecurityEnabled: true }); - return wrapper.vm.$nextTick().then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('setIngressModSecurityEnabled', { - id: INGRESS, - modSecurityEnabled: true, - }); - }); - }); - - describe(`when ingress status is ${UPDATING}`, () => { - beforeEach(() => { - createComponent({ installed: true, status: UPDATING }); - }); - - it('renders loading spinner in save button', () => { - expect(findSaveButton().props('loading')).toBe(true); - }); - - it('renders disabled save button', () => { - expect(findSaveButton().props('disabled')).toBe(true); - }); - - it('renders save button with "Saving" label', () => { - expect(findSaveButton().text()).toBe('Saving'); - }); - }); - - describe('when ingress fails to update', () => { - beforeEach(() => { - createComponent({ updateFailed: true }); - }); - - it('displays a error message', () => { - expect(wrapper.find(GlAlert).exists()).toBe(true); - }); - }); - }); - - describe('when ingress is not installed', () => { - beforeEach(() => { - createComponent(); - }); - - it('does not render the save button', () => { - expect(findSaveButton().exists()).toBe(false); - expect(findModSecurityToggle().props('value')).toBe(false); - }); - }); -}); diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js index c5cec4c4fdb..b129baa2d83 100644 --- a/spec/frontend/clusters/forms/components/integration_form_spec.js +++ b/spec/frontend/clusters/forms/components/integration_form_spec.js @@ -15,7 +15,6 @@ describe('ClusterIntegrationForm', () => { editable: true, environmentScope: '*', baseDomain: 'testDomain', - applicationIngressExternalIp: null, }; const createWrapper = (storeValues = defaultStoreValues) => { @@ -72,18 +71,6 @@ describe('ClusterIntegrationForm', () => { expect(findSubmitButton().exists()).toBe(false); }); }); - - it('does not render external IP block if applicationIngressExternalIp was not passed', () => { - createWrapper({ ...defaultStoreValues }); - - expect(wrapper.find('.js-ingress-domain-help-text').exists()).toBe(false); - }); - - it('renders external IP block if applicationIngressExternalIp was passed', () => { - createWrapper({ ...defaultStoreValues, applicationIngressExternalIp: '127.0.0.1' }); - - expect(wrapper.find('.js-ingress-domain-help-text').exists()).toBe(true); - }); }); describe('reactivity', () => { diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index 4f8b27d623c..a75fcb0cb06 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -20,7 +20,6 @@ const CLUSTERS_MOCK_DATA = { external_ip: null, external_hostname: null, can_uninstall: false, - modsecurity_enabled: false, }, { name: 'runner', @@ -154,7 +153,6 @@ const APPLICATIONS_MOCK_STATE = { ingress: { title: 'Ingress', status: 'installable', - modsecurity_enabled: false, }, crossplane: { title: 'Crossplane', status: 'installable', stack: '' }, cert_manager: { title: 'Cert-Manager', status: 'installable' }, @@ -163,7 +161,6 @@ const APPLICATIONS_MOCK_STATE = { jupyter: { title: 'JupyterHub', status: 'installable', hostname: '' }, knative: { title: 'Knative ', status: 'installable', hostname: '' }, elastic_stack: { title: 'Elastic Stack', status: 'installable' }, - fluentd: { title: 'Fluentd', status: 'installable' }, cilium: { title: 'GitLab Container Network Policies', status: 'not_installable', diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index c80949531c8..cdba6fc6ab8 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -84,16 +84,12 @@ describe('Clusters Store', () => { externalHostname: null, installable: true, installed: false, - isEditingModSecurityEnabled: false, - isEditingModSecurityMode: false, installFailed: true, uninstallable: false, updateFailed: false, uninstallSuccessful: false, uninstallFailed: false, validationError: null, - modsecurity_enabled: false, - modsecurity_mode: undefined, }, runner: { title: 'GitLab Runner', @@ -126,25 +122,6 @@ describe('Clusters Store', () => { uninstallFailed: false, validationError: null, }, - fluentd: { - title: 'Fluentd', - status: null, - statusReason: null, - requestReason: null, - port: null, - ciliumLogEnabled: null, - host: null, - protocol: null, - installable: true, - installed: false, - isEditingSettings: false, - installFailed: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - wafLogEnabled: null, - }, jupyter: { title: 'JupyterHub', status: mockResponseData.applications[4].status, diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js deleted file mode 100644 index fe928a01acf..00000000000 --- a/spec/frontend/commit/pipelines/pipelines_spec.js +++ /dev/null @@ -1,280 +0,0 @@ -import '~/commons'; -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import Api from '~/api'; -import pipelinesTable from '~/commit/pipelines/pipelines_table.vue'; -import axios from '~/lib/utils/axios_utils'; - -describe('Pipelines table in Commits and Merge requests', () => { - const jsonFixtureName = 'pipelines/pipelines.json'; - let pipeline; - let PipelinesTable; - let mock; - let vm; - const props = { - endpoint: 'endpoint.json', - emptyStateSvgPath: 'foo', - errorStateSvgPath: 'foo', - }; - - const findRunPipelineBtn = () => vm.$el.querySelector('[data-testid="run_pipeline_button"]'); - const findRunPipelineBtnMobile = () => - vm.$el.querySelector('[data-testid="run_pipeline_button_mobile"]'); - - beforeEach(() => { - mock = new MockAdapter(axios); - - const { pipelines } = getJSONFixture(jsonFixtureName); - - PipelinesTable = Vue.extend(pipelinesTable); - pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); - }); - - afterEach(() => { - vm.$destroy(); - mock.restore(); - }); - - describe('successful request', () => { - describe('without pipelines', () => { - beforeEach(() => { - mock.onGet('endpoint.json').reply(200, []); - - vm = mountComponent(PipelinesTable, props); - }); - - it('should render the empty state', (done) => { - setImmediate(() => { - expect(vm.$el.querySelector('.empty-state')).toBeDefined(); - expect(vm.$el.querySelector('.realtime-loading')).toBe(null); - expect(vm.$el.querySelector('.js-pipelines-error-state')).toBe(null); - done(); - }); - }); - }); - - describe('with pipelines', () => { - beforeEach(() => { - mock.onGet('endpoint.json').reply(200, [pipeline]); - vm = mountComponent(PipelinesTable, props); - }); - - it('should render a table with the received pipelines', (done) => { - setImmediate(() => { - expect(vm.$el.querySelectorAll('.ci-table .commit').length).toEqual(1); - expect(vm.$el.querySelector('.realtime-loading')).toBe(null); - expect(vm.$el.querySelector('.empty-state')).toBe(null); - expect(vm.$el.querySelector('.js-pipelines-error-state')).toBe(null); - done(); - }); - }); - - describe('with pagination', () => { - it('should make an API request when using pagination', (done) => { - setImmediate(() => { - jest.spyOn(vm, 'updateContent').mockImplementation(() => {}); - - vm.store.state.pageInfo = { - page: 1, - total: 10, - perPage: 2, - nextPage: 2, - totalPages: 5, - }; - - vm.$nextTick(() => { - vm.$el.querySelector('.next-page-item').click(); - - expect(vm.updateContent).toHaveBeenCalledWith({ page: '2' }); - done(); - }); - }); - }); - }); - }); - - describe('pipeline badge counts', () => { - beforeEach(() => { - mock.onGet('endpoint.json').reply(200, [pipeline]); - }); - - it('should receive update-pipelines-count event', (done) => { - const element = document.createElement('div'); - document.body.appendChild(element); - - element.addEventListener('update-pipelines-count', (event) => { - expect(event.detail.pipelines).toEqual([pipeline]); - done(); - }); - - vm = mountComponent(PipelinesTable, props); - - element.appendChild(vm.$el); - }); - }); - }); - - describe('run pipeline button', () => { - let pipelineCopy; - - beforeEach(() => { - pipelineCopy = { ...pipeline }; - }); - - describe('when latest pipeline has detached flag', () => { - it('renders the run pipeline button', (done) => { - pipelineCopy.flags.detached_merge_request_pipeline = true; - pipelineCopy.flags.merge_request_pipeline = true; - - mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - - vm = mountComponent(PipelinesTable, { ...props }); - - setImmediate(() => { - expect(findRunPipelineBtn()).not.toBeNull(); - expect(findRunPipelineBtnMobile()).not.toBeNull(); - done(); - }); - }); - }); - - describe('when latest pipeline does not have detached flag', () => { - it('does not render the run pipeline button', (done) => { - pipelineCopy.flags.detached_merge_request_pipeline = false; - pipelineCopy.flags.merge_request_pipeline = false; - - mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - - vm = mountComponent(PipelinesTable, { ...props }); - - setImmediate(() => { - expect(findRunPipelineBtn()).toBeNull(); - expect(findRunPipelineBtnMobile()).toBeNull(); - done(); - }); - }); - }); - - describe('on click', () => { - const findModal = () => - document.querySelector('#create-pipeline-for-fork-merge-request-modal'); - - beforeEach((done) => { - pipelineCopy.flags.detached_merge_request_pipeline = true; - - mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - - vm = mountComponent(PipelinesTable, { - ...props, - canRunPipeline: true, - projectId: '5', - mergeRequestId: 3, - }); - - jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve()); - - setImmediate(() => { - done(); - }); - }); - - it('on desktop, shows a loading button', (done) => { - findRunPipelineBtn().click(); - - vm.$nextTick(() => { - expect(findModal()).toBeNull(); - - expect(findRunPipelineBtn().disabled).toBe(true); - expect(findRunPipelineBtn().querySelector('.gl-spinner')).not.toBeNull(); - - setImmediate(() => { - expect(findRunPipelineBtn().disabled).toBe(false); - expect(findRunPipelineBtn().querySelector('.gl-spinner')).toBeNull(); - - done(); - }); - }); - }); - - it('on mobile, shows a loading button', (done) => { - findRunPipelineBtnMobile().click(); - - vm.$nextTick(() => { - expect(findModal()).toBeNull(); - - expect(findModal()).toBeNull(); - expect(findRunPipelineBtn().querySelector('.gl-spinner')).not.toBeNull(); - - setImmediate(() => { - expect(findRunPipelineBtn().disabled).toBe(false); - expect(findRunPipelineBtn().querySelector('.gl-spinner')).toBeNull(); - - done(); - }); - }); - }); - }); - - describe('on click for fork merge request', () => { - const findModal = () => - document.querySelector('#create-pipeline-for-fork-merge-request-modal'); - - beforeEach((done) => { - pipelineCopy.flags.detached_merge_request_pipeline = true; - - mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - - vm = mountComponent(PipelinesTable, { - ...props, - projectId: '5', - mergeRequestId: 3, - canCreatePipelineInTargetProject: true, - sourceProjectFullPath: 'test/parent-project', - targetProjectFullPath: 'test/fork-project', - }); - - jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve()); - - setImmediate(() => { - done(); - }); - }); - - it('on desktop, shows a security warning modal', (done) => { - findRunPipelineBtn().click(); - - vm.$nextTick(() => { - expect(findModal()).not.toBeNull(); - done(); - }); - }); - - it('on mobile, shows a security warning modal', (done) => { - findRunPipelineBtnMobile().click(); - - vm.$nextTick(() => { - expect(findModal()).not.toBeNull(); - done(); - }); - }); - }); - }); - - describe('unsuccessfull request', () => { - beforeEach(() => { - mock.onGet('endpoint.json').reply(500, []); - - vm = mountComponent(PipelinesTable, props); - }); - - it('should render error state', (done) => { - setImmediate(() => { - expect(vm.$el.querySelector('.js-pipelines-error-state')).toBeDefined(); - expect(vm.$el.querySelector('.realtime-loading')).toBe(null); - expect(vm.$el.querySelector('.ci-table')).toBe(null); - done(); - }); - }); - }); -}); diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js new file mode 100644 index 00000000000..4bf6727af3b --- /dev/null +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -0,0 +1,253 @@ +import { GlEmptyState, GlLoadingIcon, GlModal, GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import PipelinesTable from '~/commit/pipelines/pipelines_table.vue'; +import axios from '~/lib/utils/axios_utils'; + +describe('Pipelines table in Commits and Merge requests', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + let wrapper; + let pipeline; + let mock; + + const findRunPipelineBtn = () => wrapper.findByTestId('run_pipeline_button'); + const findRunPipelineBtnMobile = () => wrapper.findByTestId('run_pipeline_button_mobile'); + const findLoadingState = () => wrapper.findComponent(GlLoadingIcon); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findTable = () => wrapper.findComponent(GlTable); + const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); + const findModal = () => wrapper.findComponent(GlModal); + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + mount(PipelinesTable, { + propsData: { + endpoint: 'endpoint.json', + emptyStateSvgPath: 'foo', + errorStateSvgPath: 'foo', + ...props, + }, + }), + ); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + const { pipelines } = getJSONFixture(jsonFixtureName); + + pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('successful request', () => { + describe('without pipelines', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(200, []); + + createComponent(); + + await waitForPromises(); + }); + + it('should render the empty state', () => { + expect(findTableRows()).toHaveLength(0); + expect(findLoadingState().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('with pipelines', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(200, [pipeline]); + + createComponent(); + + await waitForPromises(); + }); + + it('should render a table with the received pipelines', () => { + expect(findTable().exists()).toBe(true); + expect(findTableRows()).toHaveLength(1); + expect(findLoadingState().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); + }); + + describe('with pagination', () => { + it('should make an API request when using pagination', async () => { + jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); + + await wrapper.setData({ + store: { + state: { + pageInfo: { + page: 1, + total: 10, + perPage: 2, + nextPage: 2, + totalPages: 5, + }, + }, + }, + }); + + wrapper.find('.next-page-item').trigger('click'); + + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ page: '2' }); + }); + }); + + describe('pipeline badge counts', () => { + it('should receive update-pipelines-count event', (done) => { + const element = document.createElement('div'); + document.body.appendChild(element); + + element.addEventListener('update-pipelines-count', (event) => { + expect(event.detail.pipelines).toEqual([pipeline]); + done(); + }); + + createComponent(); + + element.appendChild(wrapper.vm.$el); + }); + }); + }); + }); + + describe('run pipeline button', () => { + let pipelineCopy; + + beforeEach(() => { + pipelineCopy = { ...pipeline }; + }); + + describe('when latest pipeline has detached flag', () => { + it('renders the run pipeline button', async () => { + pipelineCopy.flags.detached_merge_request_pipeline = true; + pipelineCopy.flags.merge_request_pipeline = true; + + mock.onGet('endpoint.json').reply(200, [pipelineCopy]); + + createComponent(); + + await waitForPromises(); + + expect(findRunPipelineBtn().exists()).toBe(true); + expect(findRunPipelineBtnMobile().exists()).toBe(true); + }); + }); + + describe('when latest pipeline does not have detached flag', () => { + it('does not render the run pipeline button', async () => { + pipelineCopy.flags.detached_merge_request_pipeline = false; + pipelineCopy.flags.merge_request_pipeline = false; + + mock.onGet('endpoint.json').reply(200, [pipelineCopy]); + + createComponent(); + + await waitForPromises(); + + expect(findRunPipelineBtn().exists()).toBe(false); + expect(findRunPipelineBtnMobile().exists()).toBe(false); + }); + }); + + describe('on click', () => { + beforeEach(async () => { + pipelineCopy.flags.detached_merge_request_pipeline = true; + + mock.onGet('endpoint.json').reply(200, [pipelineCopy]); + + createComponent({ + canRunPipeline: true, + projectId: '5', + mergeRequestId: 3, + }); + + jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve()); + + await waitForPromises(); + }); + + it('on desktop, shows a loading button', async () => { + await findRunPipelineBtn().trigger('click'); + + expect(findRunPipelineBtn().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findRunPipelineBtn().props('loading')).toBe(false); + }); + + it('on mobile, shows a loading button', async () => { + await findRunPipelineBtnMobile().trigger('click'); + + expect(findRunPipelineBtn().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findRunPipelineBtn().props('disabled')).toBe(false); + expect(findRunPipelineBtn().props('loading')).toBe(false); + }); + }); + + describe('on click for fork merge request', () => { + beforeEach(async () => { + pipelineCopy.flags.detached_merge_request_pipeline = true; + + mock.onGet('endpoint.json').reply(200, [pipelineCopy]); + + createComponent({ + projectId: '5', + mergeRequestId: 3, + canCreatePipelineInTargetProject: true, + sourceProjectFullPath: 'test/parent-project', + targetProjectFullPath: 'test/fork-project', + }); + + jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve()); + + await waitForPromises(); + }); + + it('on desktop, shows a security warning modal', async () => { + await findRunPipelineBtn().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findModal()).not.toBeNull(); + }); + + it('on mobile, shows a security warning modal', async () => { + await findRunPipelineBtnMobile().trigger('click'); + + expect(findModal()).not.toBeNull(); + }); + }); + }); + + describe('unsuccessfull request', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(500, []); + + createComponent(); + + await waitForPromises(); + }); + + it('should render error state', () => { + expect(findEmptyState().text()).toBe( + 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', + ); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap new file mode 100644 index 00000000000..e56c37b0dc9 --- /dev/null +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`content_editor/components/toolbar_link_button renders dropdown component 1`] = ` +"<div class=\\"dropdown b-dropdown gl-new-dropdown btn-group\\" aria-label=\\"Insert link\\" title=\\"Insert link\\"> + <!----><button aria-haspopup=\\"true\\" aria-expanded=\\"false\\" type=\\"button\\" class=\\"btn dropdown-toggle btn-default btn-sm gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only\\"> + <!----> <svg data-testid=\\"link-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"dropdown-icon gl-icon s16\\"> + <use href=\\"#link\\"></use> + </svg> <span class=\\"gl-new-dropdown-button-text\\"></span> <svg data-testid=\\"chevron-down-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"gl-button-icon dropdown-chevron gl-icon s16\\"> + <use href=\\"#chevron-down\\"></use> + </svg></button> + <ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\"> + <div class=\\"gl-new-dropdown-inner\\"> + <!----> + <div class=\\"gl-new-dropdown-contents\\"> + <li role=\\"presentation\\" class=\\"gl-px-3!\\"> + <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> + <div placeholder=\\"Link URL\\"> + <div role=\\"group\\" class=\\"input-group\\"> + <!----> + <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"gl-form-input form-control\\"> + <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> + <!----> + <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> + <!----> + </div> + </div> + </form> + </li> + <!----> + <!----> + </div> + <!----> + </div> + </ul> +</div>" +`; diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index e3741032bf4..59c4190ad3a 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -27,7 +27,10 @@ describe('ContentEditor', () => { it('renders editor content component and attaches editor instance', () => { createWrapper(editor); - expect(wrapper.findComponent(EditorContent).props().editor).toBe(editor.tiptapEditor); + const editorContent = wrapper.findComponent(EditorContent); + + expect(editorContent.props().editor).toBe(editor.tiptapEditor); + expect(editorContent.classes()).toContain('md'); }); it('renders top toolbar component and attaches editor instance', () => { @@ -38,8 +41,8 @@ describe('ContentEditor', () => { it.each` isFocused | classes - ${true} | ${['md', 'md-area', 'is-focused']} - ${false} | ${['md', 'md-area']} + ${true} | ${['md-area', 'is-focused']} + ${false} | ${['md-area']} `( 'has $classes class selectors when tiptapEditor.isFocused = $isFocused', ({ isFocused, classes }) => { diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js new file mode 100644 index 00000000000..812e769c891 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -0,0 +1,151 @@ +import { GlDropdown, GlDropdownDivider, GlFormInputGroup, GlButton } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; +import { tiptapExtension as Link } from '~/content_editor/extensions/link'; +import { hasSelection } from '~/content_editor/services/utils'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; + +jest.mock('~/content_editor/services/utils'); + +describe('content_editor/components/toolbar_link_button', () => { + let wrapper; + let editor; + + const buildWrapper = () => { + wrapper = mountExtended(ToolbarLinkButton, { + propsData: { + tiptapEditor: editor, + }, + stubs: { + GlFormInputGroup, + }, + }); + }; + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); + const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); + const findApplyLinkButton = () => wrapper.findComponent(GlButton); + const findRemoveLinkButton = () => wrapper.findByText('Remove link'); + + beforeEach(() => { + editor = createTestEditor({ + extensions: [Link], + }); + }); + + afterEach(() => { + editor.destroy(); + wrapper.destroy(); + }); + + it('renders dropdown component', () => { + buildWrapper(); + + expect(findDropdown().html()).toMatchSnapshot(); + }); + + describe('when there is an active link', () => { + beforeEach(() => { + jest.spyOn(editor, 'isActive'); + editor.isActive.mockReturnValueOnce(true); + buildWrapper(); + }); + + it('sets dropdown as active when link extension is active', () => { + expect(findDropdown().props('toggleClass')).toEqual({ active: true }); + }); + + it('displays a remove link dropdown option', () => { + expect(findDropdownDivider().exists()).toBe(true); + expect(wrapper.findByText('Remove link').exists()).toBe(true); + }); + + it('executes removeLink command when the remove link option is clicked', async () => { + const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'run']); + + await findRemoveLinkButton().trigger('click'); + + expect(commands.unsetLink).toHaveBeenCalled(); + expect(commands.focus).toHaveBeenCalled(); + expect(commands.run).toHaveBeenCalled(); + }); + + it('updates the link with a new link when "Apply" button is clicked', async () => { + const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']); + + await findLinkURLInput().setValue('https://example'); + await findApplyLinkButton().trigger('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.unsetLink).toHaveBeenCalled(); + expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' }); + expect(commands.run).toHaveBeenCalled(); + }); + }); + + describe('when there is not an active link', () => { + beforeEach(() => { + jest.spyOn(editor, 'isActive'); + editor.isActive.mockReturnValueOnce(false); + buildWrapper(); + }); + + it('does not set dropdown as active', () => { + expect(findDropdown().props('toggleClass')).toEqual({ active: false }); + }); + + it('does not display a remove link dropdown option', () => { + expect(findDropdownDivider().exists()).toBe(false); + expect(wrapper.findByText('Remove link').exists()).toBe(false); + }); + + it('sets the link to the value in the URL input when "Apply" button is clicked', async () => { + const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']); + + await findLinkURLInput().setValue('https://example'); + await findApplyLinkButton().trigger('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' }); + expect(commands.run).toHaveBeenCalled(); + }); + }); + + describe('when the user displays the dropdown', () => { + let commands; + + beforeEach(() => { + commands = mockChainedCommands(editor, ['focus', 'extendMarkRange', 'run']); + }); + + describe('given the user has not selected text', () => { + beforeEach(() => { + hasSelection.mockReturnValueOnce(false); + }); + + it('the editor selection is extended to the current mark extent', () => { + buildWrapper(); + + findDropdown().vm.$emit('show'); + expect(commands.extendMarkRange).toHaveBeenCalledWith(Link.name); + expect(commands.focus).toHaveBeenCalled(); + expect(commands.run).toHaveBeenCalled(); + }); + }); + + describe('given the user has selected text', () => { + beforeEach(() => { + hasSelection.mockReturnValueOnce(true); + }); + + it('the editor does not modify the current selection', () => { + buildWrapper(); + + findDropdown().vm.$emit('show'); + expect(commands.extendMarkRange).not.toHaveBeenCalled(); + expect(commands.focus).not.toHaveBeenCalled(); + expect(commands.run).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js new file mode 100644 index 00000000000..8c54f6bb8bb --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js @@ -0,0 +1,131 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; +import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; +import { createTestContentEditorExtension, createTestEditor } from '../test_utils'; + +describe('content_editor/components/toolbar_headings_dropdown', () => { + let wrapper; + let tiptapEditor; + let commandMocks; + + const buildEditor = () => { + const testExtension = createTestContentEditorExtension({ + commands: TEXT_STYLE_DROPDOWN_ITEMS.map((item) => item.editorCommand), + }); + + commandMocks = testExtension.commandMocks; + tiptapEditor = createTestEditor({ + extensions: [testExtension.tiptapExtension], + }); + + jest.spyOn(tiptapEditor, 'isActive'); + }; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(ToolbarTextStyleDropdown, { + stubs: { + GlDropdown, + GlDropdownItem, + }, + propsData: { + tiptapEditor, + ...propsData, + }, + }); + }; + const findDropdown = () => wrapper.findComponent(GlDropdown); + + beforeEach(() => { + buildEditor(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all text styles as dropdown items', () => { + buildWrapper(); + + TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle) => { + expect(wrapper.findByText(textStyle.label).exists()).toBe(true); + }); + }); + + describe('when there is an active item ', () => { + let activeTextStyle; + + beforeEach(() => { + [, activeTextStyle] = TEXT_STYLE_DROPDOWN_ITEMS; + + tiptapEditor.isActive.mockImplementation( + (contentType, params) => + activeTextStyle.contentType === contentType && activeTextStyle.commandParams === params, + ); + + buildWrapper(); + }); + + it('displays the active text style label as the dropdown toggle text ', () => { + expect(findDropdown().props().text).toBe(activeTextStyle.label); + }); + + it('sets dropdown as enabled', () => { + expect(findDropdown().props().disabled).toBe(false); + }); + + it('sets active item as active', () => { + const activeItem = wrapper + .findAllComponents(GlDropdownItem) + .filter((item) => item.text() === activeTextStyle.label) + .at(0); + expect(activeItem.props().isChecked).toBe(true); + }); + }); + + describe('when there isn’t an active item', () => { + beforeEach(() => { + tiptapEditor.isActive.mockReturnValue(false); + buildWrapper(); + }); + + it('sets dropdown as disabled', () => { + expect(findDropdown().props().disabled).toBe(true); + }); + + it('sets dropdown toggle text to Text style', () => { + expect(findDropdown().props().text).toBe('Text style'); + }); + }); + + describe('when a text style is selected', () => { + it('executes the tiptap command related to that text style', () => { + buildWrapper(); + + TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => { + const { editorCommand, commandParams } = textStyle; + + wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click'); + expect(commandMocks[editorCommand]).toHaveBeenCalledWith(commandParams || {}); + }); + }); + + it('emits execute event with contentType and value params that indicates the heading level', () => { + TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => { + buildWrapper(); + const { contentType, commandParams } = textStyle; + + wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click'); + expect(wrapper.emitted('execute')).toEqual([ + [ + { + contentType, + value: commandParams?.level, + }, + ], + ]); + wrapper.destroy(); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index 8f47be3f489..0a1405a1774 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -39,32 +39,35 @@ describe('content_editor/components/top_toolbar', () => { }); describe.each` - testId | buttonProps + testId | controlProps ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} - `('given a $testId toolbar control', ({ testId, buttonProps }) => { + ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} + ${'text-styles'} | ${{}} + ${'link'} | ${{}} + `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); }); it('renders the toolbar control with the provided properties', () => { expect(wrapper.findByTestId(testId).props()).toEqual({ - ...buttonProps, + ...controlProps, tiptapEditor: contentEditor.tiptapEditor, }); }); it.each` - control | eventData - ${'bold'} | ${{ contentType: 'bold' }} - ${'blockquote'} | ${{ contentType: 'blockquote', value: 1 }} - `('tracks the execution of toolbar controls', ({ control, eventData }) => { + eventData + ${{ contentType: 'bold' }} + ${{ contentType: 'blockquote', value: 1 }} + `('tracks the execution of toolbar controls', ({ eventData }) => { const { contentType, value } = eventData; - wrapper.findByTestId(control).vm.$emit('execute', eventData); + wrapper.findByTestId(testId).vm.$emit('execute', eventData); expect(trackingSpy).toHaveBeenCalledWith(undefined, TOOLBAR_CONTROL_TRACKING_ACTION, { label: CONTENT_EDITOR_TRACKING_LABEL, diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js new file mode 100644 index 00000000000..cc695ffe241 --- /dev/null +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -0,0 +1,37 @@ +import { tiptapExtension as CodeBlockHighlight } from '~/content_editor/extensions/code_block_highlight'; +import { loadMarkdownApiResult } from '../markdown_processing_examples'; +import { createTestEditor } from '../test_utils'; + +describe('content_editor/extensions/code_block_highlight', () => { + let codeBlockHtmlFixture; + let parsedCodeBlockHtmlFixture; + let tiptapEditor; + + const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); + const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); + + beforeEach(() => { + const { html } = loadMarkdownApiResult('code_block'); + + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + codeBlockHtmlFixture = html; + parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture); + + tiptapEditor.commands.setContent(codeBlockHtmlFixture); + }); + + it('extracts language and params attributes from Markdown API output', () => { + const language = preElement().getAttribute('lang'); + + expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ + language, + params: language, + }); + }); + + it('adds code, highlight, and js-syntax-highlight to code block element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); + }); +}); diff --git a/spec/frontend/content_editor/extensions/link_spec.js b/spec/frontend/content_editor/extensions/link_spec.js new file mode 100644 index 00000000000..026b2a06df3 --- /dev/null +++ b/spec/frontend/content_editor/extensions/link_spec.js @@ -0,0 +1,61 @@ +import { + markdownLinkSyntaxInputRuleRegExp, + urlSyntaxRegExp, + extractHrefFromMarkdownLink, +} from '~/content_editor/extensions/link'; + +describe('content_editor/extensions/link', () => { + describe.each` + input | matches + ${'[gitlab](https://gitlab.com)'} | ${true} + ${'[documentation](readme.md)'} | ${true} + ${'[link 123](readme.md)'} | ${true} + ${'[link 123](read me.md)'} | ${true} + ${'text'} | ${false} + ${'documentation](readme.md'} | ${false} + ${'https://www.google.com'} | ${false} + `('markdownLinkSyntaxInputRuleRegExp', ({ input, matches }) => { + it(`${matches ? 'matches' : 'does not match'} ${input}`, () => { + const match = new RegExp(markdownLinkSyntaxInputRuleRegExp).exec(input); + + expect(Boolean(match?.groups.href)).toBe(matches); + }); + }); + + describe.each` + input | matches + ${'http://example.com '} | ${true} + ${'https://example.com '} | ${true} + ${'www.example.com '} | ${true} + ${'example.com/ab.html '} | ${false} + ${'text'} | ${false} + ${' http://example.com '} | ${true} + ${'https://www.google.com '} | ${true} + `('urlSyntaxRegExp', ({ input, matches }) => { + it(`${matches ? 'matches' : 'does not match'} ${input}`, () => { + const match = new RegExp(urlSyntaxRegExp).exec(input); + + expect(Boolean(match?.groups.href)).toBe(matches); + }); + }); + + describe('extractHrefFromMarkdownLink', () => { + const input = '[gitlab](https://gitlab.com)'; + const href = 'https://gitlab.com'; + let match; + let result; + + beforeEach(() => { + match = new RegExp(markdownLinkSyntaxInputRuleRegExp).exec(input); + result = extractHrefFromMarkdownLink(match); + }); + + it('extracts the url from a markdown link captured by markdownLinkSyntaxInputRuleRegExp', () => { + expect(result).toEqual({ href }); + }); + + it('makes sure that url text is the last capture group', () => { + expect(match[match.length - 1]).toEqual('gitlab'); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js index 437714ba938..cf74b5c56c9 100644 --- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -5,11 +5,8 @@ import { Heading } from '@tiptap/extension-heading'; import { ListItem } from '@tiptap/extension-list-item'; import { Paragraph } from '@tiptap/extension-paragraph'; import { Text } from '@tiptap/extension-text'; -import { Editor, EditorContent } from '@tiptap/vue-2'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { Editor } from '@tiptap/vue-2'; import { mockTracking } from 'helpers/tracking_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { KEYBOARD_SHORTCUT_TRACKING_ACTION, INPUT_RULE_TRACKING_ACTION, @@ -19,47 +16,33 @@ import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_r import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; describe('content_editor/services/track_input_rules_and_shortcuts', () => { - let wrapper; let trackingSpy; let editor; + let trackedExtensions; const HEADING_TEXT = 'Heading text'; - - const buildWrapper = () => { - wrapper = extendedWrapper( - mount(EditorContent, { - propsData: { - editor, - }, - }), - ); - }; + const extensions = [Document, Paragraph, Text, Heading, CodeBlockLowlight, BulletList, ListItem]; beforeEach(() => { trackingSpy = mockTracking(undefined, null, jest.spyOn); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('given the heading extension is instrumented', () => { beforeEach(() => { + trackedExtensions = extensions.map(trackInputRulesAndShortcuts); editor = new Editor({ - extensions: [ - Document, - Paragraph, - Text, - Heading, - CodeBlockLowlight, - BulletList, - ListItem, - ].map(trackInputRulesAndShortcuts), + extensions: extensions.map(trackInputRulesAndShortcuts), }); }); - beforeEach(async () => { - buildWrapper(); - await nextTick(); + it('does not remove existing keyboard shortcuts', () => { + extensions.forEach((extension, index) => { + const originalShortcuts = Object.keys(extension.addKeyboardShortcuts?.() || {}); + const trackedShortcuts = Object.keys( + trackedExtensions[index].addKeyboardShortcuts?.() || {}, + ); + + expect(originalShortcuts).toEqual(trackedShortcuts); + }); }); describe('when creating a heading using an keyboard shortcut', () => { diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index a92ceb6d058..8e73aef678b 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -1,34 +1,106 @@ import { Node } from '@tiptap/core'; +import { Document } from '@tiptap/extension-document'; +import { Paragraph } from '@tiptap/extension-paragraph'; +import { Text } from '@tiptap/extension-text'; +import { Editor } from '@tiptap/vue-2'; -export const createTestContentEditorExtension = () => ({ - tiptapExtension: Node.create({ - name: 'label', - priority: 101, - inline: true, - group: 'inline', - addAttributes() { - return { - labelName: { - default: null, - parseHTML: (element) => { - return { labelName: element.dataset.labelName }; +/** + * Creates an instance of the Tiptap Editor class + * with a minimal configuration for testing purposes. + * + * It only includes the Document, Text, and Paragraph + * extensions. + * + * @param {Array} config.extensions One or more extensions to + * include in the editor + * @returns An instance of a Tiptap’s Editor class + */ +export const createTestEditor = ({ extensions = [] }) => { + return new Editor({ + extensions: [Document, Text, Paragraph, ...extensions], + }); +}; + +export const mockChainedCommands = (editor, commandNames = []) => { + const commandMocks = commandNames.reduce( + (accum, commandName) => ({ + ...accum, + [commandName]: jest.fn(), + }), + {}, + ); + + Object.keys(commandMocks).forEach((commandName) => { + commandMocks[commandName].mockReturnValue(commandMocks); + }); + + jest.spyOn(editor, 'chain').mockImplementation(() => commandMocks); + + return commandMocks; +}; + +/** + * Creates a Content Editor extension for testing + * purposes. + * + * @param {Array} config.commands A list of command names + * to include in the test extension. This utility will create + * Jest mock functions for each command name. + * @returns An object with the following properties: + * + * tiptapExtension A Node tiptap extension + * commandMocks Jest mock functions for each created command + * serializer A markdown serializer for the extension + */ +export const createTestContentEditorExtension = ({ commands = [] } = {}) => { + const commandMocks = commands.reduce( + (accum, commandName) => ({ + ...accum, + [commandName]: jest.fn(), + }), + {}, + ); + + return { + commandMocks, + tiptapExtension: Node.create({ + name: 'label', + priority: 101, + inline: true, + group: 'inline', + addCommands() { + return commands.reduce( + (accum, commandName) => ({ + ...accum, + [commandName]: (...params) => () => commandMocks[commandName](...params), + }), + {}, + ); + }, + addAttributes() { + return { + labelName: { + default: null, + parseHTML: (element) => { + return { labelName: element.dataset.labelName }; + }, }, - }, - }; - }, - parseHTML() { - return [ - { - tag: 'span[data-reference="label"]', - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ['span', HTMLAttributes, 0]; + }; + }, + parseHTML() { + return [ + { + tag: 'span[data-reference="label"]', + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0]; + }, + }), + serializer: (state, node) => { + state.write(`~${node.attrs.labelName}`); + state.closeBlock(node); }, - }), - serializer: (state, node) => { - state.write(`~${node.attrs.labelName}`); - state.closeBlock(node); - }, -}); + }; +}; diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index f10cf4b4140..8d7b22fe4ff 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -24,7 +24,7 @@ import { CREATE_CLUSTER_ERROR, } from '~/create_cluster/eks_cluster/store/mutation_types'; import createState from '~/create_cluster/eks_cluster/store/state'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; jest.mock('~/flash'); @@ -358,7 +358,9 @@ describe('EKS Cluster Store Actions', () => { testAction(actions.createClusterError, payload, state, [ { type: CREATE_CLUSTER_ERROR, payload }, ]).then(() => { - expect(createFlash).toHaveBeenCalledWith(payload.name[0]); + expect(createFlash).toHaveBeenCalledWith({ + message: payload.name[0], + }); })); }); }); diff --git a/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap new file mode 100644 index 00000000000..1af612ed029 --- /dev/null +++ b/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Value stream analytics component isEmptyStage = true renders the empty stage with \`Not enough data\` message 1`] = `"<gl-empty-state-stub title=\\"We don't have enough data to show this stage.\\" svgpath=\\"path/to/no/data\\" description=\\"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`; + +exports[`Value stream analytics component isEmptyStage = true with a selectedStageError renders the empty stage with \`There is too much data to calculate\` message 1`] = `"<gl-empty-state-stub title=\\"There is too much data to calculate\\" svgpath=\\"path/to/no/data\\" description=\\"\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`; + +exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`; + +exports[`Value stream analytics component without enough permissions renders the empty stage with \`You need permission\` message 1`] = `"<gl-empty-state-stub title=\\"You need permission.\\" svgpath=\\"path/to/no/access\\" description=\\"Want to see the data? Please ask an administrator for access.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`; diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js new file mode 100644 index 00000000000..2f85cc04051 --- /dev/null +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -0,0 +1,197 @@ +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import BaseComponent from '~/cycle_analytics/components/base.vue'; +import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; +import initState from '~/cycle_analytics/store/state'; +import { selectedStage, convertedEvents as selectedStageEvents } from './mock_data'; + +const noDataSvgPath = 'path/to/no/data'; +const noAccessSvgPath = 'path/to/no/access'; + +Vue.use(Vuex); + +let wrapper; + +function createStore({ initialState = {} }) { + return new Vuex.Store({ + state: { + ...initState(), + permissions: { + [selectedStage.id]: true, + }, + ...initialState, + }, + getters: { + pathNavigationData: () => [], + }, + }); +} + +function createComponent({ initialState } = {}) { + return extendedWrapper( + shallowMount(BaseComponent, { + store: createStore({ initialState }), + propsData: { + noDataSvgPath, + noAccessSvgPath, + }, + }), + ); +} + +const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); +const findPathNavigation = () => wrapper.findComponent(PathNavigation); +const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics'); +const findStageTable = () => wrapper.findByTestId('vsa-stage-table'); +const findEmptyStage = () => wrapper.findComponent(GlEmptyState); +const findStageEvents = () => wrapper.findByTestId('stage-table-events'); + +describe('Value stream analytics component', () => { + beforeEach(() => { + wrapper = createComponent({ + initialState: { + isLoading: false, + isLoadingStage: false, + isEmptyStage: false, + selectedStageEvents, + selectedStage, + selectedStageError: '', + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the path navigation component', () => { + expect(findPathNavigation().exists()).toBe(true); + }); + + it('renders the overview metrics', () => { + expect(findOverviewMetrics().exists()).toBe(true); + }); + + it('renders the stage table', () => { + expect(findStageTable().exists()).toBe(true); + }); + + it('renders the stage table events', () => { + expect(findEmptyStage().exists()).toBe(false); + expect(findStageEvents().exists()).toBe(true); + }); + + it('does not render the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('isLoading = true', () => { + beforeEach(() => { + wrapper = createComponent({ + initialState: { isLoading: true }, + }); + }); + + it('renders the path navigation component with prop `loading` set to true', () => { + expect(findPathNavigation().html()).toMatchSnapshot(); + }); + + it('does not render the overview metrics', () => { + expect(findOverviewMetrics().exists()).toBe(false); + }); + + it('does not render the stage table', () => { + expect(findStageTable().exists()).toBe(false); + }); + + it('renders the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('isLoadingStage = true', () => { + beforeEach(() => { + wrapper = createComponent({ + initialState: { isLoadingStage: true }, + }); + }); + + it('renders the stage table with a loading icon', () => { + const tableWrapper = findStageTable(); + expect(tableWrapper.exists()).toBe(true); + expect(tableWrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('isEmptyStage = true', () => { + beforeEach(() => { + wrapper = createComponent({ + initialState: { selectedStage, isEmptyStage: true }, + }); + }); + + it('renders the empty stage with `Not enough data` message', () => { + expect(findEmptyStage().html()).toMatchSnapshot(); + }); + + describe('with a selectedStageError', () => { + beforeEach(() => { + wrapper = createComponent({ + initialState: { + selectedStage, + isEmptyStage: true, + selectedStageError: 'There is too much data to calculate', + }, + }); + }); + + it('renders the empty stage with `There is too much data to calculate` message', () => { + expect(findEmptyStage().html()).toMatchSnapshot(); + }); + }); + }); + + describe('without enough permissions', () => { + beforeEach(() => { + wrapper = createComponent({ + initialState: { + permissions: { + [selectedStage.id]: false, + }, + }, + }); + }); + + it('renders the empty stage with `You need permission` message', () => { + expect(findEmptyStage().html()).toMatchSnapshot(); + }); + }); + + describe('without a selected stage', () => { + beforeEach(() => { + wrapper = createComponent({ + initialState: { selectedStage: null, isEmptyStage: true }, + }); + }); + + it('renders the stage table', () => { + expect(findStageTable().exists()).toBe(true); + }); + + it('does not render the path navigation component', () => { + expect(findPathNavigation().exists()).toBe(false); + }); + + it('does not render the stage table events', () => { + expect(findStageEvents().exists()).toBe(false); + }); + + it('does not render the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index 091b574821d..242ea1932fb 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -1,5 +1,11 @@ +import { DEFAULT_VALUE_STREAM } from '~/cycle_analytics/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +export const getStageByTitle = (stages, title) => + stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {}; + +export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging']; + export const summary = [ { value: '20', title: 'New Issues' }, { value: null, title: 'Commits' }, @@ -8,6 +14,7 @@ export const summary = [ ]; const issueStage = { + id: 'issue', title: 'Issue', name: 'issue', legend: '', @@ -16,30 +23,34 @@ const issueStage = { }; const planStage = { + id: 'plan', title: 'Plan', name: 'plan', legend: '', description: 'Time before an issue starts implementation', - value: 'about 21 hours', + value: 75600, }; const codeStage = { + id: 'code', title: 'Code', name: 'code', legend: '', description: 'Time until first merge request', - value: '2 days', + value: 172800, }; const testStage = { + id: 'test', title: 'Test', name: 'test', legend: '', description: 'Total test time for all commits/merges', - value: 'about 5 hours', + value: 17550, }; const reviewStage = { + id: 'review', title: 'Review', name: 'review', legend: '', @@ -48,11 +59,12 @@ const reviewStage = { }; const stagingStage = { + id: 'staging', title: 'Staging', name: 'staging', legend: '', description: 'From merge request merge until deploy to production', - value: '2 days', + value: 172800, }; export const selectedStage = { @@ -84,54 +96,6 @@ export const rawData = { }; export const convertedData = { - stages: [ - selectedStage, - { - ...planStage, - active: false, - isUserAllowed: true, - emptyStageText: - 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - component: 'stage-plan-component', - slug: 'plan', - }, - { - ...codeStage, - active: false, - isUserAllowed: true, - emptyStageText: - 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - component: 'stage-code-component', - slug: 'code', - }, - { - ...testStage, - active: false, - isUserAllowed: true, - emptyStageText: - 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - component: 'stage-test-component', - slug: 'test', - }, - { - ...reviewStage, - active: false, - isUserAllowed: true, - emptyStageText: - 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - component: 'stage-review-component', - slug: 'review', - }, - { - ...stagingStage, - active: false, - isUserAllowed: true, - emptyStageText: - 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - component: 'stage-staging-component', - slug: 'staging', - }, - ], summary: [ { value: '20', title: 'New Issues' }, { value: '-', title: 'Commits' }, @@ -184,3 +148,110 @@ export const rawEvents = [ export const convertedEvents = rawEvents.map((ev) => convertObjectPropsToCamelCase(ev, { deep: true }), ); + +export const pathNavIssueMetric = 172800; + +export const rawStageMedians = [ + { id: 'issue', value: 172800 }, + { id: 'plan', value: 86400 }, + { id: 'review', value: 1036800 }, + { id: 'code', value: 129600 }, + { id: 'test', value: 259200 }, + { id: 'staging', value: 388800 }, +]; + +export const stageMedians = { + issue: 172800, + plan: 86400, + review: 1036800, + code: 129600, + test: 259200, + staging: 388800, +}; + +export const allowedStages = [issueStage, planStage, codeStage]; + +export const transformedProjectStagePathData = [ + { + metric: 172800, + selected: true, + stageCount: undefined, + icon: null, + id: 'issue', + title: 'Issue', + name: 'issue', + legend: '', + description: 'Time before an issue gets scheduled', + value: null, + }, + { + metric: 86400, + selected: false, + stageCount: undefined, + icon: null, + id: 'plan', + title: 'Plan', + name: 'plan', + legend: '', + description: 'Time before an issue starts implementation', + value: 75600, + }, + { + metric: 129600, + selected: false, + stageCount: undefined, + icon: null, + id: 'code', + title: 'Code', + name: 'code', + legend: '', + description: 'Time until first merge request', + value: 172800, + }, +]; + +export const selectedValueStream = DEFAULT_VALUE_STREAM; + +export const rawValueStreamStages = [ + { + title: 'Issue', + hidden: false, + legend: '', + description: 'Time before an issue gets scheduled', + id: 'issue', + custom: false, + start_event_html_description: + '\u003cp data-sourcepos="1:1-1:13" dir="auto"\u003eIssue created\u003c/p\u003e', + end_event_html_description: + '\u003cp data-sourcepos="1:1-1:71" dir="auto"\u003eIssue first associated with a milestone or issue first added to a board\u003c/p\u003e', + }, + { + title: 'Plan', + hidden: false, + legend: '', + description: 'Time before an issue starts implementation', + id: 'plan', + custom: false, + start_event_html_description: + '\u003cp data-sourcepos="1:1-1:71" dir="auto"\u003eIssue first associated with a milestone or issue first added to a board\u003c/p\u003e', + end_event_html_description: + '\u003cp data-sourcepos="1:1-1:33" dir="auto"\u003eIssue first mentioned in a commit\u003c/p\u003e', + }, + { + title: 'Code', + hidden: false, + legend: '', + description: 'Time until first merge request', + id: 'code', + custom: false, + start_event_html_description: + '\u003cp data-sourcepos="1:1-1:33" dir="auto"\u003eIssue first mentioned in a commit\u003c/p\u003e', + end_event_html_description: + '\u003cp data-sourcepos="1:1-1:21" dir="auto"\u003eMerge request created\u003c/p\u003e', + }, +]; + +export const valueStreamStages = rawValueStreamStages.map((s) => ({ + ...convertObjectPropsToCamelCase(s, { deep: true }), + component: `stage-${s.id}-component`, +})); diff --git a/spec/frontend/cycle_analytics/path_navigation_spec.js b/spec/frontend/cycle_analytics/path_navigation_spec.js new file mode 100644 index 00000000000..c6d72d3b571 --- /dev/null +++ b/spec/frontend/cycle_analytics/path_navigation_spec.js @@ -0,0 +1,148 @@ +import { GlPath, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import Component from '~/cycle_analytics/components/path_navigation.vue'; +import { transformedProjectStagePathData, selectedStage } from './mock_data'; + +describe('Project PathNavigation', () => { + let wrapper = null; + let trackingSpy = null; + + const createComponent = (props) => { + return extendedWrapper( + mount(Component, { + propsData: { + stages: transformedProjectStagePathData, + selectedStage, + loading: false, + ...props, + }, + }), + ); + }; + + const findPathNavigation = () => { + return wrapper.findByTestId('gl-path-nav'); + }; + + const findPathNavigationItems = () => { + return findPathNavigation().findAll('li'); + }; + + const findPathNavigationTitles = () => { + return findPathNavigation() + .findAll('li button') + .wrappers.map((w) => w.html()); + }; + + const clickItemAt = (index) => { + findPathNavigationItems().at(index).find('button').trigger('click'); + }; + + const pathItemContent = () => findPathNavigationItems().wrappers.map(extendedWrapper); + const firstPopover = () => wrapper.findAllByTestId('stage-item-popover').at(0); + + beforeEach(() => { + wrapper = createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + wrapper.destroy(); + wrapper = null; + }); + + describe('displays correctly', () => { + it('has the correct props', () => { + expect(wrapper.find(GlPath).props('items')).toMatchObject(transformedProjectStagePathData); + }); + + it('contains all the expected stages', () => { + const stageContent = findPathNavigationTitles(); + transformedProjectStagePathData.forEach((stage, index) => { + expect(stageContent[index]).toContain(stage.title); + }); + }); + + describe('loading', () => { + describe('is false', () => { + it('displays the gl-path component', () => { + expect(wrapper.find(GlPath).exists()).toBe(true); + }); + + it('hides the gl-skeleton-loading component', () => { + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); + }); + + it('renders each stage', () => { + const result = findPathNavigationTitles(); + expect(result.length).toBe(transformedProjectStagePathData.length); + }); + + it('renders each stage with its median', () => { + const result = findPathNavigationTitles(); + transformedProjectStagePathData.forEach(({ title, metric }, index) => { + expect(result[index]).toContain(title); + expect(result[index]).toContain(metric); + }); + }); + + describe('popovers', () => { + beforeEach(() => { + wrapper = createComponent({ stages: transformedProjectStagePathData }); + }); + + it('renders popovers for all stages', () => { + pathItemContent().forEach((stage) => { + expect(stage.findByTestId('stage-item-popover').exists()).toBe(true); + }); + }); + + it('shows the median stage time for the first stage item', () => { + expect(firstPopover().text()).toContain('Stage time (median)'); + }); + }); + }); + + describe('is true', () => { + beforeEach(() => { + wrapper = createComponent({ loading: true }); + }); + + it('hides the gl-path component', () => { + expect(wrapper.find(GlPath).exists()).toBe(false); + }); + + it('displays the gl-skeleton-loading component', () => { + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + }); + }); + }); + }); + + describe('event handling', () => { + it('emits the selected event', () => { + expect(wrapper.emitted('selected')).toBeUndefined(); + + clickItemAt(0); + clickItemAt(1); + clickItemAt(2); + + expect(wrapper.emitted().selected).toEqual([ + [transformedProjectStagePathData[0]], + [transformedProjectStagePathData[1]], + [transformedProjectStagePathData[2]], + ]); + }); + + it('sends tracking information', () => { + clickItemAt(0); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_path_navigation', { + extra: { stage_id: selectedStage.slug }, + }); + }); + }); +}); diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 630c5100754..4f37e1266fb 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -3,10 +3,27 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/cycle_analytics/store/actions'; import httpStatusCodes from '~/lib/utils/http_status'; -import { selectedStage } from '../mock_data'; +import { selectedStage, selectedValueStream } from '../mock_data'; const mockRequestPath = 'some/cool/path'; +const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; const mockStartDate = 30; +const mockRequestedDataActions = ['fetchValueStreams', 'fetchCycleAnalyticsData']; +const mockInitializeActionCommit = { + payload: { requestPath: mockRequestPath }, + type: 'INITIALIZE_VSA', +}; +const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' }; +const mockRequestedDataMutations = [ + { + payload: true, + type: 'SET_LOADING', + }, + { + payload: false, + type: 'SET_LOADING', + }, +]; describe('Project Value Stream Analytics actions', () => { let state; @@ -22,27 +39,26 @@ describe('Project Value Stream Analytics actions', () => { state = {}; }); - it.each` - action | type | payload | expectedActions - ${'initializeVsa'} | ${'INITIALIZE_VSA'} | ${{ requestPath: mockRequestPath }} | ${['fetchCycleAnalyticsData']} - ${'setDateRange'} | ${'SET_DATE_RANGE'} | ${{ startDate: 30 }} | ${[]} - ${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${{ selectedStage }} | ${[]} - `( - '$action should dispatch $expectedActions and commit $type', - ({ action, type, payload, expectedActions }) => + const mutationTypes = (arr) => arr.map(({ type }) => type); + + describe.each` + action | payload | expectedActions | expectedMutations + ${'initializeVsa'} | ${{ requestPath: mockRequestPath }} | ${mockRequestedDataActions} | ${[mockInitializeActionCommit, ...mockRequestedDataMutations]} + ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockRequestedDataActions} | ${[mockSetDateActionCommit, ...mockRequestedDataMutations]} + ${'setSelectedStage'} | ${{ selectedStage }} | ${['fetchStageData']} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} + ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${['fetchValueStreamStages']} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} + `('$action', ({ action, payload, expectedActions, expectedMutations }) => { + const types = mutationTypes(expectedMutations); + + it(`will dispatch ${expectedActions} and commit ${types}`, () => testAction({ action: actions[action], state, payload, - expectedMutations: [ - { - type, - payload, - }, - ], + expectedMutations, expectedActions: expectedActions.map((a) => ({ type: a })), - }), - ); + })); + }); describe('fetchCycleAnalyticsData', () => { beforeEach(() => { @@ -60,7 +76,7 @@ describe('Project Value Stream Analytics actions', () => { { type: 'REQUEST_CYCLE_ANALYTICS_DATA' }, { type: 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS' }, ], - expectedActions: [{ type: 'setSelectedStage' }, { type: 'fetchStageData' }], + expectedActions: [], })); describe('with a failing request', () => { @@ -85,7 +101,7 @@ describe('Project Value Stream Analytics actions', () => { }); describe('fetchStageData', () => { - const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}.json`; + const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}`; beforeEach(() => { state = { @@ -106,6 +122,32 @@ describe('Project Value Stream Analytics actions', () => { expectedActions: [], })); + describe('with a successful request, but an error in the payload', () => { + const tooMuchDataError = 'Too much data'; + + beforeEach(() => { + state = { + requestPath: mockRequestPath, + startDate: mockStartDate, + selectedStage, + }; + mock = new MockAdapter(axios); + mock.onGet(mockStagePath).reply(httpStatusCodes.OK, { error: tooMuchDataError }); + }); + + it(`commits the 'RECEIVE_STAGE_DATA_ERROR' mutation`, () => + testAction({ + action: actions.fetchStageData, + state, + payload: { error: tooMuchDataError }, + expectedMutations: [ + { type: 'REQUEST_STAGE_DATA' }, + { type: 'RECEIVE_STAGE_DATA_ERROR', payload: tooMuchDataError }, + ], + expectedActions: [], + })); + }); + describe('with a failing request', () => { beforeEach(() => { state = { @@ -127,4 +169,115 @@ describe('Project Value Stream Analytics actions', () => { })); }); }); + + describe('fetchValueStreams', () => { + const mockValueStreamPath = /\/analytics\/value_stream_analytics\/value_streams/; + + beforeEach(() => { + state = { + fullPath: mockFullPath, + }; + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + }); + + it(`commits the 'REQUEST_VALUE_STREAMS' mutation`, () => + testAction({ + action: actions.fetchValueStreams, + state, + payload: {}, + expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], + expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }], + })); + + describe('with a failing request', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + }); + + it(`commits the 'RECEIVE_VALUE_STREAMS_ERROR' mutation`, () => + testAction({ + action: actions.fetchValueStreams, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_VALUE_STREAMS' }, + { type: 'RECEIVE_VALUE_STREAMS_ERROR', payload: httpStatusCodes.BAD_REQUEST }, + ], + expectedActions: [], + })); + }); + }); + + describe('receiveValueStreamsSuccess', () => { + const mockValueStream = { + id: 'mockDefault', + name: 'mock default', + }; + const mockValueStreams = [mockValueStream, selectedValueStream]; + it('with data, will set the first value stream', () => { + testAction({ + action: actions.receiveValueStreamsSuccess, + state, + payload: mockValueStreams, + expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: mockValueStreams }], + expectedActions: [{ type: 'setSelectedValueStream', payload: mockValueStream }], + }); + }); + + it('without data, will set the default value stream', () => { + testAction({ + action: actions.receiveValueStreamsSuccess, + state, + payload: [], + expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: [] }], + expectedActions: [{ type: 'setSelectedValueStream', payload: selectedValueStream }], + }); + }); + }); + + describe('fetchValueStreamStages', () => { + const mockValueStreamPath = /\/analytics\/value_stream_analytics\/value_streams/; + + beforeEach(() => { + state = { + fullPath: mockFullPath, + selectedValueStream, + }; + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + }); + + it(`commits the 'REQUEST_VALUE_STREAM_STAGES' and 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS' mutations`, () => + testAction({ + action: actions.fetchValueStreamStages, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_VALUE_STREAM_STAGES' }, + { type: 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS' }, + ], + expectedActions: [], + })); + + describe('with a failing request', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + }); + + it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () => + testAction({ + action: actions.fetchValueStreamStages, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_VALUE_STREAM_STAGES' }, + { type: 'RECEIVE_VALUE_STREAM_STAGES_ERROR', payload: httpStatusCodes.BAD_REQUEST }, + ], + expectedActions: [], + })); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/store/getters_spec.js b/spec/frontend/cycle_analytics/store/getters_spec.js new file mode 100644 index 00000000000..5745e9d7902 --- /dev/null +++ b/spec/frontend/cycle_analytics/store/getters_spec.js @@ -0,0 +1,16 @@ +import * as getters from '~/cycle_analytics/store/getters'; +import { + allowedStages, + stageMedians, + transformedProjectStagePathData, + selectedStage, +} from '../mock_data'; + +describe('Value stream analytics getters', () => { + describe('pathNavigationData', () => { + it('returns the transformed data', () => { + const state = { stages: allowedStages, medians: stageMedians, selectedStage }; + expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData); + }); + }); +}); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 08c70af6ef6..88e1a13f506 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -1,6 +1,15 @@ import * as types from '~/cycle_analytics/store/mutation_types'; import mutations from '~/cycle_analytics/store/mutations'; -import { selectedStage, rawEvents, convertedEvents, rawData, convertedData } from '../mock_data'; +import { + selectedStage, + rawEvents, + convertedEvents, + rawData, + convertedData, + selectedValueStream, + rawValueStreamStages, + valueStreamStages, +} from '../mock_data'; let state; const mockRequestPath = 'fake/request/path'; @@ -17,15 +26,15 @@ describe('Project Value Stream Analytics mutations', () => { it.each` mutation | stateKey | value - ${types.SET_SELECTED_STAGE} | ${'isLoadingStage'} | ${false} + ${types.REQUEST_VALUE_STREAMS} | ${'valueStreams'} | ${[]} + ${types.RECEIVE_VALUE_STREAMS_ERROR} | ${'valueStreams'} | ${[]} + ${types.REQUEST_VALUE_STREAM_STAGES} | ${'stages'} | ${[]} + ${types.RECEIVE_VALUE_STREAM_STAGES_ERROR} | ${'stages'} | ${[]} ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true} - ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'stages'} | ${[]} ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'hasError'} | ${false} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${'isLoading'} | ${false} ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${'hasError'} | ${false} ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'isLoading'} | ${false} ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'hasError'} | ${true} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'stages'} | ${[]} ${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true} ${types.REQUEST_STAGE_DATA} | ${'isEmptyStage'} | ${false} ${types.REQUEST_STAGE_DATA} | ${'hasError'} | ${false} @@ -44,12 +53,15 @@ describe('Project Value Stream Analytics mutations', () => { }); it.each` - mutation | payload | stateKey | value - ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} - ${types.SET_SELECTED_STAGE} | ${selectedStage} | ${'selectedStage'} | ${selectedStage} - ${types.SET_DATE_RANGE} | ${{ startDate: mockStartData }} | ${'startDate'} | ${mockStartData} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'stages'} | ${convertedData.stages} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} + mutation | payload | stateKey | value + ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} + ${types.SET_DATE_RANGE} | ${{ startDate: mockStartData }} | ${'startDate'} | ${mockStartData} + ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} + ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} + ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} + ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} + ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index 73e26e1cdcc..15137bb0571 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -1,5 +1,22 @@ -import { decorateEvents, decorateData } from '~/cycle_analytics/utils'; -import { selectedStage, rawData, convertedData, rawEvents } from './mock_data'; +import { + decorateEvents, + decorateData, + transformStagesForPathNavigation, + timeSummaryForPathNavigation, + medianTimeToParsedSeconds, + formatMedianValues, + filterStagesByHiddenStatus, +} from '~/cycle_analytics/utils'; +import { + selectedStage, + rawData, + convertedData, + rawEvents, + allowedStages, + stageMedians, + pathNavIssueMetric, + rawStageMedians, +} from './mock_data'; describe('Value stream analytics utils', () => { describe('decorateEvents', () => { @@ -36,17 +53,6 @@ describe('Value stream analytics utils', () => { expect(result.summary).toEqual(convertedData.summary); }); - it('returns the stages data', () => { - expect(result.stages).toEqual(convertedData.stages); - }); - - it('returns each of the default value stream stages', () => { - const stages = result.stages.map(({ name }) => name); - ['issue', 'plan', 'code', 'test', 'review', 'staging'].forEach((stageName) => { - expect(stages).toContain(stageName); - }); - }); - it('returns `-` for summary data that has no value', () => { const singleSummaryResult = decorateData({ stats: [], @@ -55,23 +61,92 @@ describe('Value stream analytics utils', () => { }); expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]); }); + }); + + describe('transformStagesForPathNavigation', () => { + const stages = allowedStages; + const response = transformStagesForPathNavigation({ + stages, + medians: stageMedians, + selectedStage, + }); + + describe('transforms the data as expected', () => { + it('returns an array of stages', () => { + expect(Array.isArray(response)).toBe(true); + expect(response.length).toBe(stages.length); + }); + + it('selects the correct stage', () => { + const selected = response.filter((stage) => stage.selected === true)[0]; + + expect(selected.title).toBe(selectedStage.title); + }); + + it('includes the correct metric for the associated stage', () => { + const issue = response.filter((stage) => stage.name === 'issue')[0]; - it('returns additional fields for each stage', () => { - const singleStageResult = decorateData({ - stats: [{ name: 'issue', value: null }], - permissions: { issue: false }, + expect(issue.metric).toBe(pathNavIssueMetric); }); - const stage = singleStageResult.stages[0]; - const txt = - 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'; - - expect(stage).toMatchObject({ - active: false, - isUserAllowed: false, - emptyStageText: txt, - slug: 'issue', - component: 'stage-issue-component', + }); + }); + + describe('timeSummaryForPathNavigation', () => { + it.each` + unit | value | result + ${'months'} | ${1.5} | ${'1.5M'} + ${'weeks'} | ${1.25} | ${'1.5w'} + ${'days'} | ${2} | ${'2d'} + ${'hours'} | ${10} | ${'10h'} + ${'minutes'} | ${20} | ${'20m'} + ${'seconds'} | ${10} | ${'<1m'} + ${'seconds'} | ${0} | ${'-'} + `('will format $value $unit to $result', ({ unit, value, result }) => { + expect(timeSummaryForPathNavigation({ [unit]: value })).toBe(result); + }); + }); + + describe('medianTimeToParsedSeconds', () => { + it.each` + value | result + ${1036800} | ${'1w'} + ${259200} | ${'3d'} + ${172800} | ${'2d'} + ${86400} | ${'1d'} + ${1000} | ${'16m'} + ${61} | ${'1m'} + ${59} | ${'<1m'} + ${0} | ${'-'} + `('will correctly parse $value seconds into $result', ({ value, result }) => { + expect(medianTimeToParsedSeconds(value)).toBe(result); + }); + }); + + describe('formatMedianValues', () => { + const calculatedMedians = formatMedianValues(rawStageMedians); + + it('returns an object with each stage and their median formatted for display', () => { + rawStageMedians.forEach(({ id, value }) => { + expect(calculatedMedians).toMatchObject({ [id]: medianTimeToParsedSeconds(value) }); }); }); }); + + describe('filterStagesByHiddenStatus', () => { + const hiddenStages = [{ title: 'three', hidden: true }]; + const visibleStages = [ + { title: 'one', hidden: false }, + { title: 'two', hidden: false }, + ]; + const mockStages = [...visibleStages, ...hiddenStages]; + + it.each` + isHidden | result + ${false} | ${visibleStages} + ${undefined} | ${hiddenStages} + ${true} | ${hiddenStages} + `('with isHidden=$isHidden returns matching stages', ({ isHidden, result }) => { + expect(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result); + }); + }); }); diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js index 9c784f3c5a2..6bc9c4d374c 100644 --- a/spec/frontend/deploy_freeze/store/actions_spec.js +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -4,7 +4,7 @@ import Api from '~/api'; import * as actions from '~/deploy_freeze/store/actions'; import * as types from '~/deploy_freeze/store/mutation_types'; import getInitialState from '~/deploy_freeze/store/state'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; @@ -189,9 +189,9 @@ describe('deploy freeze store actions', () => { [{ type: types.REQUEST_FREEZE_PERIODS }], [], () => - expect(createFlash).toHaveBeenCalledWith( - 'There was an error fetching the deploy freezes.', - ), + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the deploy freezes.', + }), ); }); }); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index db4d69f0176..7012889440c 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -73,6 +73,7 @@ describe('DiffContent', () => { isParallelView: isParallelViewGetterMock, getCommentFormForDiffFile: getCommentFormForDiffFileGetterMock, diffLines: () => () => [...diffFileMockData.parallel_diff_lines], + fileLineCodequality: () => () => [], }, actions: { saveDiffDiscussion: saveDiffDiscussionMock, diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js index 504158fb7fc..4ef1ec55cb0 100644 --- a/spec/frontend/diffs/components/diff_stats_spec.js +++ b/spec/frontend/diffs/components/diff_stats_spec.js @@ -1,6 +1,9 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + import DiffStats from '~/diffs/components/diff_stats.vue'; +import mockDiffFile from '../mock_data/diff_file'; const TEST_ADDED_LINES = 100; const TEST_REMOVED_LINES = 200; @@ -11,13 +14,15 @@ describe('diff_stats', () => { let wrapper; const createComponent = (props = {}) => { - wrapper = shallowMount(DiffStats, { - propsData: { - addedLines: TEST_ADDED_LINES, - removedLines: TEST_REMOVED_LINES, - ...props, - }, - }); + wrapper = extendedWrapper( + shallowMount(DiffStats, { + propsData: { + addedLines: TEST_ADDED_LINES, + removedLines: TEST_REMOVED_LINES, + ...props, + }, + }), + ); }; describe('diff stats group', () => { @@ -38,15 +43,43 @@ describe('diff_stats', () => { }); }); + describe('bytes changes', () => { + let file; + const getBytesContainer = () => wrapper.find('.diff-stats > div:first-child'); + + beforeEach(() => { + file = { + ...mockDiffFile, + viewer: { + ...mockDiffFile.viewer, + name: 'not_diffable', + }, + }; + + createComponent({ diffFile: file }); + }); + + it("renders the bytes changes instead of line changes when the file isn't diffable", () => { + const content = getBytesContainer(); + + expect(content.classes('gl-text-green-600')).toBe(true); + expect(content.text()).toBe('+1.00 KiB (+100%)'); + }); + }); + describe('line changes', () => { - const findFileLine = (name) => wrapper.find(name); + const findFileLine = (name) => wrapper.findByTestId(name); + + beforeEach(() => { + createComponent(); + }); it('shows the amount of lines added', () => { - expect(findFileLine('.js-file-addition-line').text()).toBe(TEST_ADDED_LINES.toString()); + expect(findFileLine('js-file-addition-line').text()).toBe(TEST_ADDED_LINES.toString()); }); it('shows the amount of lines removed', () => { - expect(findFileLine('.js-file-deletion-line').text()).toBe(TEST_REMOVED_LINES.toString()); + expect(findFileLine('js-file-deletion-line').text()).toBe(TEST_REMOVED_LINES.toString()); }); }); diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index feac88cb802..43b9c5871a6 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -142,7 +142,6 @@ describe('Diff settings dropdown component', () => { expect(store.dispatch).toHaveBeenCalledWith('diffs/setShowWhitespace', { showWhitespace: !checked, - pushState: true, }); }); }); diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js index cef776c885a..9ebcd5ef26b 100644 --- a/spec/frontend/diffs/mock_data/diff_file.js +++ b/spec/frontend/diffs/mock_data/diff_file.js @@ -19,6 +19,8 @@ export default { renamed_file: false, old_path: 'CHANGELOG', new_path: 'CHANGELOG', + old_size: 1024, + new_size: 2048, mode_changed: false, a_mode: '100644', b_mode: '100644', diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index f46a42fae7a..14f8e090be9 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -9,8 +9,6 @@ import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, DIFFS_PER_PAGE, - DIFF_WHITESPACE_COOKIE_NAME, - SHOW_WHITESPACE, } from '~/diffs/constants'; import { setBaseConfig, @@ -54,7 +52,8 @@ import { } from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import * as workerUtils from '~/diffs/utils/workers'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -252,7 +251,10 @@ describe('DiffsStoreActions', () => { { type: types.SET_MERGE_REQUEST_DIFFS, payload: diffMetadata.merge_request_diffs }, { type: types.SET_DIFF_METADATA, payload: noFilesData }, // Workers are synchronous in Jest environment (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58805) - { type: types.SET_TREE_DATA, payload: utils.generateTreeList(diffMetadata.diff_files) }, + { + type: types.SET_TREE_DATA, + payload: workerUtils.generateTreeList(diffMetadata.diff_files), + }, ], [], () => { @@ -293,7 +295,9 @@ describe('DiffsStoreActions', () => { testAction(fetchCoverageFiles, {}, { endpointCoverage }, [], [], () => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(expect.stringMatching('Something went wrong')); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), + }); done(); }); }); @@ -1013,14 +1017,26 @@ describe('DiffsStoreActions', () => { }); describe('setShowWhitespace', () => { + const endpointUpdateUser = 'user/prefs'; + let putSpy; + let mock; + beforeEach(() => { + mock = new MockAdapter(axios); + putSpy = jest.spyOn(axios, 'put'); + + mock.onPut(endpointUpdateUser).reply(200, {}); jest.spyOn(eventHub, '$emit').mockImplementation(); }); + afterEach(() => { + mock.restore(); + }); + it('commits SET_SHOW_WHITESPACE', (done) => { testAction( setShowWhitespace, - { showWhitespace: true }, + { showWhitespace: true, updateDatabase: false }, {}, [{ type: types.SET_SHOW_WHITESPACE, payload: true }], [], @@ -1028,32 +1044,20 @@ describe('DiffsStoreActions', () => { ); }); - it('sets cookie', () => { - setShowWhitespace({ commit() {} }, { showWhitespace: true }); - - expect(Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)).toEqual(SHOW_WHITESPACE); - }); - - it('calls history pushState', () => { - setShowWhitespace({ commit() {} }, { showWhitespace: true, pushState: true }); - - expect(window.history.pushState).toHaveBeenCalled(); - }); - - it('calls history pushState with merged params', () => { - window.history.pushState({}, '', '?test=1'); - - setShowWhitespace({ commit() {} }, { showWhitespace: true, pushState: true }); - - expect( - window.history.pushState.mock.calls[window.history.pushState.mock.calls.length - 1][2], - ).toMatch(/(.*)\?test=1&w=0/); + it('saves to the database', async () => { + await setShowWhitespace( + { state: { endpointUpdateUser }, commit() {} }, + { showWhitespace: true, updateDatabase: true }, + ); - window.history.pushState({}, '', '?'); + expect(putSpy).toHaveBeenCalledWith(endpointUpdateUser, { show_whitespace_in_diffs: true }); }); - it('emits eventHub event', () => { - setShowWhitespace({ commit() {} }, { showWhitespace: true, pushState: true }); + it('emits eventHub event', async () => { + await setShowWhitespace( + { state: {}, commit() {} }, + { showWhitespace: true, updateDatabase: false }, + ); expect(eventHub.$emit).toHaveBeenCalledWith('refetchDiffData'); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 6af38590610..73de0a6d381 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -685,141 +685,6 @@ describe('DiffsStoreUtils', () => { }); }); - describe('generateTreeList', () => { - let files; - - beforeAll(() => { - files = [ - { - new_path: 'app/index.js', - deleted_file: false, - new_file: false, - removed_lines: 10, - added_lines: 0, - file_hash: 'test', - }, - { - new_path: 'app/test/index.js', - deleted_file: false, - new_file: true, - removed_lines: 0, - added_lines: 0, - file_hash: 'test', - }, - { - new_path: 'app/test/filepathneedstruncating.js', - deleted_file: false, - new_file: true, - removed_lines: 0, - added_lines: 0, - file_hash: 'test', - }, - { - new_path: 'package.json', - deleted_file: true, - new_file: false, - removed_lines: 0, - added_lines: 0, - file_hash: 'test', - }, - ]; - }); - - it('creates a tree of files', () => { - const { tree } = utils.generateTreeList(files); - - expect(tree).toEqual([ - { - key: 'app', - path: 'app', - name: 'app', - type: 'tree', - tree: [ - { - addedLines: 0, - changed: true, - deleted: false, - fileHash: 'test', - key: 'app/index.js', - name: 'index.js', - parentPath: 'app/', - path: 'app/index.js', - removedLines: 10, - tempFile: false, - type: 'blob', - tree: [], - }, - { - key: 'app/test', - path: 'app/test', - name: 'test', - type: 'tree', - opened: true, - tree: [ - { - addedLines: 0, - changed: true, - deleted: false, - fileHash: 'test', - key: 'app/test/index.js', - name: 'index.js', - parentPath: 'app/test/', - path: 'app/test/index.js', - removedLines: 0, - tempFile: true, - type: 'blob', - tree: [], - }, - { - addedLines: 0, - changed: true, - deleted: false, - fileHash: 'test', - key: 'app/test/filepathneedstruncating.js', - name: 'filepathneedstruncating.js', - parentPath: 'app/test/', - path: 'app/test/filepathneedstruncating.js', - removedLines: 0, - tempFile: true, - type: 'blob', - tree: [], - }, - ], - }, - ], - opened: true, - }, - { - key: 'package.json', - parentPath: '/', - path: 'package.json', - name: 'package.json', - type: 'blob', - changed: true, - tempFile: false, - deleted: true, - fileHash: 'test', - addedLines: 0, - removedLines: 0, - tree: [], - }, - ]); - }); - - it('creates flat list of blobs & folders', () => { - const { treeEntries } = utils.generateTreeList(files); - - expect(Object.keys(treeEntries)).toEqual([ - 'app', - 'app/index.js', - 'app/test', - 'app/test/index.js', - 'app/test/filepathneedstruncating.js', - 'package.json', - ]); - }); - }); - describe('getDiffMode', () => { it('returns mode when matched in file', () => { expect( @@ -842,177 +707,6 @@ describe('DiffsStoreUtils', () => { }); }); - describe('getLowestSingleFolder', () => { - it('returns path and tree of lowest single folder tree', () => { - const folder = { - name: 'app', - type: 'tree', - tree: [ - { - name: 'javascripts', - type: 'tree', - tree: [ - { - type: 'blob', - name: 'index.js', - }, - ], - }, - ], - }; - const { path, treeAcc } = utils.getLowestSingleFolder(folder); - - expect(path).toEqual('app/javascripts'); - expect(treeAcc).toEqual([ - { - type: 'blob', - name: 'index.js', - }, - ]); - }); - - it('returns passed in folders path & tree when more than tree exists', () => { - const folder = { - name: 'app', - type: 'tree', - tree: [ - { - name: 'spec', - type: 'blob', - tree: [], - }, - ], - }; - const { path, treeAcc } = utils.getLowestSingleFolder(folder); - - expect(path).toEqual('app'); - expect(treeAcc).toBeNull(); - }); - }); - - describe('flattenTree', () => { - it('returns flattened directory structure', () => { - const tree = [ - { - type: 'tree', - name: 'app', - tree: [ - { - type: 'tree', - name: 'javascripts', - tree: [ - { - type: 'blob', - name: 'index.js', - tree: [], - }, - ], - }, - ], - }, - { - type: 'tree', - name: 'ee', - tree: [ - { - type: 'tree', - name: 'lib', - tree: [ - { - type: 'tree', - name: 'ee', - tree: [ - { - type: 'tree', - name: 'gitlab', - tree: [ - { - type: 'tree', - name: 'checks', - tree: [ - { - type: 'tree', - name: 'longtreenametomakepath', - tree: [ - { - type: 'blob', - name: 'diff_check.rb', - tree: [], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - { - type: 'tree', - name: 'spec', - tree: [ - { - type: 'tree', - name: 'javascripts', - tree: [], - }, - { - type: 'blob', - name: 'index_spec.js', - tree: [], - }, - ], - }, - ]; - const flattened = utils.flattenTree(tree); - - expect(flattened).toEqual([ - { - type: 'tree', - name: 'app/javascripts', - tree: [ - { - type: 'blob', - name: 'index.js', - tree: [], - }, - ], - }, - { - type: 'tree', - name: 'ee/lib/…/…/…/longtreenametomakepath', - tree: [ - { - name: 'diff_check.rb', - tree: [], - type: 'blob', - }, - ], - }, - { - type: 'tree', - name: 'spec', - tree: [ - { - type: 'tree', - name: 'javascripts', - tree: [], - }, - { - type: 'blob', - name: 'index_spec.js', - tree: [], - }, - ], - }, - ]); - }); - }); - describe('convertExpandLines', () => { it('converts expanded lines to normal lines', () => { const diffLines = [ @@ -1058,28 +752,6 @@ describe('DiffsStoreUtils', () => { }); }); - describe('getDefaultWhitespace', () => { - it('defaults to true if querystring and cookie are undefined', () => { - expect(utils.getDefaultWhitespace()).toBe(true); - }); - - it('returns false if querystring is `1`', () => { - expect(utils.getDefaultWhitespace('1', '0')).toBe(false); - }); - - it('returns true if querystring is `0`', () => { - expect(utils.getDefaultWhitespace('0', undefined)).toBe(true); - }); - - it('returns false if cookie is `1`', () => { - expect(utils.getDefaultWhitespace(undefined, '1')).toBe(false); - }); - - it('returns true if cookie is `0`', () => { - expect(utils.getDefaultWhitespace(undefined, '0')).toBe(true); - }); - }); - describe('isAdded', () => { it.each` type | expected diff --git a/spec/frontend/diffs/utils/diff_file_spec.js b/spec/frontend/diffs/utils/diff_file_spec.js index c6cfdfced65..3223b6c2dab 100644 --- a/spec/frontend/diffs/utils/diff_file_spec.js +++ b/spec/frontend/diffs/utils/diff_file_spec.js @@ -1,4 +1,11 @@ -import { prepareRawDiffFile, getShortShaFromFile } from '~/diffs/utils/diff_file'; +import { + prepareRawDiffFile, + getShortShaFromFile, + stats, + isNotDiffable, +} from '~/diffs/utils/diff_file'; +import { diffViewerModes } from '~/ide/constants'; +import mockDiffFile from '../mock_data/diff_file'; function getDiffFiles() { const loadFull = 'namespace/project/-/merge_requests/12345/diff_for_path?file_identifier=abc'; @@ -154,4 +161,73 @@ describe('diff_file utilities', () => { expect(getShortShaFromFile({ content_sha: cs })).toBe(response); }); }); + + describe('stats', () => { + const noFile = [ + "returns empty stats when the file isn't provided", + undefined, + { + text: '', + percent: 0, + changed: 0, + classes: '', + sign: '', + valid: false, + }, + ]; + const validFile = [ + 'computes the correct stats from a file', + mockDiffFile, + { + changed: 1024, + percent: 100, + classes: 'gl-text-green-600', + sign: '+', + text: '+1.00 KiB (+100%)', + valid: true, + }, + ]; + const negativeChange = [ + 'computed the correct states from a file with a negative size change', + { + ...mockDiffFile, + new_size: 0, + old_size: 1024, + }, + { + changed: -1024, + percent: -100, + classes: 'gl-text-red-500', + sign: '', + text: '-1.00 KiB (-100%)', + valid: true, + }, + ]; + + it.each([noFile, validFile, negativeChange])('%s', (_, file, output) => { + expect(stats(file)).toEqual(output); + }); + }); + + describe('isNotDiffable', () => { + it.each` + bool | vw + ${true} | ${diffViewerModes.not_diffable} + ${false} | ${diffViewerModes.text} + ${false} | ${diffViewerModes.image} + `('returns $bool when the viewer is $vw', ({ bool, vw }) => { + expect(isNotDiffable({ viewer: { name: vw } })).toBe(bool); + }); + + it.each` + file + ${undefined} + ${null} + ${{}} + ${{ viewer: undefined }} + ${{ viewer: null }} + `('reports `false` when the file is `$file`', ({ file }) => { + expect(isNotDiffable(file)).toBe(false); + }); + }); }); diff --git a/spec/frontend/diffs/utils/workers_spec.js b/spec/frontend/diffs/utils/workers_spec.js new file mode 100644 index 00000000000..25d8183b777 --- /dev/null +++ b/spec/frontend/diffs/utils/workers_spec.js @@ -0,0 +1,309 @@ +import { generateTreeList, getLowestSingleFolder, flattenTree } from '~/diffs/utils/workers'; + +describe('~/diffs/utils/workers', () => { + describe('generateTreeList', () => { + let files; + + beforeAll(() => { + files = [ + { + new_path: 'app/index.js', + deleted_file: false, + new_file: false, + removed_lines: 10, + added_lines: 0, + file_hash: 'test', + }, + { + new_path: 'app/test/index.js', + deleted_file: false, + new_file: true, + removed_lines: 0, + added_lines: 0, + file_hash: 'test', + }, + { + new_path: 'app/test/filepathneedstruncating.js', + deleted_file: false, + new_file: true, + removed_lines: 0, + added_lines: 0, + file_hash: 'test', + }, + { + new_path: 'package.json', + deleted_file: true, + new_file: false, + removed_lines: 0, + added_lines: 0, + file_hash: 'test', + }, + ]; + }); + + it('creates a tree of files', () => { + const { tree } = generateTreeList(files); + + expect(tree).toEqual([ + { + key: 'app', + path: 'app', + name: 'app', + type: 'tree', + tree: [ + { + addedLines: 0, + changed: true, + deleted: false, + fileHash: 'test', + key: 'app/index.js', + name: 'index.js', + parentPath: 'app/', + path: 'app/index.js', + removedLines: 10, + tempFile: false, + type: 'blob', + tree: [], + }, + { + key: 'app/test', + path: 'app/test', + name: 'test', + type: 'tree', + opened: true, + tree: [ + { + addedLines: 0, + changed: true, + deleted: false, + fileHash: 'test', + key: 'app/test/index.js', + name: 'index.js', + parentPath: 'app/test/', + path: 'app/test/index.js', + removedLines: 0, + tempFile: true, + type: 'blob', + tree: [], + }, + { + addedLines: 0, + changed: true, + deleted: false, + fileHash: 'test', + key: 'app/test/filepathneedstruncating.js', + name: 'filepathneedstruncating.js', + parentPath: 'app/test/', + path: 'app/test/filepathneedstruncating.js', + removedLines: 0, + tempFile: true, + type: 'blob', + tree: [], + }, + ], + }, + ], + opened: true, + }, + { + key: 'package.json', + parentPath: '/', + path: 'package.json', + name: 'package.json', + type: 'blob', + changed: true, + tempFile: false, + deleted: true, + fileHash: 'test', + addedLines: 0, + removedLines: 0, + tree: [], + }, + ]); + }); + + it('creates flat list of blobs & folders', () => { + const { treeEntries } = generateTreeList(files); + + expect(Object.keys(treeEntries)).toEqual([ + 'app', + 'app/index.js', + 'app/test', + 'app/test/index.js', + 'app/test/filepathneedstruncating.js', + 'package.json', + ]); + }); + }); + + describe('getLowestSingleFolder', () => { + it('returns path and tree of lowest single folder tree', () => { + const folder = { + name: 'app', + type: 'tree', + tree: [ + { + name: 'javascripts', + type: 'tree', + tree: [ + { + type: 'blob', + name: 'index.js', + }, + ], + }, + ], + }; + const { path, treeAcc } = getLowestSingleFolder(folder); + + expect(path).toEqual('app/javascripts'); + expect(treeAcc).toEqual([ + { + type: 'blob', + name: 'index.js', + }, + ]); + }); + + it('returns passed in folders path & tree when more than tree exists', () => { + const folder = { + name: 'app', + type: 'tree', + tree: [ + { + name: 'spec', + type: 'blob', + tree: [], + }, + ], + }; + const { path, treeAcc } = getLowestSingleFolder(folder); + + expect(path).toEqual('app'); + expect(treeAcc).toBeNull(); + }); + }); + + describe('flattenTree', () => { + it('returns flattened directory structure', () => { + const tree = [ + { + type: 'tree', + name: 'app', + tree: [ + { + type: 'tree', + name: 'javascripts', + tree: [ + { + type: 'blob', + name: 'index.js', + tree: [], + }, + ], + }, + ], + }, + { + type: 'tree', + name: 'ee', + tree: [ + { + type: 'tree', + name: 'lib', + tree: [ + { + type: 'tree', + name: 'ee', + tree: [ + { + type: 'tree', + name: 'gitlab', + tree: [ + { + type: 'tree', + name: 'checks', + tree: [ + { + type: 'tree', + name: 'longtreenametomakepath', + tree: [ + { + type: 'blob', + name: 'diff_check.rb', + tree: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'tree', + name: 'spec', + tree: [ + { + type: 'tree', + name: 'javascripts', + tree: [], + }, + { + type: 'blob', + name: 'index_spec.js', + tree: [], + }, + ], + }, + ]; + const flattened = flattenTree(tree); + + expect(flattened).toEqual([ + { + type: 'tree', + name: 'app/javascripts', + tree: [ + { + type: 'blob', + name: 'index.js', + tree: [], + }, + ], + }, + { + type: 'tree', + name: 'ee/lib/…/…/…/longtreenametomakepath', + tree: [ + { + name: 'diff_check.rb', + tree: [], + type: 'blob', + }, + ], + }, + { + type: 'tree', + name: 'spec', + tree: [ + { + type: 'tree', + name: 'javascripts', + tree: [], + }, + { + type: 'blob', + name: 'index_spec.js', + tree: [], + }, + ], + }, + ]); + }); + }); +}); diff --git a/spec/frontend/editor/editor_ci_schema_ext_spec.js b/spec/frontend/editor/editor_ci_schema_ext_spec.js index 17a9ae7335f..2f0ecfb151e 100644 --- a/spec/frontend/editor/editor_ci_schema_ext_spec.js +++ b/spec/frontend/editor/editor_ci_schema_ext_spec.js @@ -4,6 +4,8 @@ import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '~/editor/constants'; import EditorLite from '~/editor/editor_lite'; import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext'; +const mockRef = 'AABBCCDD'; + describe('~/editor/editor_ci_config_ext', () => { const defaultBlobPath = '.gitlab-ci.yml'; @@ -75,8 +77,6 @@ describe('~/editor/editor_ci_config_ext', () => { }); it('with an schema uri that contains project and ref', () => { - const mockRef = 'AABBCCDD'; - instance.registerCiSchema({ projectNamespace: mockProjectNamespace, projectPath: mockProjectPath, @@ -95,10 +95,11 @@ describe('~/editor/editor_ci_config_ext', () => { instance.registerCiSchema({ projectNamespace: mockProjectNamespace, projectPath: mockProjectPath, + ref: mockRef, }); expect(getConfiguredYmlSchema()).toEqual({ - uri: `${TEST_HOST}/${mockProjectNamespace}/${mockProjectPath}/-/schema/master/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`, + uri: `${TEST_HOST}/${mockProjectNamespace}/${mockProjectPath}/-/schema/${mockRef}/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`, fileMatch: ['another-ci-filename.yml'], }); }); diff --git a/spec/frontend/emoji/awards_app/store/actions_spec.js b/spec/frontend/emoji/awards_app/store/actions_spec.js index dac4fded260..e96920d1112 100644 --- a/spec/frontend/emoji/awards_app/store/actions_spec.js +++ b/spec/frontend/emoji/awards_app/store/actions_spec.js @@ -7,6 +7,10 @@ import axios from '~/lib/utils/axios_utils'; jest.mock('@sentry/browser'); describe('Awards app actions', () => { + afterEach(() => { + window.gon = {}; + }); + describe('setInitialData', () => { it('commits SET_INITIAL_DATA', async () => { await testAction( @@ -31,21 +35,36 @@ describe('Awards app actions', () => { }); describe('success', () => { - beforeEach(() => { - mock - .onGet('/awards', { params: { per_page: 100, page: '1' } }) - .reply(200, ['thumbsup'], { 'x-next-page': '2' }); - mock.onGet('/awards', { params: { per_page: 100, page: '2' } }).reply(200, ['thumbsdown']); - }); + describe.each` + relativeRootUrl + ${null} + ${'/gitlab'} + `('with relative_root_url as $relativeRootUrl', ({ relativeRootUrl }) => { + beforeEach(() => { + window.gon = { relative_url_root: relativeRootUrl }; + mock + .onGet(`${relativeRootUrl || ''}/awards`, { params: { per_page: 100, page: '1' } }) + .reply(200, ['thumbsup'], { 'x-next-page': '2' }); + mock + .onGet(`${relativeRootUrl || ''}/awards`, { params: { per_page: 100, page: '2' } }) + .reply(200, ['thumbsdown']); + }); + + it('commits FETCH_AWARDS_SUCCESS', async () => { + window.gon.current_user_id = 1; - it('commits FETCH_AWARDS_SUCCESS', async () => { - await testAction( - actions.fetchAwards, - '1', - { path: '/awards' }, - [{ type: 'FETCH_AWARDS_SUCCESS', payload: ['thumbsup'] }], - [{ type: 'fetchAwards', payload: '2' }], - ); + await testAction( + actions.fetchAwards, + '1', + { path: '/awards' }, + [{ type: 'FETCH_AWARDS_SUCCESS', payload: ['thumbsup'] }], + [{ type: 'fetchAwards', payload: '2' }], + ); + }); + + it('does not commit FETCH_AWARDS_SUCCESS when user signed out', async () => { + await testAction(actions.fetchAwards, '1', { path: '/awards' }, [], []); + }); }); }); @@ -55,6 +74,8 @@ describe('Awards app actions', () => { }); it('calls Sentry.captureException', async () => { + window.gon = { current_user_id: 1 }; + await testAction(actions.fetchAwards, null, { path: '/awards' }, [], [], () => { expect(Sentry.captureException).toHaveBeenCalled(); }); @@ -73,81 +94,91 @@ describe('Awards app actions', () => { mock.restore(); }); - describe('adding new award', () => { - describe('success', () => { - beforeEach(() => { - mock.onPost('/awards').reply(200, { id: 1 }); - }); - - it('commits ADD_NEW_AWARD', async () => { - testAction(actions.toggleAward, null, { path: '/awards', awards: [] }, [ - { type: 'ADD_NEW_AWARD', payload: { id: 1 } }, - ]); - }); - }); - - describe('error', () => { - beforeEach(() => { - mock.onPost('/awards').reply(500); - }); - - it('calls Sentry.captureException', async () => { - await testAction( - actions.toggleAward, - null, - { path: '/awards', awards: [] }, - [], - [], - () => { - expect(Sentry.captureException).toHaveBeenCalled(); - }, - ); - }); + describe.each` + relativeRootUrl + ${null} + ${'/gitlab'} + `('with relative_root_url as $relativeRootUrl', ({ relativeRootUrl }) => { + beforeEach(() => { + window.gon = { relative_url_root: relativeRootUrl }; }); - }); - describe('removing a award', () => { - const mockData = { id: 1, name: 'thumbsup', user: { id: 1 } }; - - describe('success', () => { - beforeEach(() => { - mock.onDelete('/awards/1').reply(200); + describe('adding new award', () => { + describe('success', () => { + beforeEach(() => { + mock.onPost(`${relativeRootUrl || ''}/awards`).reply(200, { id: 1 }); + }); + + it('commits ADD_NEW_AWARD', async () => { + testAction(actions.toggleAward, null, { path: '/awards', awards: [] }, [ + { type: 'ADD_NEW_AWARD', payload: { id: 1 } }, + ]); + }); }); - it('commits REMOVE_AWARD', async () => { - testAction( - actions.toggleAward, - 'thumbsup', - { - path: '/awards', - currentUserId: 1, - awards: [mockData], - }, - [{ type: 'REMOVE_AWARD', payload: 1 }], - ); + describe('error', () => { + beforeEach(() => { + mock.onPost(`${relativeRootUrl || ''}/awards`).reply(500); + }); + + it('calls Sentry.captureException', async () => { + await testAction( + actions.toggleAward, + null, + { path: '/awards', awards: [] }, + [], + [], + () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }, + ); + }); }); }); - describe('error', () => { - beforeEach(() => { - mock.onDelete('/awards/1').reply(500); + describe('removing a award', () => { + const mockData = { id: 1, name: 'thumbsup', user: { id: 1 } }; + + describe('success', () => { + beforeEach(() => { + mock.onDelete(`${relativeRootUrl || ''}/awards/1`).reply(200); + }); + + it('commits REMOVE_AWARD', async () => { + testAction( + actions.toggleAward, + 'thumbsup', + { + path: '/awards', + currentUserId: 1, + awards: [mockData], + }, + [{ type: 'REMOVE_AWARD', payload: 1 }], + ); + }); }); - it('calls Sentry.captureException', async () => { - await testAction( - actions.toggleAward, - 'thumbsup', - { - path: '/awards', - currentUserId: 1, - awards: [mockData], - }, - [], - [], - () => { - expect(Sentry.captureException).toHaveBeenCalled(); - }, - ); + describe('error', () => { + beforeEach(() => { + mock.onDelete(`${relativeRootUrl || ''}/awards/1`).reply(500); + }); + + it('calls Sentry.captureException', async () => { + await testAction( + actions.toggleAward, + 'thumbsup', + { + path: '/awards', + currentUserId: 1, + awards: [mockData], + }, + [], + [], + () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }, + ); + }); }); }); }); diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index b469a855d23..babbc0c8a4d 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -21,7 +21,7 @@ import { trackErrorDetailsViewsOptions, trackErrorStatusUpdateOptions, } from '~/error_tracking/utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import Tracking from '~/tracking'; @@ -160,10 +160,10 @@ describe('ErrorDetails', () => { return wrapper.vm.$nextTick().then(() => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLink).exists()).toBe(false); - expect(createFlash).toHaveBeenCalledWith( - 'Could not connect to Sentry. Refresh the page to try again.', - 'warning', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Could not connect to Sentry. Refresh the page to try again.', + type: 'warning', + }); expect(mocks.$apollo.queries.error.stopPolling).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js index 9d598344acd..aaaa1194a29 100644 --- a/spec/frontend/error_tracking/store/actions_spec.js +++ b/spec/frontend/error_tracking/store/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/error_tracking/store/actions'; import * as types from '~/error_tracking/store/mutation_types'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js index 0c19dce7bad..623cb82851d 100644 --- a/spec/frontend/error_tracking/store/details/actions_spec.js +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/error_tracking/store/details/actions'; import * as types from '~/error_tracking/store/details/mutation_types'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 39481a8576f..5465bde397c 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/error_tracking/store/list/actions'; import * as types from '~/error_tracking/store/list/mutation_types'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; diff --git a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js b/spec/frontend/feature_flags/components/empty_state_spec.js index c2170e8a768..86d0c1a05fd 100644 --- a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js +++ b/spec/frontend/feature_flags/components/empty_state_spec.js @@ -1,16 +1,14 @@ -import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue'; +import EmptyState from '~/feature_flags/components/empty_state.vue'; const DEFAULT_PROPS = { - title: 'test', - count: 5, alerts: ['an alert', 'another alert'], isLoading: false, loadingLabel: 'test loading', errorState: false, errorTitle: 'test title', - emptyState: true, + emptyState: false, emptyTitle: 'test empty', emptyDescription: 'empty description', }; @@ -27,13 +25,10 @@ describe('feature_flags/components/feature_flags_tab.vue', () => { mount( { components: { - GlTabs, - FeatureFlagsTab, + EmptyState, }, render(h) { - return h(GlTabs, [ - h(FeatureFlagsTab, { props: this.$attrs, on: this.$listeners }, this.$slots.default), - ]); + return h(EmptyState, { props: this.$attrs, on: this.$listeners }, this.$slots.default); }, }, { @@ -72,7 +67,7 @@ describe('feature_flags/components/feature_flags_tab.vue', () => { it('should emit a dismiss event for a dismissed alert', () => { alerts.at(0).vm.$emit('dismiss'); - expect(wrapper.find(FeatureFlagsTab).emitted('dismissAlert')).toEqual([[0]]); + expect(wrapper.find(EmptyState).emitted('dismissAlert')).toEqual([[0]]); }); }); @@ -138,30 +133,4 @@ describe('feature_flags/components/feature_flags_tab.vue', () => { expect(slot.text()).toBe('testing'); }); }); - - describe('count', () => { - it('should display a count if there is one', async () => { - wrapper = factory(); - await wrapper.vm.$nextTick(); - - expect(wrapper.find(GlBadge).text()).toBe(DEFAULT_PROPS.count.toString()); - }); - it('should display 0 if there is no count', async () => { - wrapper = factory({ count: undefined }); - await wrapper.vm.$nextTick(); - - expect(wrapper.find(GlBadge).text()).toBe('0'); - }); - }); - - describe('title', () => { - it('should show the title', async () => { - wrapper = factory(); - await wrapper.vm.$nextTick(); - - expect(wrapper.find('[data-testid="feature-flags-tab-title"]').text()).toBe( - DEFAULT_PROPS.title, - ); - }); - }); }); diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js index b519aab0dc4..db4bdc736de 100644 --- a/spec/frontend/feature_flags/components/feature_flags_spec.js +++ b/spec/frontend/feature_flags/components/feature_flags_spec.js @@ -1,19 +1,17 @@ -import { GlAlert, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; -import Api from '~/api'; import ConfigureFeatureFlagsModal from '~/feature_flags/components/configure_feature_flags_modal.vue'; +import EmptyState from '~/feature_flags/components/empty_state.vue'; import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue'; -import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue'; import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue'; -import UserListsTable from '~/feature_flags/components/user_lists_table.vue'; -import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '~/feature_flags/constants'; import createStore from '~/feature_flags/store/index'; import axios from '~/lib/utils/axios_utils'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { getRequestData, userList } from '../mock_data'; +import { getRequestData } from '../mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -28,7 +26,7 @@ describe('Feature flags', () => { featureFlagsLimit: '200', featureFlagsLimitExceeded: false, newFeatureFlagPath: 'feature-flags/new', - newUserListPath: '/user-list/new', + userListPath: '/user-list', unleashApiUrl: `${TEST_HOST}/api/unleash`, projectName: 'fakeProjectName', errorStateSvgPath: '/assets/illustrations/feature_flag.svg', @@ -44,36 +42,25 @@ describe('Feature flags', () => { let mock; let store; - const factory = (provide = mockData, fn = shallowMount) => { + const factory = (provide = mockData, fn = mount) => { store = createStore(mockState); wrapper = fn(FeatureFlagsComponent, { localVue, store, provide, stubs: { - FeatureFlagsTab, + EmptyState, }, }); }; const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]'); const newButton = () => wrapper.find('[data-testid="ff-new-button"]'); - const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]'); - const limitAlert = () => wrapper.find(GlAlert); + const userListButton = () => wrapper.find('[data-testid="ff-user-list-button"]'); + const limitAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => { mock = new MockAdapter(axios); - jest.spyOn(Api, 'fetchFeatureFlagUserLists').mockResolvedValue({ - data: [userList], - headers: { - 'x-next-page': '2', - 'x-page': '1', - 'X-Per-Page': '8', - 'X-Prev-Page': '', - 'X-TOTAL': '40', - 'X-Total-Pages': '5', - }, - }); }); afterEach(() => { @@ -87,7 +74,7 @@ describe('Feature flags', () => { beforeEach((done) => { mock - .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } }) .reply(200, getRequestData, {}); factory(provideData); setImmediate(done); @@ -101,9 +88,7 @@ describe('Feature flags', () => { it('shows a feature flags limit reached alert', () => { expect(limitAlert().exists()).toBe(true); - expect(limitAlert().find(GlSprintf).attributes('message')).toContain( - 'Feature flags limit reached', - ); + expect(limitAlert().text()).toContain('Feature flags limit reached'); }); describe('when the alert is dismissed', () => { @@ -129,12 +114,12 @@ describe('Feature flags', () => { canUserConfigure: false, canUserRotateToken: false, newFeatureFlagPath: null, - newUserListPath: null, + userListPath: null, }; beforeEach((done) => { mock - .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } }) .reply(200, getRequestData, {}); factory(provideData); setImmediate(done); @@ -148,20 +133,20 @@ describe('Feature flags', () => { expect(newButton().exists()).toBe(false); }); - it('does not render new user list button', () => { - expect(newUserListButton().exists()).toBe(false); + it('does not render view user list button', () => { + expect(userListButton().exists()).toBe(false); }); }); describe('loading state', () => { it('renders a loading icon', () => { mock - .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } }) .replyOnce(200, getRequestData, {}); factory(); - const loadingElement = wrapper.find(GlLoadingIcon); + const loadingElement = wrapper.findComponent(GlLoadingIcon); expect(loadingElement.exists()).toBe(true); expect(loadingElement.props('label')).toEqual('Loading feature flags'); @@ -173,7 +158,7 @@ describe('Feature flags', () => { let emptyState; beforeEach(async () => { - mock.onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }).reply( + mock.onGet(mockState.endpoint, { params: { page: '1' } }).reply( 200, { feature_flags: [], @@ -187,9 +172,10 @@ describe('Feature flags', () => { ); factory(); + await waitForPromises(); await wrapper.vm.$nextTick(); - emptyState = wrapper.find(GlEmptyState); + emptyState = wrapper.findComponent(GlEmptyState); }); it('should render the empty state', async () => { @@ -204,9 +190,9 @@ describe('Feature flags', () => { expect(newButton().exists()).toBe(true); }); - it('renders new user list button', () => { - expect(newUserListButton().exists()).toBe(true); - expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + it('renders view user list button', () => { + expect(userListButton().exists()).toBe(true); + expect(userListButton().attributes('href')).toBe(mockData.userListPath); }); describe('in feature flags tab', () => { @@ -218,16 +204,14 @@ describe('Feature flags', () => { describe('with paginated feature flags', () => { beforeEach((done) => { - mock - .onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) - .replyOnce(200, getRequestData, { - 'x-next-page': '2', - 'x-page': '1', - 'X-Per-Page': '2', - 'X-Prev-Page': '', - 'X-TOTAL': '37', - 'X-Total-Pages': '5', - }); + mock.onGet(mockState.endpoint, { params: { page: '1' } }).replyOnce(200, getRequestData, { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }); factory(); jest.spyOn(store, 'dispatch'); @@ -235,9 +219,9 @@ describe('Feature flags', () => { }); it('should render a table with feature flags', () => { - const table = wrapper.find(FeatureFlagsTable); + const table = wrapper.findComponent(FeatureFlagsTable); expect(table.exists()).toBe(true); - expect(table.props(FEATURE_FLAG_SCOPE)).toEqual( + expect(table.props('featureFlags')).toEqual( expect.arrayContaining([ expect.objectContaining({ name: getRequestData.feature_flags[0].name, @@ -248,9 +232,9 @@ describe('Feature flags', () => { }); it('should toggle a flag when receiving the toggle-flag event', () => { - const table = wrapper.find(FeatureFlagsTable); + const table = wrapper.findComponent(FeatureFlagsTable); - const [flag] = table.props(FEATURE_FLAG_SCOPE); + const [flag] = table.props('featureFlags'); table.vm.$emit('toggle-flag', flag); expect(store.dispatch).toHaveBeenCalledWith('toggleFeatureFlag', flag); @@ -264,71 +248,38 @@ describe('Feature flags', () => { expect(newButton().exists()).toBe(true); }); - it('renders new user list button', () => { - expect(newUserListButton().exists()).toBe(true); - expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + it('renders view user list button', () => { + expect(userListButton().exists()).toBe(true); + expect(userListButton().attributes('href')).toBe(mockData.userListPath); }); describe('pagination', () => { it('should render pagination', () => { - expect(wrapper.find(TablePagination).exists()).toBe(true); + expect(wrapper.findComponent(TablePagination).exists()).toBe(true); }); it('should make an API request when page is clicked', () => { jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); - wrapper.find(TablePagination).vm.change(4); + wrapper.findComponent(TablePagination).vm.change(4); expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ - scope: FEATURE_FLAG_SCOPE, page: '4', }); }); - - it('should make an API request when using tabs', () => { - jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); - wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); - - expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ - scope: USER_LIST_SCOPE, - page: '1', - }); - }); - }); - }); - - describe('in user lists tab', () => { - beforeEach((done) => { - factory(); - setImmediate(done); - }); - beforeEach(() => { - wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); - return wrapper.vm.$nextTick(); - }); - - it('should display the user list table', () => { - expect(wrapper.find(UserListsTable).exists()).toBe(true); - }); - - it('should set the user lists to display', () => { - expect(wrapper.find(UserListsTable).props('userLists')).toEqual([userList]); }); }); }); describe('unsuccessful request', () => { beforeEach((done) => { - mock - .onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) - .replyOnce(500, {}); - Api.fetchFeatureFlagUserLists.mockRejectedValueOnce(); + mock.onGet(mockState.endpoint, { params: { page: '1' } }).replyOnce(500, {}); factory(); setImmediate(done); }); it('should render error state', () => { - const emptyState = wrapper.find(GlEmptyState); + const emptyState = wrapper.findComponent(GlEmptyState); expect(emptyState.props('title')).toEqual('There was an error fetching the feature flags.'); expect(emptyState.props('description')).toEqual( 'Try again in a few moments or contact your support team.', @@ -343,16 +294,16 @@ describe('Feature flags', () => { expect(newButton().exists()).toBe(true); }); - it('renders new user list button', () => { - expect(newUserListButton().exists()).toBe(true); - expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + it('renders view user list button', () => { + expect(userListButton().exists()).toBe(true); + expect(userListButton().attributes('href')).toBe(mockData.userListPath); }); }); describe('rotate instance id', () => { beforeEach((done) => { mock - .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } }) .reply(200, getRequestData, {}); factory(); setImmediate(done); @@ -360,7 +311,7 @@ describe('Feature flags', () => { it('should fire the rotate action when a `token` event is received', () => { const actionSpy = jest.spyOn(wrapper.vm, 'rotateInstanceId'); - const modal = wrapper.find(ConfigureFeatureFlagsModal); + const modal = wrapper.findComponent(ConfigureFeatureFlagsModal); modal.vm.$emit('token'); expect(actionSpy).toHaveBeenCalled(); diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js index a7ab2e92cb2..ec311ef92a3 100644 --- a/spec/frontend/feature_flags/store/index/actions_spec.js +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -1,7 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; -import Api from '~/api'; import { mapToScopesViewModel } from '~/feature_flags/store/helpers'; import { requestFeatureFlags, @@ -17,18 +16,12 @@ import { updateFeatureFlag, receiveUpdateFeatureFlagSuccess, receiveUpdateFeatureFlagError, - requestUserLists, - receiveUserListsSuccess, - receiveUserListsError, - fetchUserLists, - deleteUserList, - receiveDeleteUserListError, clearAlert, } from '~/feature_flags/store/index/actions'; import * as types from '~/feature_flags/store/index/mutation_types'; import state from '~/feature_flags/store/index/state'; import axios from '~/lib/utils/axios_utils'; -import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data'; +import { getRequestData, rotateData, featureFlag } from '../../mock_data'; jest.mock('~/api.js'); @@ -154,99 +147,6 @@ describe('Feature flags actions', () => { }); }); - describe('fetchUserLists', () => { - beforeEach(() => { - Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} }); - }); - - describe('success', () => { - it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => { - testAction( - fetchUserLists, - null, - mockedState, - [], - [ - { - type: 'requestUserLists', - }, - { - payload: { data: [userList], headers: {} }, - type: 'receiveUserListsSuccess', - }, - ], - done, - ); - }); - }); - - describe('error', () => { - it('dispatches requestUserLists and receiveUserListsError ', (done) => { - Api.fetchFeatureFlagUserLists.mockRejectedValue(); - - testAction( - fetchUserLists, - null, - mockedState, - [], - [ - { - type: 'requestUserLists', - }, - { - type: 'receiveUserListsError', - }, - ], - done, - ); - }); - }); - }); - - describe('requestUserLists', () => { - it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { - testAction( - requestUserLists, - null, - mockedState, - [{ type: types.REQUEST_USER_LISTS }], - [], - done, - ); - }); - }); - - describe('receiveUserListsSuccess', () => { - it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { - testAction( - receiveUserListsSuccess, - { data: [userList], headers: {} }, - mockedState, - [ - { - type: types.RECEIVE_USER_LISTS_SUCCESS, - payload: { data: [userList], headers: {} }, - }, - ], - [], - done, - ); - }); - }); - - describe('receiveUserListsError', () => { - it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => { - testAction( - receiveUserListsError, - null, - mockedState, - [{ type: types.RECEIVE_USER_LISTS_ERROR }], - [], - done, - ); - }); - }); - describe('rotateInstanceId', () => { let mock; @@ -482,69 +382,6 @@ describe('Feature flags actions', () => { ); }); }); - describe('deleteUserList', () => { - beforeEach(() => { - mockedState.userLists = [userList]; - }); - - describe('success', () => { - beforeEach(() => { - Api.deleteFeatureFlagUserList.mockResolvedValue(); - }); - - it('should refresh the user lists', (done) => { - testAction( - deleteUserList, - userList, - mockedState, - [], - [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }], - done, - ); - }); - }); - - describe('error', () => { - beforeEach(() => { - Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } }); - }); - - it('should dispatch receiveDeleteUserListError', (done) => { - testAction( - deleteUserList, - userList, - mockedState, - [], - [ - { type: 'requestDeleteUserList', payload: userList }, - { - type: 'receiveDeleteUserListError', - payload: { list: userList, error: 'some error' }, - }, - ], - done, - ); - }); - }); - }); - - describe('receiveDeleteUserListError', () => { - it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => { - testAction( - receiveDeleteUserListError, - { list: userList, error: 'mock error' }, - mockedState, - [ - { - type: 'RECEIVE_DELETE_USER_LIST_ERROR', - payload: { list: userList, error: 'mock error' }, - }, - ], - [], - done, - ); - }); - }); describe('clearAlert', () => { it('should commit RECEIVE_CLEAR_ALERT', (done) => { diff --git a/spec/frontend/feature_flags/store/index/mutations_spec.js b/spec/frontend/feature_flags/store/index/mutations_spec.js index 08b5868d1b4..b9354196c68 100644 --- a/spec/frontend/feature_flags/store/index/mutations_spec.js +++ b/spec/frontend/feature_flags/store/index/mutations_spec.js @@ -3,7 +3,7 @@ import * as types from '~/feature_flags/store/index/mutation_types'; import mutations from '~/feature_flags/store/index/mutations'; import state from '~/feature_flags/store/index/state'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data'; +import { getRequestData, rotateData, featureFlag } from '../../mock_data'; describe('Feature flags store Mutations', () => { let stateCopy; @@ -59,13 +59,11 @@ describe('Feature flags store Mutations', () => { }); it('should set count with the given data', () => { - expect(stateCopy.count.featureFlags).toEqual(37); + expect(stateCopy.count).toEqual(37); }); it('should set pagination', () => { - expect(stateCopy.pageInfo.featureFlags).toEqual( - parseIntPagination(normalizeHeaders(headers)), - ); + expect(stateCopy.pageInfo).toEqual(parseIntPagination(normalizeHeaders(headers))); }); }); @@ -83,58 +81,6 @@ describe('Feature flags store Mutations', () => { }); }); - describe('REQUEST_USER_LISTS', () => { - it('sets isLoading to true', () => { - mutations[types.REQUEST_USER_LISTS](stateCopy); - expect(stateCopy.isLoading).toBe(true); - }); - }); - - describe('RECEIVE_USER_LISTS_SUCCESS', () => { - const headers = { - 'x-next-page': '2', - 'x-page': '1', - 'X-Per-Page': '2', - 'X-Prev-Page': '', - 'X-TOTAL': '37', - 'X-Total-Pages': '5', - }; - - beforeEach(() => { - mutations[types.RECEIVE_USER_LISTS_SUCCESS](stateCopy, { data: [userList], headers }); - }); - - it('sets isLoading to false', () => { - expect(stateCopy.isLoading).toBe(false); - }); - - it('sets userLists to the received userLists', () => { - expect(stateCopy.userLists).toEqual([userList]); - }); - - it('sets pagination info for user lits', () => { - expect(stateCopy.pageInfo.userLists).toEqual(parseIntPagination(normalizeHeaders(headers))); - }); - - it('sets the count for user lists', () => { - expect(stateCopy.count.userLists).toBe(parseInt(headers['X-TOTAL'], 10)); - }); - }); - - describe('RECEIVE_USER_LISTS_ERROR', () => { - beforeEach(() => { - mutations[types.RECEIVE_USER_LISTS_ERROR](stateCopy); - }); - - it('should set isLoading to false', () => { - expect(stateCopy.isLoading).toEqual(false); - }); - - it('should set hasError to true', () => { - expect(stateCopy.hasError).toEqual(true); - }); - }); - describe('REQUEST_ROTATE_INSTANCE_ID', () => { beforeEach(() => { mutations[types.REQUEST_ROTATE_INSTANCE_ID](stateCopy); @@ -214,7 +160,7 @@ describe('Feature flags store Mutations', () => { ...flagState, scopes: mapToScopesViewModel(flag.scopes || []), })); - stateCopy.count.featureFlags = stateCount; + stateCopy.count = stateCount; mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, { ...featureFlag, @@ -241,8 +187,6 @@ describe('Feature flags store Mutations', () => { ...flag, scopes: mapToScopesViewModel(flag.scopes || []), })); - stateCopy.count = { enabled: 1, disabled: 0 }; - mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, featureFlag.id); }); @@ -257,36 +201,6 @@ describe('Feature flags store Mutations', () => { }); }); - describe('REQUEST_DELETE_USER_LIST', () => { - beforeEach(() => { - stateCopy.userLists = [userList]; - mutations[types.REQUEST_DELETE_USER_LIST](stateCopy, userList); - }); - - it('should remove the deleted list', () => { - expect(stateCopy.userLists).not.toContain(userList); - }); - }); - - describe('RECEIVE_DELETE_USER_LIST_ERROR', () => { - beforeEach(() => { - stateCopy.userLists = []; - mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](stateCopy, { - list: userList, - error: 'some error', - }); - }); - - it('should set isLoading to false and hasError to false', () => { - expect(stateCopy.isLoading).toBe(false); - expect(stateCopy.hasError).toBe(false); - }); - - it('should add the user list back to the list of user lists', () => { - expect(stateCopy.userLists).toContain(userList); - }); - }); - describe('RECEIVE_CLEAR_ALERT', () => { it('clears the alert', () => { stateCopy.alerts = ['a server error']; diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 465e1ee1ef1..c03c8f6c529 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -185,7 +185,7 @@ describe('Filtered Search Manager', () => { }); describe('search', () => { - const defaultParams = '?scope=all&utf8=%E2%9C%93'; + const defaultParams = '?scope=all'; const defaultState = '&state=opened'; it('should search with a single word', (done) => { diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb index e012d922aad..1c3967b2c36 100644 --- a/spec/frontend/fixtures/api_markdown.rb +++ b/spec/frontend/fixtures/api_markdown.rb @@ -25,7 +25,7 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do let(:markdown) { markdown_example.fetch(:markdown) } it "#{fixture_subdir}/#{name}.json" do - post api("/markdown"), params: { text: markdown } + post api("/markdown"), params: { text: markdown, gfm: true } expect(response).to be_successful end diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb index 7ec155fcb10..1882ac49fd6 100644 --- a/spec/frontend/fixtures/releases.rb +++ b/spec/frontend/fixtures/releases.rb @@ -100,6 +100,17 @@ RSpec.describe 'Releases (JavaScript fixtures)' do link_type: :image) end + let_it_be(:another_release) do + create(:release, + project: project, + tag: 'v1.2', + name: 'The second release', + author: admin, + description: 'An okay release :shrug:', + created_at: Time.zone.parse('2019-01-03'), + released_at: Time.zone.parse('2019-01-10')) + end + after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb new file mode 100644 index 00000000000..b88fb840137 --- /dev/null +++ b/spec/frontend/fixtures/runner.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Runner (JavaScript fixtures)' do + include AdminModeHelper + include ApiHelpers + include JavaScriptFixturesHelpers + include GraphqlHelpers + + let_it_be(:admin) { create(:admin) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, :public) } + + let_it_be(:instance_runner) { create(:ci_runner, :instance, version: '1.0.0', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') } + let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') } + let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') } + + query_path = 'runner/graphql/' + fixtures_path = 'graphql/runner/' + + before(:all) do + clean_frontend_fixtures(fixtures_path) + end + + after(:all) do + remove_repository(project) + end + + before do + sign_in(admin) + enable_admin_mode!(admin) + end + + describe GraphQL::Query, type: :request do + get_runners_query_name = 'get_runners.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runners_query_name}", [ + 'runner/graphql/runner_node.fragment.graphql', + 'graphql_shared/fragments/pageInfo.fragment.graphql' + ]) + end + + it "#{fixtures_path}#{get_runners_query_name}.json" do + post_graphql(query, current_user: admin, variables: {}) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_runners_query_name}.paginated.json" do + post_graphql(query, current_user: admin, variables: { first: 2 }) + + expect_graphql_errors_to_be_empty + end + end + + describe GraphQL::Query, type: :request do + get_runner_query_name = 'get_runner.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runner_query_name}", [ + 'runner/graphql/runner_details.fragment.graphql' + ]) + end + + it "#{fixtures_path}#{get_runner_query_name}.json" do + post_graphql(query, current_user: admin, variables: { + id: instance_runner.to_global_id.to_s + }) + + expect_graphql_errors_to_be_empty + end + end +end diff --git a/spec/frontend/fixtures/services.rb b/spec/frontend/fixtures/services.rb index 7472af802f3..91e6c2eb280 100644 --- a/spec/frontend/fixtures/services.rb +++ b/spec/frontend/fixtures/services.rb @@ -7,7 +7,7 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } - let!(:service) { create(:custom_issue_tracker_service, project: project) } + let!(:service) { create(:custom_issue_tracker_integration, project: project) } let(:user) { project.owner } render_views diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb new file mode 100644 index 00000000000..003f7b768dd --- /dev/null +++ b/spec/frontend/fixtures/startup_css.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Startup CSS fixtures', type: :controller do + include JavaScriptFixturesHelpers + + let(:use_full_html) { true } + + render_views + + before(:all) do + stub_feature_flags(combined_menu: true) + stub_feature_flags(sidebar_refactor: true) + clean_frontend_fixtures('startup_css/') + end + + shared_examples 'startup css project fixtures' do |type| + let(:user) { create(:user, :admin) } + let(:project) { create(:project, :public, :repository, description: 'Code and stuff', creator: user) } + + before do + sign_in(user) + end + + it "startup_css/project-#{type}-legacy-menu.html" do + stub_feature_flags(combined_menu: false) + + get :show, params: { + namespace_id: project.namespace.to_param, + id: project + } + + expect(response).to be_successful + end + + it "startup_css/project-#{type}.html" do + get :show, params: { + namespace_id: project.namespace.to_param, + id: project + } + + expect(response).to be_successful + end + + it "startup_css/project-#{type}-legacy-sidebar.html" do + stub_feature_flags(sidebar_refactor: false) + + get :show, params: { + namespace_id: project.namespace.to_param, + id: project + } + + expect(response).to be_successful + end + + it "startup_css/project-#{type}-signed-out.html" do + sign_out(user) + + get :show, params: { + namespace_id: project.namespace.to_param, + id: project + } + + expect(response).to be_successful + end + end + + describe ProjectsController, '(Startup CSS fixtures)', type: :controller do + it_behaves_like 'startup css project fixtures', 'general' + end + + describe ProjectsController, '(Startup CSS fixtures)', type: :controller do + before do + user.update!(theme_id: 11) + end + + it_behaves_like 'startup css project fixtures', 'dark' + end + + describe RegistrationsController, '(Startup CSS fixtures)', type: :controller do + it 'startup_css/sign-in.html' do + get :new + + expect(response).to be_successful + end + end +end diff --git a/spec/frontend/fixtures/static/projects.json b/spec/frontend/fixtures/static/projects.json index f28d9899099..d843549039b 100644 --- a/spec/frontend/fixtures/static/projects.json +++ b/spec/frontend/fixtures/static/projects.json @@ -3,6 +3,7 @@ "description": "", "default_branch": null, "tag_list": [], + "topics": [], "public": true, "archived": false, "visibility_level": 20, @@ -54,6 +55,7 @@ "description": "Voluptatem quae nulla eius numquam ullam voluptatibus quia modi.", "default_branch": "master", "tag_list": [], + "topics": [], "public": false, "archived": false, "visibility_level": 0, @@ -114,6 +116,7 @@ "description": "Modi odio mollitia dolorem qui.", "default_branch": "master", "tag_list": [], + "topics": [], "public": false, "archived": false, "visibility_level": 0, @@ -162,6 +165,7 @@ "description": "Omnis asperiores ipsa et beatae quidem necessitatibus quia.", "default_branch": "master", "tag_list": [], + "topics": [], "public": true, "archived": false, "visibility_level": 20, @@ -210,6 +214,7 @@ "description": "Voluptatem commodi voluptate placeat architecto beatae illum dolores fugiat.", "default_branch": "master", "tag_list": [], + "topics": [], "public": false, "archived": false, "visibility_level": 0, @@ -258,6 +263,7 @@ "description": "Aut molestias quas est ut aperiam officia quod libero.", "default_branch": "master", "tag_list": [], + "topics": [], "public": true, "archived": false, "visibility_level": 20, @@ -309,6 +315,7 @@ "description": "Excepturi molestiae quia repellendus omnis est illo illum eligendi.", "default_branch": "master", "tag_list": [], + "topics": [], "public": true, "archived": false, "visibility_level": 20, @@ -357,6 +364,7 @@ "description": "Adipisci quaerat dignissimos enim sed ipsam dolorem quia.", "default_branch": "master", "tag_list": [], + "topics": [], "public": false, "archived": false, "visibility_level": 10, @@ -408,6 +416,7 @@ "description": "Vel voluptatem maxime saepe ex quia.", "default_branch": "master", "tag_list": [], + "topics": [], "public": false, "archived": false, "visibility_level": 0, diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 6a5ac76a4d0..28e8522cc12 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -357,27 +357,46 @@ describe('Flash', () => { }); describe('removeFlashClickListener', () => { - beforeEach(() => { - document.body.innerHTML += ` - <div class="flash-container"> - <div class="flash"> - <div class="close-icon js-close-icon"></div> + let el; + + describe('with close icon', () => { + beforeEach(() => { + el = document.createElement('div'); + el.innerHTML = ` + <div class="flash-container"> + <div class="flash"> + <div class="close-icon js-close-icon"></div> + </div> </div> - </div> - `; - }); + `; + }); - it('removes global flash on click', (done) => { - const flashEl = document.querySelector('.flash'); + it('removes global flash on click', (done) => { + removeFlashClickListener(el, false); - removeFlashClickListener(flashEl, false); + el.querySelector('.js-close-icon').click(); - flashEl.querySelector('.js-close-icon').click(); + setImmediate(() => { + expect(document.querySelector('.flash')).toBeNull(); - setImmediate(() => { - expect(document.querySelector('.flash')).toBeNull(); + done(); + }); + }); + }); + + describe('without close icon', () => { + beforeEach(() => { + el = document.createElement('div'); + el.innerHTML = ` + <div class="flash-container"> + <div class="flash"> + </div> + </div> + `; + }); - done(); + it('does not throw', () => { + expect(() => removeFlashClickListener(el, false)).not.toThrow(); }); }); }); diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js index 7a1026e8bfc..a94cb3e2fcc 100644 --- a/spec/frontend/frequent_items/components/app_spec.js +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -21,13 +21,14 @@ const TEST_NAMESPACE = 'projects'; const TEST_VUEX_MODULE = 'frequentProjects'; const TEST_PROJECT = currentSession[TEST_NAMESPACE].project; const TEST_STORAGE_KEY = currentSession[TEST_NAMESPACE].storageKey; +const TEST_SEARCH_CLASS = 'test-search-class'; describe('Frequent Items App Component', () => { let wrapper; let mock; let store; - const createComponent = ({ currentItem = null } = {}) => { + const createComponent = (props = {}) => { const session = currentSession[TEST_NAMESPACE]; gon.api_version = session.apiVersion; @@ -36,7 +37,8 @@ describe('Frequent Items App Component', () => { propsData: { namespace: TEST_NAMESPACE, currentUserName: session.username, - currentItem: currentItem || session.project, + currentItem: session.project, + ...props, }, provide: { vuexModule: TEST_VUEX_MODULE, @@ -88,7 +90,7 @@ describe('Frequent Items App Component', () => { }); it('should render search input', () => { - expect(findSearchInput().exists()).toBe(true); + expect(findSearchInput().classes()).toEqual(['search-input-container']); }); it('should render loading animation', async () => { @@ -159,6 +161,16 @@ describe('Frequent Items App Component', () => { }); }); + describe('with searchClass', () => { + beforeEach(() => { + createComponent({ searchClass: TEST_SEARCH_CLASS }); + }); + + it('should render search input with searchClass', () => { + expect(findSearchInput().classes()).toEqual(['search-input-container', TEST_SEARCH_CLASS]); + }); + }); + describe('logging', () => { it('when created, it should create a project storage entry and adds a project', () => { createComponent(); diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap index 1d2a5d636bc..33e2c0db5e5 100644 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -43,19 +43,25 @@ exports[`grafana integration component default state to match the default snapsh class="settings-content" > <form> - <gl-form-checkbox-stub - class="mb-4" - id="grafana-integration-enabled" + <gl-form-group-stub + label="Enable authentication" + label-for="grafana-integration-enabled" + labeldescription="" > + <gl-form-checkbox-stub + id="grafana-integration-enabled" + > + + Active - Active - - </gl-form-checkbox-stub> + </gl-form-checkbox-stub> + </gl-form-group-stub> <gl-form-group-stub description="Enter the base URL of the Grafana instance." label="Grafana URL" label-for="grafana-url" + labeldescription="" > <gl-form-input-stub id="grafana-url" @@ -67,6 +73,7 @@ exports[`grafana integration component default state to match the default snapsh <gl-form-group-stub label="API token" label-for="grafana-token" + labeldescription="" > <gl-form-input-stub id="grafana-token" @@ -76,32 +83,19 @@ exports[`grafana integration component default state to match the default snapsh <p class="form-text text-muted" > - - Enter the Grafana API token. - - <a - href="https://grafana.com/docs/http_api/auth/#create-api-token" - rel="noopener noreferrer" - target="_blank" - > - - More information. - - <gl-icon-stub - class="vertical-align-middle" - name="external-link" - size="16" - /> - </a> + <gl-sprintf-stub + message="Enter the %{docLinkStart}Grafana API token%{docLinkEnd}." + /> </p> </gl-form-group-stub> <gl-button-stub buttontextclasses="" category="primary" + data-testid="save-grafana-settings-button" icon="" size="medium" - variant="success" + variant="confirm" > Save changes diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js index f1a8e6fe2dc..3cb4dd41574 100644 --- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js +++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js @@ -1,7 +1,8 @@ import { GlButton } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createFlash from '~/flash'; import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue'; import { createStore } from '~/grafana_integration/store'; import axios from '~/lib/utils/axios_utils'; @@ -51,8 +52,7 @@ describe('grafana integration component', () => { it('renders as an expand button by default', () => { wrapper = shallowMount(GrafanaIntegration, { store }); - const button = wrapper.find(GlButton); - + const button = wrapper.findComponent(GlButton); expect(button.text()).toBe('Expand'); }); }); @@ -70,6 +70,7 @@ describe('grafana integration component', () => { describe('form', () => { beforeEach(() => { jest.spyOn(axios, 'patch').mockImplementation(); + wrapper = mountExtended(GrafanaIntegration, { store }); }); afterEach(() => { @@ -77,7 +78,7 @@ describe('grafana integration component', () => { }); describe('submit button', () => { - const findSubmitButton = () => wrapper.find('.settings-content form').find(GlButton); + const findSubmitButton = () => wrapper.findByTestId('save-grafana-settings-button'); const endpointRequest = [ operationsSettingsEndpoint, @@ -93,9 +94,7 @@ describe('grafana integration component', () => { ]; it('submits form on click', () => { - wrapper = mount(GrafanaIntegration, { store }); axios.patch.mockResolvedValue(); - findSubmitButton(wrapper).trigger('click'); expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); @@ -104,7 +103,6 @@ describe('grafana integration component', () => { it('creates flash banner on error', () => { const message = 'mockErrorMessage'; - wrapper = mount(GrafanaIntegration, { store }); axios.patch.mockRejectedValue({ response: { data: { message } } }); findSubmitButton().trigger('click'); @@ -114,10 +112,10 @@ describe('grafana integration component', () => { .$nextTick() .then(jest.runAllTicks) .then(() => - expect(createFlash).toHaveBeenCalledWith( - `There was an error saving your changes. ${message}`, - 'alert', - ), + expect(createFlash).toHaveBeenCalledWith({ + message: `There was an error saving your changes. ${message}`, + type: 'alert', + }), ); }); }); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 5a9f640392f..546cdd3cd6f 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -1,34 +1,33 @@ +import { mount } from '@vue/test-utils'; import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; +import GroupFolder from '~/groups/components/group_folder.vue'; +import GroupItem from '~/groups/components/group_item.vue'; +import ItemActions from '~/groups/components/item_actions.vue'; import eventHub from '~/groups/event_hub'; import { getGroupItemMicrodata } from '~/groups/store/utils'; import * as urlUtilities from '~/lib/utils/url_utility'; import { mockParentGroupItem, mockChildren } from '../mock_data'; -const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { - const Component = Vue.extend(groupItemComponent); - - return mountComponent(Component, { - group, - parentGroup, +const createComponent = ( + propsData = { group: mockParentGroupItem, parentGroup: mockChildren[0] }, +) => { + return mount(GroupItem, { + propsData, + components: { GroupFolder }, }); }; describe('GroupItemComponent', () => { - let vm; + let wrapper; beforeEach(() => { - Vue.component('GroupFolder', groupFolderComponent); - - vm = createComponent(); + wrapper = createComponent(); return Vue.nextTick(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); const withMicrodata = (group) => ({ @@ -39,14 +38,14 @@ describe('GroupItemComponent', () => { describe('computed', () => { describe('groupDomId', () => { it('should return ID string suffixed with group ID', () => { - expect(vm.groupDomId).toBe('group-55'); + expect(wrapper.vm.groupDomId).toBe('group-55'); }); }); describe('rowClass', () => { it('should return map of classes based on group details', () => { const classes = ['is-open', 'has-children', 'has-description', 'being-removed']; - const { rowClass } = vm; + const { rowClass } = wrapper.vm; expect(Object.keys(rowClass).length).toBe(classes.length); Object.keys(rowClass).forEach((className) => { @@ -57,58 +56,55 @@ describe('GroupItemComponent', () => { describe('hasChildren', () => { it('should return boolean value representing if group has any children present', () => { - let newVm; const group = { ...mockParentGroupItem }; group.childrenCount = 5; - newVm = createComponent(group); + wrapper = createComponent({ group }); - expect(newVm.hasChildren).toBeTruthy(); - newVm.$destroy(); + expect(wrapper.vm.hasChildren).toBe(true); + wrapper.destroy(); group.childrenCount = 0; - newVm = createComponent(group); + wrapper = createComponent({ group }); - expect(newVm.hasChildren).toBeFalsy(); - newVm.$destroy(); + expect(wrapper.vm.hasChildren).toBe(false); + wrapper.destroy(); }); }); describe('hasAvatar', () => { it('should return boolean value representing if group has any avatar present', () => { - let newVm; const group = { ...mockParentGroupItem }; group.avatarUrl = null; - newVm = createComponent(group); + wrapper = createComponent({ group }); - expect(newVm.hasAvatar).toBeFalsy(); - newVm.$destroy(); + expect(wrapper.vm.hasAvatar).toBe(false); + wrapper.destroy(); group.avatarUrl = '/uploads/group_avatar.png'; - newVm = createComponent(group); + wrapper = createComponent({ group }); - expect(newVm.hasAvatar).toBeTruthy(); - newVm.$destroy(); + expect(wrapper.vm.hasAvatar).toBe(true); + wrapper.destroy(); }); }); describe('isGroup', () => { it('should return boolean value representing if group item is of type `group` or not', () => { - let newVm; const group = { ...mockParentGroupItem }; group.type = 'group'; - newVm = createComponent(group); + wrapper = createComponent({ group }); - expect(newVm.isGroup).toBeTruthy(); - newVm.$destroy(); + expect(wrapper.vm.isGroup).toBe(true); + wrapper.destroy(); group.type = 'project'; - newVm = createComponent(group); + wrapper = createComponent({ group }); - expect(newVm.isGroup).toBeFalsy(); - newVm.$destroy(); + expect(wrapper.vm.isGroup).toBe(false); + wrapper.destroy(); }); }); }); @@ -137,22 +133,22 @@ describe('GroupItemComponent', () => { it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - vm.onClickRowGroup(event); + wrapper.vm.onClickRowGroup(event); - expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group); + expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', wrapper.vm.group); }); it('should navigate page to group homepage if group does not have any children present', () => { jest.spyOn(urlUtilities, 'visitUrl').mockImplementation(); const group = { ...mockParentGroupItem }; group.childrenCount = 0; - const newVm = createComponent(group); + wrapper = createComponent({ group }); jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - newVm.onClickRowGroup(event); + wrapper.vm.onClickRowGroup(event); expect(eventHub.$emit).not.toHaveBeenCalled(); - expect(urlUtilities.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath); + expect(urlUtilities.visitUrl).toHaveBeenCalledWith(wrapper.vm.group.relativePath); }); }); }); @@ -163,11 +159,11 @@ describe('GroupItemComponent', () => { describe('for a group pending deletion', () => { beforeEach(() => { group = { ...mockParentGroupItem, pendingRemoval: true }; - vm = createComponent(group); + wrapper = createComponent({ group }); }); it('renders the group pending removal badge', () => { - const badgeEl = vm.$el.querySelector('.badge-warning'); + const badgeEl = wrapper.vm.$el.querySelector('.badge-warning'); expect(badgeEl).toBeDefined(); expect(badgeEl.innerHTML).toContain('pending removal'); @@ -177,21 +173,41 @@ describe('GroupItemComponent', () => { describe('for a group not scheduled for deletion', () => { beforeEach(() => { group = { ...mockParentGroupItem, pendingRemoval: false }; - vm = createComponent(group); + wrapper = createComponent({ group }); }); it('does not render the group pending removal badge', () => { - const groupTextContainer = vm.$el.querySelector('.group-text-container'); + const groupTextContainer = wrapper.vm.$el.querySelector('.group-text-container'); expect(groupTextContainer).not.toContain('pending removal'); }); + + it('renders `item-actions` component and passes correct props to it', () => { + wrapper = createComponent({ + group: mockParentGroupItem, + parentGroup: mockChildren[0], + action: 'subgroups_and_projects', + }); + const itemActionsComponent = wrapper.findComponent(ItemActions); + + expect(itemActionsComponent.exists()).toBe(true); + expect(itemActionsComponent.props()).toEqual({ + group: mockParentGroupItem, + parentGroup: mockChildren[0], + action: 'subgroups_and_projects', + }); + }); }); it('should render component template correctly', () => { - const visibilityIconEl = vm.$el.querySelector('[data-testid="group-visibility-icon"]'); + const visibilityIconEl = wrapper.vm.$el.querySelector( + '[data-testid="group-visibility-icon"]', + ); + + const { vm } = wrapper; expect(vm.$el.getAttribute('id')).toBe('group-55'); - expect(vm.$el.classList.contains('group-row')).toBeTruthy(); + expect(vm.$el.classList.contains('group-row')).toBe(true); expect(vm.$el.querySelector('.group-row-contents')).toBeDefined(); expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined(); @@ -220,13 +236,13 @@ describe('GroupItemComponent', () => { describe('schema.org props', () => { describe('when showSchemaMarkup is disabled on the group', () => { it.each(['itemprop', 'itemtype', 'itemscope'], 'it does not set %s', (attr) => { - expect(vm.$el.getAttribute(attr)).toBeNull(); + expect(wrapper.vm.$el.getAttribute(attr)).toBeNull(); }); it.each( ['.js-group-avatar', '.js-group-name', '.js-group-description'], 'it does not set `itemprop` on sub-nodes', (selector) => { - expect(vm.$el.querySelector(selector).getAttribute('itemprop')).toBeNull(); + expect(wrapper.vm.$el.querySelector(selector).getAttribute('itemprop')).toBeNull(); }, ); }); @@ -238,7 +254,7 @@ describe('GroupItemComponent', () => { description: 'Foo Bar', }); - vm = createComponent(group); + wrapper = createComponent({ group }); }); it.each` @@ -247,7 +263,7 @@ describe('GroupItemComponent', () => { ${'itemtype'} | ${'https://schema.org/Organization'} ${'itemprop'} | ${'subOrganization'} `('it does set correct $attr', ({ attr, value } = {}) => { - expect(vm.$el.getAttribute(attr)).toBe(value); + expect(wrapper.vm.$el.getAttribute(attr)).toBe(value); }); it.each` @@ -256,7 +272,7 @@ describe('GroupItemComponent', () => { ${'[data-testid="group-name"]'} | ${'name'} ${'[data-testid="group-description"]'} | ${'description'} `('it does set correct $selector', ({ selector, propValue } = {}) => { - expect(vm.$el.querySelector(selector).getAttribute('itemprop')).toBe(propValue); + expect(wrapper.vm.$el.querySelector(selector).getAttribute('itemprop')).toBe(propValue); }); }); }); diff --git a/spec/frontend/ide/components/branches/item_spec.js b/spec/frontend/ide/components/branches/item_spec.js index f90c298c401..271d0600e16 100644 --- a/spec/frontend/ide/components/branches/item_spec.js +++ b/spec/frontend/ide/components/branches/item_spec.js @@ -7,7 +7,7 @@ import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import { projectData } from '../../mock_data'; const TEST_BRANCH = { - name: 'master', + name: 'main', committedDate: '2018-01-05T05:50Z', }; const TEST_PROJECT_ID = projectData.name_with_namespace; diff --git a/spec/frontend/ide/components/commit_sidebar/actions_spec.js b/spec/frontend/ide/components/commit_sidebar/actions_spec.js index c4dccf26af3..ed9d11246ae 100644 --- a/spec/frontend/ide/components/commit_sidebar/actions_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/actions_spec.js @@ -10,7 +10,7 @@ import { const ACTION_UPDATE_COMMIT_ACTION = 'commit/updateCommitAction'; -const BRANCH_DEFAULT = 'master'; +const BRANCH_DEFAULT = 'main'; const BRANCH_PROTECTED = 'protected/access'; const BRANCH_PROTECTED_NO_ACCESS = 'protected/no-access'; const BRANCH_REGULAR = 'regular'; @@ -20,11 +20,7 @@ describe('IDE commit sidebar actions', () => { let store; let vm; - const createComponent = ({ - hasMR = false, - currentBranchId = 'master', - emptyRepo = false, - } = {}) => { + const createComponent = ({ hasMR = false, currentBranchId = 'main', emptyRepo = false } = {}) => { const Component = Vue.extend(commitActions); vm = createComponentWithStore(Component, store); @@ -72,7 +68,7 @@ describe('IDE commit sidebar actions', () => { it('renders current branch text', () => { createComponent(); - expect(findText()).toContain('Commit to master branch'); + expect(findText()).toContain('Commit to main branch'); }); it('hides merge request option when project merge requests are disabled', (done) => { @@ -112,7 +108,7 @@ describe('IDE commit sidebar actions', () => { it('calls again after staged changes', (done) => { createComponent({ currentBranchId: null }); - vm.$store.state.currentBranchId = 'master'; + vm.$store.state.currentBranchId = 'main'; vm.$store.state.changedFiles.push({}); vm.$store.state.stagedFiles.push({}); @@ -158,7 +154,7 @@ describe('IDE commit sidebar actions', () => { it('only renders commit to current branch', () => { expect(findRadios().length).toBe(1); - expect(findText()).toContain('Commit to master branch'); + expect(findText()).toContain('Commit to main branch'); }); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index f5916b021aa..83d1bbb842e 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -67,7 +67,7 @@ describe('IDE commit form', () => { store = createStore(); store.state.stagedFiles.push('test'); store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; Vue.set(store.state.projects, 'abcproject', { ...projectData, userPermissions: { pushCode: true }, diff --git a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js index 253c2a426ee..4474647552d 100644 --- a/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/new_merge_request_option_spec.js @@ -24,7 +24,7 @@ describe('create new MR checkbox', () => { store.state.projects[store.state.currentProjectId].userPermissions = permissions; }; - const createComponent = ({ currentBranchId = 'master', createNewBranch = false } = {}) => { + const createComponent = ({ currentBranchId = 'main', createNewBranch = false } = {}) => { const Component = Vue.extend(NewMergeRequestOption); vm = createComponentWithStore(Component, store); @@ -63,7 +63,7 @@ describe('create new MR checkbox', () => { describe('is rendered when pushing to a new branch', () => { beforeEach(() => { createComponent({ - currentBranchId: 'master', + currentBranchId: 'main', createNewBranch: true, }); }); @@ -87,7 +87,7 @@ describe('create new MR checkbox', () => { describe('is NOT rendered when pushing to the same branch', () => { beforeEach(() => { createComponent({ - currentBranchId: 'master', + currentBranchId: 'main', createNewBranch: false, }); }); diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js index 740b7ada521..7a92f59641f 100644 --- a/spec/frontend/ide/components/ide_review_spec.js +++ b/spec/frontend/ide/components/ide_review_spec.js @@ -19,9 +19,9 @@ describe('IDE review mode', () => { beforeEach(() => { store = createStore(); store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects.abcproject = { ...projectData }; - Vue.set(store.state.trees, 'abcproject/master', { + Vue.set(store.state.trees, 'abcproject/main', { tree: [file('fileName')], loading: false, }); diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index b23a78a035d..f8d29fc7b47 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -22,9 +22,9 @@ describe('WebIDE', () => { const createComponent = ({ projData = emptyProjData, state = {} } = {}) => { store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects.abcproject = projData && { ...projData }; - store.state.trees['abcproject/master'] = { + store.state.trees['abcproject/main'] = { tree: [], loading: false, }; diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js index 9d33a1e2554..f1a0b64caf2 100644 --- a/spec/frontend/ide/components/ide_status_bar_spec.js +++ b/spec/frontend/ide/components/ide_status_bar_spec.js @@ -24,7 +24,7 @@ describe('ideStatusBar', () => { store = createStore(); store.state.currentProjectId = TEST_PROJECT_ID; store.state.projects[TEST_PROJECT_ID] = _.clone(projectData); - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; }); afterEach(() => { diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js index c8153ea339e..85d9feb0c09 100644 --- a/spec/frontend/ide/components/ide_tree_list_spec.js +++ b/spec/frontend/ide/components/ide_tree_list_spec.js @@ -14,9 +14,9 @@ describe('IDE tree list', () => { const bootstrapWithTree = (tree = normalBranchTree) => { store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects.abcproject = { ...projectData }; - Vue.set(store.state.trees, 'abcproject/master', { + Vue.set(store.state.trees, 'abcproject/main', { tree, loading: false, }); @@ -42,7 +42,7 @@ describe('IDE tree list', () => { }); it('renders loading indicator', (done) => { - store.state.trees['abcproject/master'].loading = true; + store.state.trees['abcproject/main'].loading = true; vm.$nextTick(() => { expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js index 6eef646b012..0792b88aeb6 100644 --- a/spec/frontend/ide/components/ide_tree_spec.js +++ b/spec/frontend/ide/components/ide_tree_spec.js @@ -18,9 +18,9 @@ describe('IdeTree', () => { store = createStore(); store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects.abcproject = { ...projectData }; - Vue.set(store.state.trees, 'abcproject/master', { + Vue.set(store.state.trees, 'abcproject/main', { tree: [file('fileName')], loading: false, }); diff --git a/spec/frontend/ide/components/merge_requests/list_spec.js b/spec/frontend/ide/components/merge_requests/list_spec.js index 85acabca38b..610e20d5868 100644 --- a/spec/frontend/ide/components/merge_requests/list_spec.js +++ b/spec/frontend/ide/components/merge_requests/list_spec.js @@ -21,7 +21,7 @@ describe('IDE merge requests list', () => { const fakeStore = new Vuex.Store({ state: { currentMergeRequestId: '1', - currentProjectId: 'project/master', + currentProjectId: 'project/main', ...restOfState, }, modules: { diff --git a/spec/frontend/ide/components/nav_dropdown_spec.js b/spec/frontend/ide/components/nav_dropdown_spec.js index 4ddb3930764..6a1be7ee964 100644 --- a/spec/frontend/ide/components/nav_dropdown_spec.js +++ b/spec/frontend/ide/components/nav_dropdown_spec.js @@ -14,14 +14,14 @@ describe('IDE NavDropdown', () => { store = createStore(); Object.assign(store.state, { currentProjectId: TEST_PROJECT_ID, - currentBranchId: 'master', + currentBranchId: 'main', projects: { [TEST_PROJECT_ID]: { userPermissions: { [PERMISSION_READ_MR]: true, }, branches: { - master: { id: 'master' }, + main: { id: 'main' }, }, }, }, diff --git a/spec/frontend/ide/components/new_dropdown/index_spec.js b/spec/frontend/ide/components/new_dropdown/index_spec.js index 5a1c0471206..fa34d1b257f 100644 --- a/spec/frontend/ide/components/new_dropdown/index_spec.js +++ b/spec/frontend/ide/components/new_dropdown/index_spec.js @@ -13,7 +13,7 @@ describe('new dropdown component', () => { const component = Vue.extend(newDropdown); vm = createComponentWithStore(component, store, { - branch: 'master', + branch: 'main', path: '', mouseOver: false, type: 'tree', diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js index 0600fcea917..fce6ccf4b58 100644 --- a/spec/frontend/ide/components/new_dropdown/modal_spec.js +++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import modal from '~/ide/components/new_dropdown/modal.vue'; import { createStore } from '~/ide/stores'; @@ -182,14 +182,14 @@ describe('new file modal component', () => { vm.submitForm(); - expect(createFlash).toHaveBeenCalledWith( - 'The name "test-path/test" is already taken in this directory.', - 'alert', - expect.anything(), - null, - false, - true, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'The name "test-path/test" is already taken in this directory.', + type: 'alert', + parent: expect.anything(), + actionConfig: null, + fadeTransition: false, + addBodyClass: true, + }); }); it('does not throw error when target entry does not exist', () => { diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js index c174f5e2006..db4181395d3 100644 --- a/spec/frontend/ide/components/repo_commit_section_spec.js +++ b/spec/frontend/ide/components/repo_commit_section_spec.js @@ -22,11 +22,11 @@ describe('RepoCommitSection', () => { store.state.noChangesStateSvgPath = 'svg'; store.state.committedStateSvgPath = 'commitsvg'; store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects.abcproject = { web_url: '', branches: { - master: { + main: { workingReference: '1', }, }, @@ -39,7 +39,7 @@ describe('RepoCommitSection', () => { }), ); - store.state.currentBranch = 'master'; + store.state.currentBranch = 'main'; store.state.changedFiles = []; store.state.stagedFiles = [{ ...files[0] }, { ...files[1] }]; store.state.stagedFiles.forEach((f) => diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 646e51160d8..8e8fb31b15a 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -63,8 +63,8 @@ const prepareStore = (state, activeFile) => { projects: { 'gitlab-org/gitlab': { branches: { - master: { - name: 'master', + main: { + name: 'main', commit: { id: 'abcdefgh', }, @@ -73,7 +73,7 @@ const prepareStore = (state, activeFile) => { }, }, currentProjectId: 'gitlab-org/gitlab', - currentBranchId: 'master', + currentBranchId: 'main', entries: { [activeFile.path]: activeFile, }, @@ -656,7 +656,7 @@ describe('RepoEditor', () => { }); it("does not add file to state or set markdown image syntax if the file isn't markdown", async () => { - wrapper.setProps({ + await wrapper.setProps({ file: setFileName('myfile.txt'), }); pasteImage(); diff --git a/spec/frontend/ide/ide_router_spec.js b/spec/frontend/ide/ide_router_spec.js index acab2c6aeef..3fb7781b176 100644 --- a/spec/frontend/ide/ide_router_spec.js +++ b/spec/frontend/ide/ide_router_spec.js @@ -18,14 +18,14 @@ describe('IDE router', () => { }); [ - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/master/-/src/blob/`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/master/-/src/blob`, + `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob/`, + `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob`, `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob/-/src/blob`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/master/-/src/tree/`, + `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/tree/`, `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/weird:branch/name-123/-/src/tree/`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/master/-/src/blob`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/master/-/src/edit`, - `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/master/-/src/merge_requests/2`, + `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/blob`, + `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/edit`, + `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/merge_requests/2`, `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/blob/-/src/blob`, `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit/blob/-/src/blob`, `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/merge_requests/2`, diff --git a/spec/frontend/ide/mock_data.js b/spec/frontend/ide/mock_data.js index c8925e6745d..557626b3cca 100644 --- a/spec/frontend/ide/mock_data.js +++ b/spec/frontend/ide/mock_data.js @@ -8,8 +8,8 @@ export const projectData = { path: '', name_with_namespace: 'namespace/abcproject', branches: { - master: { - treeId: 'abcproject/master', + main: { + treeId: 'abcproject/main', can_push: true, commit: { id: '123', @@ -19,13 +19,13 @@ export const projectData = { mergeRequests: {}, merge_requests_enabled: true, userPermissions: {}, - default_branch: 'master', + default_branch: 'main', }; export const pipelines = [ { id: 1, - ref: 'master', + ref: 'main', sha: '123', details: { status: { @@ -38,7 +38,7 @@ export const pipelines = [ }, { id: 2, - ref: 'master', + ref: 'main', sha: '213', details: { status: { @@ -178,9 +178,9 @@ export const mergeRequests = [ export const branches = [ { id: 1, - name: 'master', + name: 'main', commit: { - message: 'Update master branch', + message: 'Update main branch', committed_date: '2018-08-01T00:20:05Z', }, can_push: true, diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 4a726cff3b6..925446aa280 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -15,7 +15,7 @@ jest.mock('~/ide/services/gql'); const TEST_NAMESPACE = 'alice'; const TEST_PROJECT = 'wonderland'; const TEST_PROJECT_ID = `${TEST_NAMESPACE}/${TEST_PROJECT}`; -const TEST_BRANCH = 'master-patch-123'; +const TEST_BRANCH = 'main-patch-123'; const TEST_COMMIT_SHA = '123456789'; const TEST_FILE_PATH = 'README2.md'; const TEST_FILE_OLD_PATH = 'OLD_README2.md'; diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index 6178fb08d8c..6b94d7cf6f1 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -29,7 +29,7 @@ describe('IDE store file actions', () => { store = createStore(); store.state.currentProjectId = 'test/test'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; router = createRouter(store); @@ -85,7 +85,7 @@ describe('IDE store file actions', () => { .dispatch('closeFile', localFile) .then(Vue.nextTick) .then(() => { - expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/-/newOpenFile/'); + expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/main/-/newOpenFile/'); }); }); @@ -177,11 +177,11 @@ describe('IDE store file actions', () => { store.state.entries[localFile.path] = localFile; store.state.currentProjectId = 'test/test'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects['test/test'] = { branches: { - master: { + main: { commit: { id: '7297abc', }, @@ -260,7 +260,7 @@ describe('IDE store file actions', () => { it('sets document title with the branchId', () => { return store.dispatch('getFileData', { path: localFile.path }).then(() => { - expect(document.title).toBe(`${localFile.path} · master · test/test · GitLab`); + expect(document.title).toBe(`${localFile.path} · main · test/test · GitLab`); }); }); @@ -329,7 +329,7 @@ describe('IDE store file actions', () => { it('sets document title considering `prevPath` on a file', () => { return store.dispatch('getFileData', { path: localFile.path }).then(() => { - expect(document.title).toBe(`new-shiny-file · master · test/test · GitLab`); + expect(document.title).toBe(`new-shiny-file · main · test/test · GitLab`); }); }); }); @@ -702,7 +702,7 @@ describe('IDE store file actions', () => { }); it('pushes route for active file', () => { - expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/-/tempFile/'); + expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/main/-/tempFile/'); }); }); }); @@ -778,7 +778,7 @@ describe('IDE store file actions', () => { it('pushes router URL when added', () => { return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(() => { - expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/'); + expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/main/'); }); }); }); diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js index 600bd5fe9e1..e62811a4517 100644 --- a/spec/frontend/ide/stores/actions/merge_request_spec.js +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import { range } from 'lodash'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '~/ide/constants'; import service from '~/ide/services'; import { createStore } from '~/ide/stores'; @@ -145,7 +145,9 @@ describe('IDE store merge request actions', () => { .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) .catch(() => { expect(createFlash).toHaveBeenCalled(); - expect(createFlash.mock.calls[0][0]).toBe('Error fetching merge requests for bar'); + expect(createFlash.mock.calls[0][0].message).toBe( + 'Error fetching merge requests for bar', + ); }) .then(done) .catch(done.fail); @@ -461,11 +463,11 @@ describe('IDE store merge request actions', () => { }; store.state.currentProjectId = 'test/test'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects['test/test'] = { branches: { - master: { + main: { commit: { id: '7297abc', }, @@ -562,7 +564,9 @@ describe('IDE store merge request actions', () => { openMergeRequest(store, mr) .catch(() => { - expect(createFlash).toHaveBeenCalledWith(expect.any(String)); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.any(String), + }); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js index 23ffb5ff56b..ca6f7169059 100644 --- a/spec/frontend/ide/stores/actions/project_spec.js +++ b/spec/frontend/ide/stores/actions/project_spec.js @@ -37,11 +37,11 @@ describe('IDE store project actions', () => { describe('refreshLastCommitData', () => { beforeEach(() => { store.state.currentProjectId = 'abc/def'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects['abc/def'] = { id: 4, branches: { - master: { + main: { commit: null, }, }, @@ -60,7 +60,7 @@ describe('IDE store project actions', () => { branchId: store.state.currentBranchId, }) .then(() => { - expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'master'); + expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'main'); done(); }) @@ -81,7 +81,7 @@ describe('IDE store project actions', () => { type: 'SET_BRANCH_COMMIT', payload: { projectId: TEST_PROJECT_ID, - branchId: 'master', + branchId: 'main', commit: { id: '123' }, }, }, @@ -97,17 +97,17 @@ describe('IDE store project actions', () => { it('dispatches setErrorMessage', (done) => { testAction( showBranchNotFoundError, - 'master', + 'main', null, [], [ { type: 'setErrorMessage', payload: { - text: "Branch <strong>master</strong> was not found in this project's repository.", + text: "Branch <strong>main</strong> was not found in this project's repository.", action: expect.any(Function), actionText: 'Create branch', - actionPayload: 'master', + actionPayload: 'main', }, }, ], @@ -131,7 +131,7 @@ describe('IDE store project actions', () => { }, getters: { currentProject: { - default_branch: 'master', + default_branch: 'main', }, }, dispatch() {}, @@ -140,7 +140,7 @@ describe('IDE store project actions', () => { ) .then(() => { expect(api.createBranch).toHaveBeenCalledWith('project-path', { - ref: 'master', + ref: 'main', branch: 'new-branch-name', }); }) @@ -158,7 +158,7 @@ describe('IDE store project actions', () => { }, getters: { currentProject: { - default_branch: 'master', + default_branch: 'main', }, }, dispatch: dispatchSpy, @@ -180,7 +180,7 @@ describe('IDE store project actions', () => { }, getters: { currentProject: { - default_branch: 'master', + default_branch: 'main', }, }, dispatch() {}, @@ -199,13 +199,13 @@ describe('IDE store project actions', () => { it('creates a blank tree and sets loading state to false', (done) => { testAction( loadEmptyBranch, - { projectId: TEST_PROJECT_ID, branchId: 'master' }, + { projectId: TEST_PROJECT_ID, branchId: 'main' }, store.state, [ - { type: 'CREATE_TREE', payload: { treePath: `${TEST_PROJECT_ID}/master` } }, + { type: 'CREATE_TREE', payload: { treePath: `${TEST_PROJECT_ID}/main` } }, { type: 'TOGGLE_LOADING', - payload: { entry: store.state.trees[`${TEST_PROJECT_ID}/master`], forceValue: false }, + payload: { entry: store.state.trees[`${TEST_PROJECT_ID}/main`], forceValue: false }, }, ], expect.any(Object), @@ -214,11 +214,11 @@ describe('IDE store project actions', () => { }); it('does nothing, if tree already exists', (done) => { - const trees = { [`${TEST_PROJECT_ID}/master`]: [] }; + const trees = { [`${TEST_PROJECT_ID}/main`]: [] }; testAction( loadEmptyBranch, - { projectId: TEST_PROJECT_ID, branchId: 'master' }, + { projectId: TEST_PROJECT_ID, branchId: 'main' }, { trees }, [], [], diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js index 8de2188a5f4..8d7328725e9 100644 --- a/spec/frontend/ide/stores/actions/tree_spec.js +++ b/spec/frontend/ide/stores/actions/tree_spec.js @@ -18,8 +18,8 @@ describe('Multi-file store tree actions', () => { const basicCallParameters = { endpoint: 'rootEndpoint', projectId: 'abcproject', - branch: 'master', - branchId: 'master', + branch: 'main', + branchId: 'main', ref: '12345678', }; @@ -31,7 +31,7 @@ describe('Multi-file store tree actions', () => { mock = new MockAdapter(axios); store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; store.state.projects.abcproject = { web_url: '', path_with_namespace: 'foo/abcproject', @@ -66,7 +66,7 @@ describe('Multi-file store tree actions', () => { store .dispatch('getFiles', basicCallParameters) .then(() => { - projectTree = store.state.trees['abcproject/master']; + projectTree = store.state.trees['abcproject/main']; expect(projectTree.tree.length).toBe(2); expect(projectTree.tree[0].type).toBe('tree'); @@ -89,7 +89,7 @@ describe('Multi-file store tree actions', () => { 'abc/def': { web_url: `${TEST_HOST}/files`, branches: { - 'master-testing': { + 'main-testing': { commit: { id: '12345', }, @@ -98,7 +98,7 @@ describe('Multi-file store tree actions', () => { }, }; const getters = { - findBranch: () => store.state.projects['abc/def'].branches['master-testing'], + findBranch: () => store.state.projects['abc/def'].branches['main-testing'], }; mock.onGet(/(.*)/).replyOnce(500); @@ -112,7 +112,7 @@ describe('Multi-file store tree actions', () => { }, { projectId: 'abc/def', - branchId: 'master-testing', + branchId: 'main-testing', }, ) .then(done.fail) @@ -121,7 +121,7 @@ describe('Multi-file store tree actions', () => { text: 'An error occurred while loading all the files.', action: expect.any(Function), actionText: 'Please try again', - actionPayload: { projectId: 'abc/def', branchId: 'master-testing' }, + actionPayload: { projectId: 'abc/def', branchId: 'main-testing' }, }); done(); }); @@ -178,17 +178,17 @@ describe('Multi-file store tree actions', () => { describe('setDirectoryData', () => { it('sets tree correctly if there are no opened files yet', (done) => { const treeFile = file({ name: 'README.md' }); - store.state.trees['abcproject/master'] = {}; + store.state.trees['abcproject/main'] = {}; testAction( setDirectoryData, - { projectId: 'abcproject', branchId: 'master', treeList: [treeFile] }, + { projectId: 'abcproject', branchId: 'main', treeList: [treeFile] }, store.state, [ { type: types.SET_DIRECTORY_DATA, payload: { - treePath: 'abcproject/master', + treePath: 'abcproject/main', data: [treeFile], }, }, diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index ad55313da93..e575667b8c6 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -777,7 +777,7 @@ describe('Multi-file store actions', () => { it('routes to the renamed file if the original file has been opened', (done) => { store.state.currentProjectId = 'test/test'; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; Object.assign(store.state.entries.orig, { opened: true, @@ -790,7 +790,7 @@ describe('Multi-file store actions', () => { }) .then(() => { expect(router.push.mock.calls).toHaveLength(1); - expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/master/-/renamed/`); + expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/main/-/renamed/`); }) .then(done) .catch(done.fail); @@ -1019,7 +1019,7 @@ describe('Multi-file store actions', () => { }, { projectId: 'abc/def', - branchId: 'master-testing', + branchId: 'main-testing', }, ]; dispatch = jest.fn(); diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js index 06456cdb12a..53d161ae5c9 100644 --- a/spec/frontend/ide/stores/getters_spec.js +++ b/spec/frontend/ide/stores/getters_spec.js @@ -209,12 +209,12 @@ describe('IDE store getters', () => { describe('currentBranch', () => { it('returns current projects branch', () => { localState.currentProjectId = 'abcproject'; - localState.currentBranchId = 'master'; + localState.currentBranchId = 'main'; localState.projects.abcproject = { name: 'abcproject', branches: { - master: { - name: 'master', + main: { + name: 'main', }, }, }; @@ -223,7 +223,7 @@ describe('IDE store getters', () => { }; getters.currentBranch(localState, localGetters); - expect(localGetters.findBranch).toHaveBeenCalledWith('abcproject', 'master'); + expect(localGetters.findBranch).toHaveBeenCalledWith('abcproject', 'main'); }); }); @@ -243,12 +243,12 @@ describe('IDE store getters', () => { it('returns the selected branch from a project', () => { localState.currentProjectId = 'abcproject'; - localState.currentBranchId = 'master'; + localState.currentBranchId = 'main'; localState.projects.abcproject = { name: 'abcproject', branches: { - master: { - name: 'master', + main: { + name: 'main', }, }, }; @@ -256,9 +256,9 @@ describe('IDE store getters', () => { findProject: () => localState.projects.abcproject, }; - result = getters.findBranch(localState, localGetters)('abcproject', 'master'); + result = getters.findBranch(localState, localGetters)('abcproject', 'main'); - expect(result.name).toBe('master'); + expect(result.name).toBe('main'); }); }); @@ -274,9 +274,9 @@ describe('IDE store getters', () => { it("returns true when project's default branch matches current branch", () => { const localGetters = { currentProject: { - default_branch: 'master', + default_branch: 'main', }, - branchName: 'master', + branchName: 'main', }; expect(getters.isOnDefaultBranch({}, localGetters)).toBeTruthy(); @@ -285,7 +285,7 @@ describe('IDE store getters', () => { it("returns false when project's default branch doesn't match current branch", () => { const localGetters = { currentProject: { - default_branch: 'master', + default_branch: 'main', }, branchName: 'feature', }; @@ -620,10 +620,10 @@ describe('IDE store getters', () => { describe('getUrlForPath', () => { it('returns a route url for the given path', () => { localState.currentProjectId = 'test/test'; - localState.currentBranchId = 'master'; + localState.currentBranchId = 'main'; expect(localStore.getters.getUrlForPath('path/to/foo/bar-1.jpg')).toBe( - `/project/test/test/tree/master/-/path/to/foo/bar-1.jpg/`, + `/project/test/test/tree/main/-/path/to/foo/bar-1.jpg/`, ); }); }); @@ -631,13 +631,13 @@ describe('IDE store getters', () => { describe('getJsonSchemaForPath', () => { beforeEach(() => { localState.currentProjectId = 'path/to/some/project'; - localState.currentBranchId = 'master'; + localState.currentBranchId = 'main'; }); it('returns a json schema uri and match config for a json/yaml file that can be loaded by monaco', () => { expect(localStore.getters.getJsonSchemaForPath('.gitlab-ci.yml')).toEqual({ fileMatch: ['*.gitlab-ci.yml'], - uri: `${TEST_HOST}/path/to/some/project/-/schema/master/.gitlab-ci.yml`, + uri: `${TEST_HOST}/path/to/some/project/-/schema/main/.gitlab-ci.yml`, }); }); @@ -645,8 +645,8 @@ describe('IDE store getters', () => { localState.projects['path/to/some/project'] = { name: 'project', branches: { - master: { - name: 'master', + main: { + name: 'main', commit: { id: 'abcdef123456', }, diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js index b124eb391f3..cb6bb7c1202 100644 --- a/spec/frontend/ide/stores/modules/commit/actions_spec.js +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -47,7 +47,7 @@ describe('IDE commit module actions', () => { jest.spyOn(router, 'push').mockImplementation(); mock - .onGet('/api/v1/projects/abcproject/repository/branches/master') + .onGet('/api/v1/projects/abcproject/repository/branches/main') .reply(200, { commit: COMMIT_RESPONSE }); }); @@ -101,7 +101,7 @@ describe('IDE commit module actions', () => { originalGon = window.gon; window.gon = { current_username: 'johndoe' }; - store.state.currentBranchId = 'master'; + store.state.currentBranchId = 'main'; }); afterEach(() => { @@ -177,7 +177,7 @@ describe('IDE commit module actions', () => { committed_date: '123', committer_name: 'root', }; - const branch = 'master'; + const branch = 'main'; let f; beforeEach(() => { @@ -192,12 +192,12 @@ describe('IDE commit module actions', () => { Object.assign(store.state, { currentProjectId: 'abcproject', - currentBranchId: 'master', + currentBranchId: 'main', projects: { abcproject: { web_url: 'web_url', branches: { - master: { + main: { workingReference: '', commit: { short_id: TEST_COMMIT_SHA, @@ -228,7 +228,7 @@ describe('IDE commit module actions', () => { branch, }) .then(() => { - expect(store.state.projects.abcproject.branches.master.workingReference).toBe(data.id); + expect(store.state.projects.abcproject.branches.main.workingReference).toBe(data.id); }) .then(done) .catch(done.fail); @@ -310,14 +310,14 @@ describe('IDE commit module actions', () => { changedFiles: [f], openFiles: [f], currentProjectId: 'abcproject', - currentBranchId: 'master', + currentBranchId: 'main', projects: { abcproject: { - default_branch: 'master', + default_branch: 'main', web_url: 'webUrl', branches: { - master: { - name: 'master', + main: { + name: 'main', workingReference: '1', commit: { id: TEST_COMMIT_SHA, @@ -460,7 +460,7 @@ describe('IDE commit module actions', () => { .dispatch('commit/commitChanges') .then(() => { expect(visitUrl).toHaveBeenCalledWith( - `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=master&nav_source=webide`, + `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=main&nav_source=webide`, ); done(); diff --git a/spec/frontend/ide/stores/modules/commit/getters_spec.js b/spec/frontend/ide/stores/modules/commit/getters_spec.js index 0dc938bb637..7a07ed05201 100644 --- a/spec/frontend/ide/stores/modules/commit/getters_spec.js +++ b/spec/frontend/ide/stores/modules/commit/getters_spec.js @@ -46,7 +46,7 @@ describe('IDE commit module getters', () => { describe('branchName', () => { const rootState = { - currentBranchId: 'master', + currentBranchId: 'main', }; const localGetters = { placeholderBranchName: 'placeholder-branch-name', @@ -61,7 +61,7 @@ describe('IDE commit module getters', () => { it('defaults to currentBranchId when not committing to a new branch', () => { localGetters.isCreatingNewBranch = false; - expect(getters.branchName(state, localGetters, rootState)).toBe('master'); + expect(getters.branchName(state, localGetters, rootState)).toBe('main'); }); describe('commit to a new branch', () => { diff --git a/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js index e5887ca0a33..fc00bd075e7 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js @@ -13,7 +13,7 @@ import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; const TEST_PROJECT_PATH = 'lorem/root'; -const TEST_BRANCH_ID = 'master'; +const TEST_BRANCH_ID = 'main'; const TEST_YAML_HELP_PATH = `${TEST_HOST}/test/yaml/help`; const TEST_RUNNERS_HELP_PATH = `${TEST_HOST}/test/runners/help`; diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js index e42e760b841..ecda7f304ba 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import * as actions from '~/ide/stores/modules/terminal/actions/session_controls'; import { STARTING, PENDING, STOPPING, STOPPED } from '~/ide/stores/modules/terminal/constants'; import * as messages from '~/ide/stores/modules/terminal/messages'; @@ -11,7 +11,7 @@ import httpStatus from '~/lib/utils/http_status'; jest.mock('~/flash'); const TEST_PROJECT_PATH = 'lorem/root'; -const TEST_BRANCH_ID = 'master'; +const TEST_BRANCH_ID = 'main'; const TEST_SESSION = { id: 7, status: PENDING, @@ -89,7 +89,9 @@ describe('IDE store terminal session controls actions', () => { it('flashes message', () => { actions.receiveStartSessionError({ dispatch }); - expect(createFlash).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STARTING); + expect(createFlash).toHaveBeenCalledWith({ + message: messages.UNEXPECTED_ERROR_STARTING, + }); }); it('sets session status', () => { @@ -161,7 +163,9 @@ describe('IDE store terminal session controls actions', () => { it('flashes message', () => { actions.receiveStopSessionError({ dispatch }); - expect(createFlash).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STOPPING); + expect(createFlash).toHaveBeenCalledWith({ + message: messages.UNEXPECTED_ERROR_STOPPING, + }); }); it('kills the session', () => { diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js index 0227955754c..eabc69b23aa 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import * as actions from '~/ide/stores/modules/terminal/actions/session_status'; import { PENDING, RUNNING, STOPPING, STOPPED } from '~/ide/stores/modules/terminal/constants'; import * as messages from '~/ide/stores/modules/terminal/messages'; @@ -115,7 +115,9 @@ describe('IDE store terminal session controls actions', () => { it('flashes message', () => { actions.receiveSessionStatusError({ dispatch }); - expect(createFlash).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STATUS); + expect(createFlash).toHaveBeenCalledWith({ + message: messages.UNEXPECTED_ERROR_STATUS, + }); }); it('kills the session', () => { diff --git a/spec/frontend/ide/stores/mutations/branch_spec.js b/spec/frontend/ide/stores/mutations/branch_spec.js index 0900b25d5d3..30a688d2bb0 100644 --- a/spec/frontend/ide/stores/mutations/branch_spec.js +++ b/spec/frontend/ide/stores/mutations/branch_spec.js @@ -10,9 +10,9 @@ describe('Multi-file store branch mutations', () => { describe('SET_CURRENT_BRANCH', () => { it('sets currentBranch', () => { - mutations.SET_CURRENT_BRANCH(localState, 'master'); + mutations.SET_CURRENT_BRANCH(localState, 'main'); - expect(localState.currentBranchId).toBe('master'); + expect(localState.currentBranchId).toBe('main'); }); }); @@ -21,20 +21,20 @@ describe('Multi-file store branch mutations', () => { localState.projects = { Example: { branches: { - master: {}, + main: {}, }, }, }; mutations.SET_BRANCH_COMMIT(localState, { projectId: 'Example', - branchId: 'master', + branchId: 'main', commit: { title: 'Example commit', }, }); - expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit'); + expect(localState.projects.Example.branches.main.commit.title).toBe('Example commit'); }); }); diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js index 825d2a546cd..1453f26c1d9 100644 --- a/spec/frontend/ide/stores/mutations/file_spec.js +++ b/spec/frontend/ide/stores/mutations/file_spec.js @@ -319,8 +319,8 @@ describe('IDE store file mutations', () => { localFile.content = 'test'; localFile.changed = true; localState.currentProjectId = 'gitlab-ce'; - localState.currentBranchId = 'master'; - localState.trees['gitlab-ce/master'] = { + localState.currentBranchId = 'main'; + localState.trees['gitlab-ce/main'] = { tree: [], }; }); @@ -337,7 +337,7 @@ describe('IDE store file mutations', () => { mutations.DISCARD_FILE_CHANGES(localState, localFile.path); - expect(localState.trees['gitlab-ce/master'].tree).toEqual([{ ...localFile, deleted: false }]); + expect(localState.trees['gitlab-ce/main'].tree).toEqual([{ ...localFile, deleted: false }]); }); it('adds to parent tree if deleted', () => { diff --git a/spec/frontend/ide/stores/mutations/tree_spec.js b/spec/frontend/ide/stores/mutations/tree_spec.js index a4b98aa9d5a..6935e57578f 100644 --- a/spec/frontend/ide/stores/mutations/tree_spec.js +++ b/spec/frontend/ide/stores/mutations/tree_spec.js @@ -33,16 +33,16 @@ describe('Multi-file store tree mutations', () => { }); it('adds directory data', () => { - localState.trees['project/master'] = { + localState.trees['project/main'] = { tree: [], }; mutations.SET_DIRECTORY_DATA(localState, { data, - treePath: 'project/master', + treePath: 'project/main', }); - const tree = localState.trees['project/master']; + const tree = localState.trees['project/main']; expect(tree.tree.length).toBe(3); expect(tree.tree[0].name).toBe('tree'); @@ -52,30 +52,30 @@ describe('Multi-file store tree mutations', () => { it('keeps loading state', () => { mutations.CREATE_TREE(localState, { - treePath: 'project/master', + treePath: 'project/main', }); mutations.SET_DIRECTORY_DATA(localState, { data, - treePath: 'project/master', + treePath: 'project/main', }); - expect(localState.trees['project/master'].loading).toBe(true); + expect(localState.trees['project/main'].loading).toBe(true); }); it('does not override tree already in state, but merges the two with correct order', () => { const openedFile = file('new'); - localState.trees['project/master'] = { + localState.trees['project/main'] = { loading: true, tree: [openedFile], }; mutations.SET_DIRECTORY_DATA(localState, { data, - treePath: 'project/master', + treePath: 'project/main', }); - const { tree } = localState.trees['project/master']; + const { tree } = localState.trees['project/main']; expect(tree.length).toBe(4); expect(tree[0].name).toBe('blob'); @@ -86,17 +86,17 @@ describe('Multi-file store tree mutations', () => { it('returns tree unchanged if the opened file is already in the tree', () => { const openedFile = file('foo'); - localState.trees['project/master'] = { + localState.trees['project/main'] = { loading: true, tree: [openedFile], }; mutations.SET_DIRECTORY_DATA(localState, { data, - treePath: 'project/master', + treePath: 'project/main', }); - const { tree } = localState.trees['project/master']; + const { tree } = localState.trees['project/main']; expect(tree.length).toBe(3); diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js index 09e9481e5d4..23fe23bdef9 100644 --- a/spec/frontend/ide/stores/mutations_spec.js +++ b/spec/frontend/ide/stores/mutations_spec.js @@ -98,8 +98,8 @@ describe('Multi-file store mutations', () => { describe('CREATE_TMP_ENTRY', () => { beforeEach(() => { localState.currentProjectId = 'gitlab-ce'; - localState.currentBranchId = 'master'; - localState.trees['gitlab-ce/master'] = { + localState.currentBranchId = 'main'; + localState.trees['gitlab-ce/main'] = { tree: [], }; }); @@ -115,7 +115,7 @@ describe('Multi-file store mutations', () => { }, }); - expect(localState.trees['gitlab-ce/master'].tree.length).toEqual(1); + expect(localState.trees['gitlab-ce/main'].tree.length).toEqual(1); expect(localState.entries.test.tempFile).toEqual(true); }); }); @@ -163,8 +163,8 @@ describe('Multi-file store mutations', () => { describe('DELETE_ENTRY', () => { beforeEach(() => { localState.currentProjectId = 'gitlab-ce'; - localState.currentBranchId = 'master'; - localState.trees['gitlab-ce/master'] = { + localState.currentBranchId = 'main'; + localState.trees['gitlab-ce/main'] = { tree: [], }; }); @@ -184,11 +184,11 @@ describe('Multi-file store mutations', () => { path: 'filePath', deleted: false, }; - localState.trees['gitlab-ce/master'].tree.push(localState.entries.filePath); + localState.trees['gitlab-ce/main'].tree.push(localState.entries.filePath); mutations.DELETE_ENTRY(localState, 'filePath'); - expect(localState.trees['gitlab-ce/master'].tree).toEqual([]); + expect(localState.trees['gitlab-ce/main'].tree).toEqual([]); }); it('removes from parent tree', () => { @@ -279,12 +279,12 @@ describe('Multi-file store mutations', () => { describe('RENAME_ENTRY', () => { beforeEach(() => { localState.trees = { - 'gitlab-ce/master': { + 'gitlab-ce/main': { tree: [], }, }; localState.currentProjectId = 'gitlab-ce'; - localState.currentBranchId = 'master'; + localState.currentBranchId = 'main'; localState.entries = { oldPath: file('oldPath', 'oldPath', 'blob'), }; @@ -462,7 +462,7 @@ describe('Multi-file store mutations', () => { gamma, }; - localState.trees['gitlab-ce/master'].tree = [alpha, beta, gamma]; + localState.trees['gitlab-ce/main'].tree = [alpha, beta, gamma]; mutations.RENAME_ENTRY(localState, { path: 'alpha', @@ -471,7 +471,7 @@ describe('Multi-file store mutations', () => { parentPath: '', }); - expect(localState.trees['gitlab-ce/master'].tree).toEqual([ + expect(localState.trees['gitlab-ce/main'].tree).toEqual([ expect.objectContaining({ name: 'beta', }), diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js index 46a0794b2e6..8f7b8c5e311 100644 --- a/spec/frontend/ide/stores/utils_spec.js +++ b/spec/frontend/ide/stores/utils_spec.js @@ -18,13 +18,13 @@ describe('Multi-file store utils', () => { }; const state = { - currentBranchId: 'master', + currentBranchId: 'main', currentProjectId: 'test/test', }; utils.setPageTitleForFile(state, f); - expect(document.title).toBe('README.md · master · test/test · GitLab'); + expect(document.title).toBe('README.md · main · test/test · GitLab'); }); }); @@ -52,10 +52,10 @@ describe('Multi-file store utils', () => { { ...file('deletedFile'), path: 'deletedFile', deleted: true }, { ...file('renamedFile'), path: 'renamedFile', prevPath: 'prevPath' }, ], - currentBranchId: 'master', + currentBranchId: 'main', }; const payload = utils.createCommitPayload({ - branch: 'master', + branch: 'main', newBranch: false, state, rootState, @@ -63,7 +63,7 @@ describe('Multi-file store utils', () => { }); expect(payload).toEqual({ - branch: 'master', + branch: 'main', commit_message: 'commit message', actions: [ { @@ -122,10 +122,10 @@ describe('Multi-file store utils', () => { lastCommitSha: '123456789', }, ], - currentBranchId: 'master', + currentBranchId: 'main', }; const payload = utils.createCommitPayload({ - branch: 'master', + branch: 'main', newBranch: false, state: {}, rootState, @@ -135,7 +135,7 @@ describe('Multi-file store utils', () => { }); expect(payload).toEqual({ - branch: 'master', + branch: 'main', commit_message: 'prebuilt test commit message', actions: [ { @@ -377,7 +377,7 @@ describe('Multi-file store utils', () => { let localState; let branchInfo; const currentProjectId = '123-foo'; - const currentBranchId = 'master'; + const currentBranchId = 'main'; beforeEach(() => { localState = { diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index f467d174eeb..00733615f81 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -274,33 +274,33 @@ describe('WebIDE utils', () => { * hello.md -> hello-1.md * hello_2.md -> hello_3.md * hello_ -> hello_1 - * master-patch-22432 -> master-patch-22433 + * main-patch-22432 -> main-patch-22433 * patch_332 -> patch_333 */ describe('addNumericSuffix', () => { it.each` - input | output - ${'hello'} | ${'hello-1'} - ${'hello2'} | ${'hello-3'} - ${'hello.md'} | ${'hello-1.md'} - ${'hello_2.md'} | ${'hello_3.md'} - ${'hello_'} | ${'hello_1'} - ${'master-patch-22432'} | ${'master-patch-22433'} - ${'patch_332'} | ${'patch_333'} + input | output + ${'hello'} | ${'hello-1'} + ${'hello2'} | ${'hello-3'} + ${'hello.md'} | ${'hello-1.md'} + ${'hello_2.md'} | ${'hello_3.md'} + ${'hello_'} | ${'hello_1'} + ${'main-patch-22432'} | ${'main-patch-22433'} + ${'patch_332'} | ${'patch_333'} `('adds a numeric suffix to a given filename/branch name: $input', ({ input, output }) => { expect(addNumericSuffix(input)).toBe(output); }); it.each` - input | output - ${'hello'} | ${'hello-39135'} - ${'hello2'} | ${'hello-39135'} - ${'hello.md'} | ${'hello-39135.md'} - ${'hello_2.md'} | ${'hello_39135.md'} - ${'hello_'} | ${'hello_39135'} - ${'master-patch-22432'} | ${'master-patch-39135'} - ${'patch_332'} | ${'patch_39135'} + input | output + ${'hello'} | ${'hello-39135'} + ${'hello2'} | ${'hello-39135'} + ${'hello.md'} | ${'hello-39135.md'} + ${'hello_2.md'} | ${'hello_39135.md'} + ${'hello_'} | ${'hello_39135'} + ${'main-patch-22432'} | ${'main-patch-39135'} + ${'patch_332'} | ${'patch_39135'} `('adds a random suffix if randomize=true is passed for name: $input', ({ input, output }) => { jest.spyOn(Math, 'random').mockReturnValue(0.391352525); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js index 0c69cfb3bc5..aa6a40cad18 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js @@ -5,11 +5,15 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { STATUSES } from '~/import_entities/constants'; import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; -import groupQuery from '~/import_entities/import_groups/graphql/queries/group.query.graphql'; +import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql'; +import removeValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql'; +import groupAndProjectQuery from '~/import_entities/import_groups/graphql/queries/groupAndProject.query.graphql'; import { availableNamespacesFixture } from '../graphql/fixtures'; Vue.use(VueApollo); +const { i18n: I18N } = ImportTableRow; + const getFakeGroup = (status) => ({ web_url: 'https://fake.host/', full_path: 'fake_group_1', @@ -25,6 +29,7 @@ const getFakeGroup = (status) => ({ const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group'; const EXISTING_GROUP_PATH = 'existing-path'; +const EXISTING_PROJECT_PATH = 'existing-project-path'; describe('import table row', () => { let wrapper; @@ -41,13 +46,19 @@ describe('import table row', () => { const createComponent = (props) => { apolloProvider = createMockApollo([ [ - groupQuery, + groupAndProjectQuery, ({ fullPath }) => { const existingGroup = fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_GROUP_PATH}` ? { id: 1 } : null; - return Promise.resolve({ data: { existingGroup } }); + + const existingProject = + fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_PROJECT_PATH}` + ? { id: 1 } + : null; + + return Promise.resolve({ data: { existingGroup, existingProject } }); }, ], ]); @@ -173,7 +184,7 @@ describe('import table row', () => { }); describe('validations', () => { - it('Reports invalid group name when name is not matching regex', () => { + it('reports invalid group name when name is not matching regex', () => { createComponent({ group: { ...getFakeGroup(STATUSES.NONE), @@ -188,7 +199,7 @@ describe('import table row', () => { expect(wrapper.text()).toContain('Please choose a group URL with no special characters.'); }); - it('Reports invalid group name if relevant validation error exists', async () => { + it('reports invalid group name if relevant validation error exists', async () => { const FAKE_ERROR_MESSAGE = 'fake error'; createComponent({ @@ -208,5 +219,101 @@ describe('import table row', () => { expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE); }); + + it('sets validation error when targetting existing group', async () => { + const testGroup = getFakeGroup(STATUSES.NONE); + + createComponent({ + group: { + ...testGroup, + import_target: { + target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, + new_name: EXISTING_GROUP_PATH, + }, + }, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: addValidationErrorMutation, + variables: { + field: 'new_name', + message: I18N.NAME_ALREADY_EXISTS, + sourceGroupId: testGroup.id, + }, + }); + }); + + it('sets validation error when targetting existing project', async () => { + const testGroup = getFakeGroup(STATUSES.NONE); + + createComponent({ + group: { + ...testGroup, + import_target: { + target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, + new_name: EXISTING_PROJECT_PATH, + }, + }, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: addValidationErrorMutation, + variables: { + field: 'new_name', + message: I18N.NAME_ALREADY_EXISTS, + sourceGroupId: testGroup.id, + }, + }); + }); + + it('clears validation error when target is updated', async () => { + const testGroup = getFakeGroup(STATUSES.NONE); + + createComponent({ + group: { + ...testGroup, + import_target: { + target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, + new_name: EXISTING_PROJECT_PATH, + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + + await wrapper.setProps({ + group: { + ...testGroup, + import_target: { + target_namespace: 'valid_namespace', + new_name: 'valid_path', + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: removeValidationErrorMutation, + variables: { + field: 'new_name', + sourceGroupId: testGroup.id, + }, + }); + }); }); }); diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js index 9bff77cd34a..f2bfc61381c 100644 --- a/spec/frontend/import_entities/import_projects/store/actions_spec.js +++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { STATUSES } from '~/import_entities/constants'; import actionsFactory from '~/import_entities/import_projects/store/actions'; import { getImportTarget } from '~/import_entities/import_projects/store/getters'; @@ -168,7 +168,9 @@ describe('import_projects store actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith('Provider rate limit exceeded. Try again later'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Provider rate limit exceeded. Try again later', + }); }); }); @@ -245,7 +247,9 @@ describe('import_projects store actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith('Importing the project failed'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Importing the project failed', + }); }); it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows detailed error message on an unsuccessful request with errors fields in response', async () => { @@ -266,7 +270,9 @@ describe('import_projects store actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith(`Importing the project failed: ${ERROR_MESSAGE}`); + expect(createFlash).toHaveBeenCalledWith({ + message: `Importing the project failed: ${ERROR_MESSAGE}`, + }); }); }); @@ -365,7 +371,9 @@ describe('import_projects store actions', () => { [], ); - expect(createFlash).toHaveBeenCalledWith('Requesting namespaces failed'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Requesting namespaces failed', + }); }); }); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index c7286d70b94..8d4ccab2a40 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -43,12 +43,10 @@ describe('Incidents List', () => { const findLoader = () => wrapper.find(GlLoadingIcon); const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip); const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]'); - const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]'); const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']"); const findEmptyState = () => wrapper.find(GlEmptyState); const findSeverity = () => wrapper.findAll(SeverityToken); - const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']"); function mountComponent({ data = {}, loading = false, provide = {} } = {}) { wrapper = mount(IncidentsList, { @@ -188,35 +186,6 @@ describe('Incidents List', () => { joinPaths(`/project/issues/incident`, mockIncidents[0].iid), ); }); - - describe('Incident SLA field', () => { - it('displays the column when the feature is available', () => { - mountComponent({ - data: { incidents: { list: mockIncidents } }, - provide: { slaFeatureAvailable: true }, - }); - - expect(findIncidentSlaHeader().text()).toContain('Time to SLA'); - }); - - it('does not display the column when the feature is not available', () => { - mountComponent({ - data: { incidents: { list: mockIncidents } }, - provide: { slaFeatureAvailable: false }, - }); - - expect(findIncidentSlaHeader().exists()).toBe(false); - }); - - it('renders an SLA for each incident', () => { - mountComponent({ - data: { incidents: { list: mockIncidents } }, - provide: { slaFeatureAvailable: true }, - }); - - expect(findIncidentSla().length).toBe(mockIncidents.length); - }); - }); }); describe('Create Incident', () => { diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap index 07f90a12f0f..4f70f908c4a 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap @@ -2,7 +2,7 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` <section - class="settings no-animate qa-incident-management-settings" + class="settings no-animate" data-qa-selector="incidents_settings_content" id="incident-management-settings" > @@ -30,7 +30,7 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` <p> - Set up integrations with external tools to help better manage incidents. + Fine-tune incident settings and set up integrations with external tools to help better manage incidents. </p> </div> @@ -41,15 +41,8 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` <gl-tabs-stub theme="indigo" > - <gl-tab-stub - title="Alert integration" - titlelinkclass="" - > - <alertssettingsform-stub - class="gl-pt-3" - data-testid="AlertsSettingsForm-tab" - /> - </gl-tab-stub> + <!----> + <gl-tab-stub title="PagerDuty integration" titlelinkclass="" @@ -59,9 +52,6 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` data-testid="PagerDutySettingsForm-tab" /> </gl-tab-stub> - <!----> - - <!----> </gl-tabs-stub> </div> </section> diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index 79ad5ad1bb9..2a976c04319 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -13,6 +13,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] = <form> <gl-form-group-stub class="col-8 col-md-9 gl-p-0" + labeldescription="" > <gl-toggle-stub id="active" @@ -26,6 +27,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] = class="col-8 col-md-9 gl-p-0" label="Webhook URL" label-for="url" + labeldescription="" > <gl-form-input-group-stub data-testid="webhook-url" @@ -66,20 +68,6 @@ exports[`Alert integration settings form should match the default snapshot 1`] = </gl-modal-stub> </gl-form-group-stub> - - <gl-button-stub - buttontextclasses="" - category="primary" - class="js-no-auto-disable" - icon="" - size="medium" - type="submit" - variant="success" - > - - Save changes - - </gl-button-stub> </form> </div> `; diff --git a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js index 5476e895c68..f4342c56f98 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js @@ -1,5 +1,5 @@ import AxiosMockAdapter from 'axios-mock-adapter'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { ERROR_MSG } from '~/incidents_settings/constants'; import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service'; import axios from '~/lib/utils/axios_utils'; @@ -37,7 +37,10 @@ describe('IncidentsSettingsService', () => { mock.onPatch().reply(httpStatusCodes.BAD_REQUEST); return service.updateSettings({}).then(() => { - expect(createFlash).toHaveBeenCalledWith(expect.stringContaining(ERROR_MSG), 'alert'); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringContaining(ERROR_MSG), + type: 'alert', + }); }); }); }); diff --git a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js index 2ffd1292ddc..d2b591d427d 100644 --- a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js +++ b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js @@ -1,5 +1,5 @@ -import { GlAlert, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlModal, GlToggle } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PagerDutySettingsForm from '~/incidents_settings/components/pagerduty_form.vue'; @@ -8,13 +8,13 @@ describe('Alert integration settings form', () => { const resetWebhookUrl = jest.fn(); const service = { updateSettings: jest.fn().mockResolvedValue(), resetWebhookUrl }; - const findForm = () => wrapper.find({ ref: 'settingsForm' }); - const findWebhookInput = () => wrapper.find('[data-testid="webhook-url"]'); - const findModal = () => wrapper.find(GlModal); - const findAlert = () => wrapper.find(GlAlert); + const findWebhookInput = () => wrapper.findByTestId('webhook-url'); + const findFormToggle = () => wrapper.findComponent(GlToggle); + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => { - wrapper = shallowMount(PagerDutySettingsForm, { + wrapper = shallowMountExtended(PagerDutySettingsForm, { provide: { service, pagerDutySettings: { @@ -27,18 +27,15 @@ describe('Alert integration settings form', () => { }); afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); it('should match the default snapshot', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('should call service `updateSettings` on form submit', () => { - findForm().trigger('submit'); + it('should call service `updateSettings` on toggle change', () => { + findFormToggle().vm.$emit('change', true); expect(service.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ pagerduty_active: wrapper.vm.active }), ); diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js index 0e56fb6454e..df7ffd19747 100644 --- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js +++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js @@ -1,5 +1,6 @@ import { GlFormCheckbox } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; + import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import { createStore } from '~/integrations/edit/store'; diff --git a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js index 1c126f60c37..805d3971994 100644 --- a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js +++ b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js @@ -1,5 +1,6 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; + import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; import { createStore } from '~/integrations/edit/store'; @@ -13,13 +14,10 @@ describe('ConfirmationModal', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findGlModal = () => wrapper.find(GlModal); + const findGlModal = () => wrapper.findComponent(GlModal); describe('template', () => { beforeEach(() => { diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index 2ebb3333c0f..8784b3c2b00 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -1,5 +1,6 @@ import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; + import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; describe('DynamicField', () => { @@ -24,17 +25,14 @@ describe('DynamicField', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findGlFormGroup = () => wrapper.find(GlFormGroup); - const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox); - const findGlFormInput = () => wrapper.find(GlFormInput); - const findGlFormSelect = () => wrapper.find(GlFormSelect); - const findGlFormTextarea = () => wrapper.find(GlFormTextarea); + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); + const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findGlFormInput = () => wrapper.findComponent(GlFormInput); + const findGlFormSelect = () => wrapper.findComponent(GlFormSelect); + const findGlFormTextarea = () => wrapper.findComponent(GlFormTextarea); describe('template', () => { describe.each([ diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index c015fd0b9e0..cbce26762b1 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,6 +1,6 @@ -import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture } from 'helpers/fixtures'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import { mockIntegrationProps } from 'jest/integrations/edit/mock_data'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; @@ -23,42 +23,37 @@ describe('IntegrationForm', () => { initialState = {}, props = {}, } = {}) => { - wrapper = extendedWrapper( - shallowMount(IntegrationForm, { - propsData: { ...props }, - store: createStore({ - customState: { ...mockIntegrationProps, ...customStateProps }, - ...initialState, - }), - stubs: { - OverrideDropdown, - ActiveCheckbox, - ConfirmationModal, - JiraTriggerFields, - TriggerFields, - }, - provide: { - glFeatures: featureFlags, - }, + wrapper = shallowMountExtended(IntegrationForm, { + propsData: { ...props }, + store: createStore({ + customState: { ...mockIntegrationProps, ...customStateProps }, + ...initialState, }), - ); + stubs: { + OverrideDropdown, + ActiveCheckbox, + ConfirmationModal, + JiraTriggerFields, + TriggerFields, + }, + provide: { + glFeatures: featureFlags, + }, + }); }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findOverrideDropdown = () => wrapper.find(OverrideDropdown); - const findActiveCheckbox = () => wrapper.find(ActiveCheckbox); - const findConfirmationModal = () => wrapper.find(ConfirmationModal); - const findResetConfirmationModal = () => wrapper.find(ResetConfirmationModal); - const findResetButton = () => wrapper.find('[data-testid="reset-button"]'); - const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields); - const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields); - const findTriggerFields = () => wrapper.find(TriggerFields); + const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown); + const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox); + const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal); + const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal); + const findResetButton = () => wrapper.findByTestId('reset-button'); + const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields); + const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields); + const findTriggerFields = () => wrapper.findComponent(TriggerFields); describe('template', () => { describe('showActive is true', () => { @@ -286,7 +281,7 @@ describe('IntegrationForm', () => { </div> `); - it('renders `helpHtml`', async () => { + it('renders `helpHtml`', () => { const mockHelpHtml = document.querySelector(`[data-testid="${mockTestId}"]`); createComponent({ diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index f121a148f27..eb5f7e9fe40 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -1,10 +1,13 @@ import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import JiraUpgradeCta from '~/integrations/edit/components/jira_upgrade_cta.vue'; import eventHub from '~/integrations/edit/event_hub'; +import { createStore } from '~/integrations/edit/store'; describe('JiraIssuesFields', () => { + let store; let wrapper; const defaultProps = { @@ -13,25 +16,29 @@ describe('JiraIssuesFields', () => { showJiraVulnerabilitiesIntegration: true, }; - const createComponent = ({ props, ...options } = {}) => { - wrapper = mount(JiraIssuesFields, { + const createComponent = ({ isInheriting = false, props, ...options } = {}) => { + store = createStore({ + defaultState: isInheriting ? {} : undefined, + }); + + wrapper = mountExtended(JiraIssuesFields, { propsData: { ...defaultProps, ...props }, + store, stubs: ['jira-issue-creation-vulnerabilities'], ...options, }); }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); const findEnableCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findEnableCheckboxDisabled = () => + findEnableCheckbox().find('[type=checkbox]').attributes('disabled'); const findProjectKey = () => wrapper.findComponent(GlFormInput); const findJiraUpgradeCta = () => wrapper.findComponent(JiraUpgradeCta); - const findJiraForVulnerabilities = () => wrapper.find('[data-testid="jira-for-vulnerabilities"]'); + const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities'); const setEnableCheckbox = async (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); @@ -79,6 +86,19 @@ describe('JiraIssuesFields', () => { createComponent({ props: { initialProjectKey: '' } }); }); + it('renders enabled checkbox', () => { + expect(findEnableCheckbox().exists()).toBe(true); + expect(findEnableCheckboxDisabled()).toBeUndefined(); + }); + + it('renders disabled project_key input', () => { + const projectKey = findProjectKey(); + + expect(projectKey.exists()).toBe(true); + expect(projectKey.attributes('disabled')).toBe('disabled'); + expect(projectKey.attributes('required')).toBeUndefined(); + }); + it('does not show upgrade banner', () => { expect(findJiraUpgradeCta().exists()).toBe(false); }); @@ -89,24 +109,20 @@ describe('JiraIssuesFields', () => { expect(wrapper.find('input[name="service[issues_enabled]"]').exists()).toBe(true); }); - it('disables project_key input', () => { - expect(findProjectKey().attributes('disabled')).toBe('disabled'); - }); + describe('when isInheriting = true', () => { + it('disables checkbox and sets input as readonly', () => { + createComponent({ isInheriting: true }); - it('does not require project_key', () => { - expect(findProjectKey().attributes('required')).toBeUndefined(); + expect(findEnableCheckboxDisabled()).toBe('disabled'); + expect(findProjectKey().attributes('readonly')).toBe('readonly'); + }); }); describe('on enable issues', () => { - it('enables project_key input', async () => { + it('enables project_key input as required', async () => { await setEnableCheckbox(true); expect(findProjectKey().attributes('disabled')).toBeUndefined(); - }); - - it('requires project_key input', async () => { - await setEnableCheckbox(true); - expect(findProjectKey().attributes('required')).toBe('required'); }); }); diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js index 5c04add61a1..9e01371f542 100644 --- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js @@ -1,5 +1,6 @@ import { GlFormCheckbox } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; describe('JiraTriggerFields', () => { @@ -12,7 +13,7 @@ describe('JiraTriggerFields', () => { }; const createComponent = (props, isInheriting = false) => { - wrapper = mount(JiraTriggerFields, { + wrapper = mountExtended(JiraTriggerFields, { propsData: { ...defaultProps, ...props }, computed: { isInheriting: () => isInheriting, @@ -21,18 +22,15 @@ describe('JiraTriggerFields', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findCommentSettings = () => wrapper.find('[data-testid="comment-settings"]'); - const findCommentDetail = () => wrapper.find('[data-testid="comment-detail"]'); - const findCommentSettingsCheckbox = () => findCommentSettings().find(GlFormCheckbox); + const findCommentSettings = () => wrapper.findByTestId('comment-settings'); + const findCommentDetail = () => wrapper.findByTestId('comment-detail'); + const findCommentSettingsCheckbox = () => findCommentSettings().findComponent(GlFormCheckbox); const findIssueTransitionEnabled = () => wrapper.find('[data-testid="issue-transition-enabled"] input[type="checkbox"]'); - const findIssueTransitionMode = () => wrapper.find('[data-testid="issue-transition-mode"]'); + const findIssueTransitionMode = () => wrapper.findByTestId('issue-transition-mode'); const findIssueTransitionModeRadios = () => findIssueTransitionMode().findAll('input[type="radio"]'); const findIssueTransitionIdsField = () => diff --git a/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js b/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js index e49a1619627..e90e9a5d2ac 100644 --- a/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js +++ b/spec/frontend/integrations/edit/components/jira_upgrade_cta_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; + import JiraUpgradeCta from '~/integrations/edit/components/jira_upgrade_cta.vue'; describe('JiraUpgradeCta', () => { @@ -18,13 +19,13 @@ describe('JiraUpgradeCta', () => { it('displays the correct message for premium and lower users', () => { createComponent({ showPremiumMessage: true }); - expect(wrapper.html()).toContain('This is a Premium feature'); - expect(wrapper.html()).toContain(contentMessage); + expect(wrapper.text()).toContain('This is a Premium feature'); + expect(wrapper.text()).toContain(contentMessage); }); it('displays the correct message for ultimate and lower users', () => { createComponent({ showUltimateMessage: true }); - expect(wrapper.html()).toContain('This is an Ultimate feature'); - expect(wrapper.html()).toContain(contentMessage); + expect(wrapper.text()).toContain('This is an Ultimate feature'); + expect(wrapper.text()).toContain(contentMessage); }); }); diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js index 592f4514e45..eb43d940f5e 100644 --- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js +++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js @@ -1,5 +1,6 @@ import { GlDropdown, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; + import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/edit/constants'; import { createStore } from '~/integrations/edit/store'; @@ -26,14 +27,11 @@ describe('OverrideDropdown', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); - const findGlLink = () => wrapper.find(GlLink); - const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlLink = () => wrapper.findComponent(GlLink); + const findGlDropdown = () => wrapper.findComponent(GlDropdown); describe('template', () => { describe('override prop is true', () => { diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js index b9d16464e72..a0816682741 100644 --- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js @@ -1,5 +1,6 @@ import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; describe('TriggerFields', () => { @@ -10,7 +11,7 @@ describe('TriggerFields', () => { }; const createComponent = (props, isInheriting = false) => { - wrapper = mount(TriggerFields, { + wrapper = mountExtended(TriggerFields, { propsData: { ...defaultProps, ...props }, computed: { isInheriting: () => isInheriting, @@ -19,21 +20,19 @@ describe('TriggerFields', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); }); + const findTriggerLabel = () => wrapper.findByTestId('trigger-fields-group').find('label'); const findAllGlFormGroups = () => wrapper.find('#trigger-fields').findAll(GlFormGroup); - const findAllGlFormCheckboxes = () => wrapper.findAll(GlFormCheckbox); - const findAllGlFormInputs = () => wrapper.findAll(GlFormInput); + const findAllGlFormCheckboxes = () => wrapper.findAllComponents(GlFormCheckbox); + const findAllGlFormInputs = () => wrapper.findAllComponents(GlFormInput); describe.each([true, false])('template, isInheriting = `%p`', (isInheriting) => { it('renders a label with text "Trigger"', () => { createComponent(); - const triggerLabel = wrapper.find('[data-testid="trigger-fields-group"]').find('label'); + const triggerLabel = findTriggerLabel(); expect(triggerLabel.exists()).toBe(true); expect(triggerLabel.text()).toBe('Trigger'); }); @@ -68,7 +67,7 @@ describe('TriggerFields', () => { }); it('renders GlFormInput with description for each event', () => { - const groups = wrapper.find('#trigger-fields').findAll(GlFormGroup); + const groups = findAllGlFormGroups(); expect(groups).toHaveLength(2); groups.wrappers.forEach((group, index) => { @@ -138,11 +137,11 @@ describe('TriggerFields', () => { const expectedResults = [ { name: 'service[push_channel]', - placeholder: 'general, development', + placeholder: '#general, #development', }, { name: 'service[merge_request_channel]', - placeholder: 'general, development', + placeholder: '#general, #development', }, ]; diff --git a/spec/frontend/integrations/index/components/integrations_list_spec.js b/spec/frontend/integrations/index/components/integrations_list_spec.js index 94fd7fc84ee..ee54a5fd359 100644 --- a/spec/frontend/integrations/index/components/integrations_list_spec.js +++ b/spec/frontend/integrations/index/components/integrations_list_spec.js @@ -1,5 +1,5 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + import IntegrationsList from '~/integrations/index/components/integrations_list.vue'; import { mockActiveIntegrations, mockInactiveIntegrations } from '../mock_data'; @@ -10,7 +10,7 @@ describe('IntegrationsList', () => { const findInactiveIntegrationsTable = () => wrapper.findByTestId('inactive-integrations-table'); const createComponent = (propsData = {}) => { - wrapper = extendedWrapper(shallowMount(IntegrationsList, { propsData })); + wrapper = shallowMountExtended(IntegrationsList, { propsData }); }; afterEach(() => { diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js index 2a6985de136..2ef8fe07650 100644 --- a/spec/frontend/invite_members/components/group_select_spec.js +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -1,22 +1,22 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlAvatarLabeled, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import Api from '~/api'; +import * as groupsApi from '~/api/groups_api'; import GroupSelect from '~/invite_members/components/group_select.vue'; const createComponent = () => { return mount(GroupSelect, {}); }; -const group1 = { id: 1, full_name: 'Group One' }; -const group2 = { id: 2, full_name: 'Group Two' }; +const group1 = { id: 1, full_name: 'Group One', avatar_url: 'test' }; +const group2 = { id: 2, full_name: 'Group Two', avatar_url: 'test' }; const allGroups = [group1, group2]; describe('GroupSelect', () => { let wrapper; beforeEach(() => { - jest.spyOn(Api, 'groups').mockResolvedValue(allGroups); + jest.spyOn(groupsApi, 'getGroups').mockResolvedValue(allGroups); wrapper = createComponent(); }); @@ -29,10 +29,10 @@ describe('GroupSelect', () => { const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="true"]'); - const findDropdownItemByText = (text) => + const findAvatarByLabel = (text) => wrapper - .findAllComponents(GlDropdownItem) - .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text); + .findAllComponents(GlAvatarLabeled) + .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.props('label') === text); it('renders GlSearchBoxByType with default attributes', () => { expect(findSearchBoxByType().exists()).toBe(true); @@ -45,7 +45,7 @@ describe('GroupSelect', () => { let resolveApiRequest; beforeEach(() => { - jest.spyOn(Api, 'groups').mockImplementation( + jest.spyOn(groupsApi, 'getGroups').mockImplementation( () => new Promise((resolve) => { resolveApiRequest = resolve; @@ -58,7 +58,7 @@ describe('GroupSelect', () => { it('calls the API', () => { resolveApiRequest({ data: allGroups }); - expect(Api.groups).toHaveBeenCalledWith(group1.name, { + expect(groupsApi.getGroups).toHaveBeenCalledWith(group1.name, { active: true, exclude_internal: true, }); @@ -74,9 +74,20 @@ describe('GroupSelect', () => { }); }); + describe('avatar label', () => { + it('includes the correct attributes with name and avatar_url', () => { + expect(findAvatarByLabel(group1.full_name).attributes()).toMatchObject({ + src: group1.avatar_url, + 'entity-id': `${group1.id}`, + 'entity-name': group1.full_name, + size: '32', + }); + }); + }); + describe('when group is selected from the dropdown', () => { beforeEach(() => { - findDropdownItemByText(group1.full_name).vm.$emit('click'); + findAvatarByLabel(group1.full_name).trigger('click'); }); it('emits `input` event used by `v-model`', () => { diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 7ed18775693..eabbea84234 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -15,6 +15,7 @@ const isProject = false; const inviteeType = 'members'; const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; const defaultAccessLevel = 10; +const inviteSource = 'unknown'; const helpLink = 'https://example.com'; const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; @@ -173,6 +174,7 @@ describe('InviteMembersModal', () => { user_id: '1', access_level: defaultAccessLevel, expires_at: undefined, + invite_source: inviteSource, format: 'json', }; @@ -245,6 +247,7 @@ describe('InviteMembersModal', () => { access_level: defaultAccessLevel, expires_at: undefined, email: 'email@example.com', + invite_source: inviteSource, format: 'json', }; @@ -293,6 +296,7 @@ describe('InviteMembersModal', () => { const postData = { access_level: defaultAccessLevel, expires_at: undefined, + invite_source: inviteSource, format: 'json', }; @@ -308,20 +312,39 @@ describe('InviteMembersModal', () => { jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); jest.spyOn(wrapper.vm, 'trackInvite'); - - clickInviteButton(); }); - it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData); - }); + describe('when triggered from regular mounting', () => { + beforeEach(() => { + clickInviteButton(); + }); - it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData); + it('calls Api inviteGroupMembersByEmail with the correct params', () => { + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData); + }); + + it('calls Api addGroupMembersByUserId with the correct params', () => { + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); }); - it('displays the successful toastMessage', () => { - expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + it('calls Apis with the invite source passed through to openModal', () => { + wrapper.vm.openModal({ inviteeType: 'members', source: '_invite_source_' }); + + clickInviteButton(); + + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, { + ...emailPostData, + invite_source: '_invite_source_', + }); + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, { + ...idPostData, + invite_source: '_invite_source_', + }); }); }); @@ -403,18 +426,11 @@ describe('InviteMembersModal', () => { }); describe('tracking', () => { - const postData = { - user_id: '1', - access_level: defaultAccessLevel, - expires_at: undefined, - format: 'json', - }; - beforeEach(() => { wrapper = createComponent({ newUsersToInvite: [user3] }); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); }); it('tracks the invite', () => { diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index b569b6286e0..f57af61ad5b 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -7,6 +7,8 @@ import eventHub from '~/invite_members/event_hub'; jest.mock('~/experimentation/experiment_tracking'); const displayText = 'Invite team members'; +const triggerSource = '_trigger_source_'; + let wrapper; let triggerProps; let findButton; @@ -26,7 +28,7 @@ const createComponent = (props = {}) => { }; describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => { - triggerProps = { triggerElement }; + triggerProps = { triggerElement, triggerSource }; findButton = () => wrapper.findComponent(triggerComponent[triggerElement]); afterEach(() => { @@ -48,22 +50,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement spy = jest.spyOn(eventHub, '$emit'); }); - it('emits openModal from an unknown source', () => { - createComponent(); - - findButton().vm.$emit('click'); - - expect(spy).toHaveBeenCalledWith('openModal', { inviteeType: 'members', source: 'unknown' }); - }); - it('emits openModal from a named source', () => { - createComponent({ triggerSource: '_trigger_source_' }); + createComponent(); findButton().vm.$emit('click'); expect(spy).toHaveBeenCalledWith('openModal', { inviteeType: 'members', - source: '_trigger_source_', + source: triggerSource, }); }); }); diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index 7eb85a946ae..34094d22e68 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -1,7 +1,6 @@ import { GlModal, GlIcon, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; describe('CsvExportModal', () => { @@ -9,26 +8,24 @@ describe('CsvExportModal', () => { function createComponent(options = {}) { const { injectedProperties = {}, props = {} } = options; - return extendedWrapper( - mount(CsvExportModal, { - propsData: { - modalId: 'csv-export-modal', - exportCsvPath: 'export/csv/path', - issuableCount: 1, - ...props, - }, - provide: { - issuableType: 'issues', - ...injectedProperties, - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: - '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', - }), - }, - }), - ); + return mount(CsvExportModal, { + propsData: { + modalId: 'csv-export-modal', + exportCsvPath: 'export/csv/path', + issuableCount: 1, + ...props, + }, + provide: { + issuableType: 'issues', + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }); } afterEach(() => { @@ -61,14 +58,13 @@ describe('CsvExportModal', () => { describe('issuable count info text', () => { it('displays the info text when issuableCount is > -1', () => { wrapper = createComponent({ props: { issuableCount: 10 } }); - expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(true); - expect(wrapper.findByTestId('issuable-count-note').text()).toContain('10 issues selected'); + expect(wrapper.text()).toContain('10 issues selected'); expect(findIcon().exists()).toBe(true); }); it("doesn't display the info text when issuableCount is -1", () => { wrapper = createComponent({ props: { issuableCount: -1 } }); - expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false); + expect(wrapper.text()).not.toContain('issues selected'); }); }); diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js index 2fe8d28a333..118c12d968b 100644 --- a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js +++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js @@ -1,6 +1,6 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlDropdown } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; @@ -14,35 +14,33 @@ describe('CsvImportExportButtons', () => { function createComponent(injectedProperties = {}) { glModalDirective = jest.fn(); - return extendedWrapper( - shallowMount(CsvImportExportButtons, { - directives: { - GlTooltip: createMockDirective(), - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, + return mountExtended(CsvImportExportButtons, { + directives: { + GlTooltip: createMockDirective(), + glModal: { + bind(_, { value }) { + glModalDirective(value); }, }, - provide: { - ...injectedProperties, - }, - propsData: { - exportCsvPath, - issuableCount, - }, - }), - ); + }, + provide: { + ...injectedProperties, + }, + propsData: { + exportCsvPath, + issuableCount, + }, + }); } afterEach(() => { wrapper.destroy(); }); - const findExportCsvButton = () => wrapper.findByTestId('export-csv-button'); - const findImportDropdown = () => wrapper.findByTestId('import-csv-dropdown'); - const findImportCsvButton = () => wrapper.findByTestId('import-csv-dropdown'); - const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link'); + const findExportCsvButton = () => wrapper.findComponent(GlButton); + const findImportDropdown = () => wrapper.findComponent(GlDropdown); + const findImportCsvButton = () => wrapper.findByRole('menuitem', { name: 'Import CSV' }); + const findImportFromJiraLink = () => wrapper.findByRole('menuitem', { name: 'Import from Jira' }); const findExportCsvModal = () => wrapper.findComponent(CsvExportModal); const findImportCsvModal = () => wrapper.findComponent(CsvImportModal); @@ -97,7 +95,7 @@ describe('CsvImportExportButtons', () => { expect(findImportDropdown().exists()).toBe(true); }); - it('renders the import button', () => { + it('renders the import csv menu item', () => { expect(findImportCsvButton().exists()).toBe(true); }); @@ -106,8 +104,11 @@ describe('CsvImportExportButtons', () => { wrapper = createComponent({ showImportButton: true, showLabel: false }); }); - it('does not have a button text', () => { - expect(findImportCsvButton().props('text')).toBe(null); + it('hides button text', () => { + expect(findImportDropdown().props()).toMatchObject({ + text: 'Import issues', + textSrOnly: true, + }); }); it('import button has a tooltip', () => { @@ -124,7 +125,10 @@ describe('CsvImportExportButtons', () => { }); it('displays a button text', () => { - expect(findImportCsvButton().props('text')).toBe('Import issues'); + expect(findImportDropdown().props()).toMatchObject({ + text: 'Import issues', + textSrOnly: false, + }); }); it('import button has no tooltip', () => { diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js index ce9d738f77b..0c88b6b1283 100644 --- a/spec/frontend/issuable/components/csv_import_modal_spec.js +++ b/spec/frontend/issuable/components/csv_import_modal_spec.js @@ -1,8 +1,6 @@ -import { GlModal } from '@gitlab/ui'; -import { getByRole, getByLabelText } from '@testing-library/dom'; -import { mount } from '@vue/test-utils'; +import { GlButton, GlModal } from '@gitlab/ui'; import { stubComponent } from 'helpers/stub_component'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -13,23 +11,21 @@ describe('CsvImportModal', () => { function createComponent(options = {}) { const { injectedProperties = {}, props = {} } = options; - return extendedWrapper( - mount(CsvImportModal, { - propsData: { - modalId: 'csv-import-modal', - ...props, - }, - provide: { - issuableType: 'issues', - ...injectedProperties, - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: '<div><slot></slot><slot name="modal-footer"></slot></div>', - }), - }, - }), - ); + return mountExtended(CsvImportModal, { + propsData: { + modalId: 'csv-import-modal', + ...props, + }, + provide: { + issuableType: 'issues', + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: '<div><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }); } beforeEach(() => { @@ -41,9 +37,9 @@ describe('CsvImportModal', () => { }); const findModal = () => wrapper.findComponent(GlModal); - const findPrimaryButton = () => getByRole(wrapper.element, 'button', { name: 'Import issues' }); - const findForm = () => wrapper.findByTestId('import-csv-form'); - const findFileInput = () => getByLabelText(wrapper.element, 'Upload CSV file'); + const findPrimaryButton = () => wrapper.findComponent(GlButton); + const findForm = () => wrapper.find('form'); + const findFileInput = () => wrapper.findByLabelText('Upload CSV file'); const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); describe('template', () => { @@ -76,8 +72,8 @@ describe('CsvImportModal', () => { expect(findPrimaryButton()).toExist(); }); - it('submits the form when the primary action is clicked', async () => { - findPrimaryButton().click(); + it('submits the form when the primary action is clicked', () => { + findPrimaryButton().trigger('click'); expect(formSubmitSpy).toHaveBeenCalled(); }); diff --git a/spec/frontend/issuable/components/issuable_by_email_spec.js b/spec/frontend/issuable/components/issuable_by_email_spec.js index 08a99f29479..f11c41fe25d 100644 --- a/spec/frontend/issuable/components/issuable_by_email_spec.js +++ b/spec/frontend/issuable/components/issuable_by_email_spec.js @@ -58,10 +58,11 @@ describe('IssuableByEmail', () => { mockAxios.restore(); }); - const findFormInputGroup = () => wrapper.find(GlFormInputGroup); + const findButton = () => wrapper.findComponent(GlButton); + const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); const clickResetEmail = async () => { - wrapper.findByTestId('incoming-email-token-reset').vm.$emit('click'); + wrapper.findAllComponents(GlButton).at(2).trigger('click'); await waitForPromises(); }; @@ -75,14 +76,14 @@ describe('IssuableByEmail', () => { 'renders a link with "$buttonText" when type is "$issuableType"', ({ issuableType, buttonText }) => { wrapper = createComponent({ issuableType }); - expect(wrapper.findByTestId('issuable-email-modal-btn').text()).toBe(buttonText); + expect(findButton().text()).toBe(buttonText); }, ); it('opens the modal when the user clicks the button', () => { wrapper = createComponent(); - wrapper.findByTestId('issuable-email-modal-btn').vm.$emit('click'); + findButton().trigger('click'); expect(glModalDirective).toHaveBeenCalled(); }); @@ -105,7 +106,7 @@ describe('IssuableByEmail', () => { initialEmail, }); - expect(wrapper.findByTestId('mail-to-btn').attributes('href')).toBe( + expect(wrapper.findAllComponents(GlButton).at(1).attributes('href')).toBe( `mailto:${initialEmail}?subject=${subject}&body=${body}`, ); }); diff --git a/spec/frontend/issuable/components/status_box_spec.js b/spec/frontend/issuable/components/status_box_spec.js index 990fac67f7e..9cbf023dbd6 100644 --- a/spec/frontend/issuable/components/status_box_spec.js +++ b/spec/frontend/issuable/components/status_box_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import StatusBox from '~/issuable/components/status_box.vue'; @@ -64,7 +64,7 @@ describe('Merge request status box component', () => { initialState: testCase.state, }); - expect(wrapper.find('[data-testid="status-icon"]').props('name')).toBe(testCase.icon); + expect(wrapper.findComponent(GlIcon).props('name')).toBe(testCase.icon); }); }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js index e5e3478dc59..3099e0b639b 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -6,7 +6,7 @@ import { issuable1, issuable2, } from 'jest/vue_shared/components/issue/related_issuable_mock_data'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; import { linkedIssueTypesMap } from '~/related_issues/constants'; @@ -195,7 +195,9 @@ describe('RelatedIssuesRoot', () => { wrapper.vm.onPendingFormSubmit(input); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith(message); + expect(createFlash).toHaveBeenCalledWith({ + message, + }); }); }); }); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index e324f071966..ea36d59ff83 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -336,7 +336,7 @@ describe('IssuableItem', () => { const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]'); expect(createdAtEl.exists()).toBe(true); - expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm GMT+0000'); + expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm UTC'); expect(createdAtEl.text()).toBe(wrapper.vm.createdAt); }); @@ -450,7 +450,7 @@ describe('IssuableItem', () => { const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); expect(updatedAtEl.exists()).toBe(true); - expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am GMT+0000'); + expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt); }); diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index b8860e93a22..4c06f2dca1b 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -1,6 +1,7 @@ import { GlIntersectionObserver } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import '~/behaviors/markdown/render_gfm'; import IssuableApp from '~/issue_show/components/app.vue'; @@ -17,7 +18,7 @@ import { publishedIncidentUrl, secondRequest, zoomMeetingUrl, -} from '../mock_data'; +} from '../mock_data/mock_data'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); @@ -36,12 +37,11 @@ describe('Issuable output', () => { let wrapper; const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); - const findLockedBadge = () => wrapper.find('[data-testid="locked"]'); - const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]'); + const findAlert = () => wrapper.find('.alert'); - const mountComponent = (props = {}, options = {}) => { + const mountComponent = (props = {}, options = {}, data = {}) => { wrapper = mount(IssuableApp, { propsData: { ...appProps, ...props }, provide: { @@ -53,6 +53,11 @@ describe('Issuable output', () => { HighlightBar: true, IncidentTabs: true, }, + data() { + return { + ...data, + }; + }, ...options, }); }; @@ -91,10 +96,8 @@ describe('Issuable output', () => { afterEach(() => { mock.restore(); realtimeRequestCount = 0; - wrapper.vm.poll.stop(); wrapper.destroy(); - wrapper = null; }); it('should render a title/description/edited and update title/description/edited on update', () => { @@ -115,7 +118,7 @@ describe('Issuable output', () => { expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/); expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/); expect(editedText.find('time').text()).toBeTruthy(); - expect(wrapper.vm.state.lock_version).toEqual(1); + expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version); }) .then(() => { wrapper.vm.poll.makeRequest(); @@ -133,7 +136,9 @@ describe('Issuable output', () => { expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/); expect(editedText.find('time').text()).toBeTruthy(); - expect(wrapper.vm.state.lock_version).toEqual(2); + // As the lock_version value does not differ from the server, + // we should not see an alert + expect(findAlert().exists()).toBe(false); }); }); @@ -172,7 +177,7 @@ describe('Issuable output', () => { ${'zoomMeetingUrl'} | ${zoomMeetingUrl} ${'publishedIncidentUrl'} | ${publishedIncidentUrl} `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => { - expect(wrapper.vm[prop]).toEqual(value); + expect(wrapper.vm[prop]).toBe(value); expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value); }); }); @@ -374,9 +379,9 @@ describe('Issuable output', () => { }); }) .then(() => { - expect(wrapper.vm.formState.lockedWarningVisible).toEqual(true); - expect(wrapper.vm.formState.lock_version).toEqual(1); - expect(wrapper.find('.alert').exists()).toBe(true); + expect(wrapper.vm.formState.lockedWarningVisible).toBe(true); + expect(wrapper.vm.formState.lock_version).toBe(1); + expect(findAlert().exists()).toBe(true); }); }); }); @@ -530,7 +535,7 @@ describe('Issuable output', () => { `('$title', async ({ state }) => { wrapper.setProps({ issuableStatus: state }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findStickyHeader().text()).toContain(IssuableStatusText[state]); }); @@ -542,7 +547,7 @@ describe('Issuable output', () => { `('$title', async ({ isConfidential }) => { wrapper.setProps({ isConfidential }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findConfidentialBadge().exists()).toBe(isConfidential); }); @@ -554,7 +559,7 @@ describe('Issuable output', () => { `('$title', async ({ isLocked }) => { wrapper.setProps({ isLocked }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findLockedBadge().exists()).toBe(isLocked); }); @@ -562,9 +567,9 @@ describe('Issuable output', () => { }); describe('Composable description component', () => { - const findIncidentTabs = () => wrapper.find(IncidentTabs); - const findDescriptionComponent = () => wrapper.find(DescriptionComponent); - const findPinnedLinks = () => wrapper.find(PinnedLinks); + const findIncidentTabs = () => wrapper.findComponent(IncidentTabs); + const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); + const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; describe('when using description component', () => { diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js index 70c04280675..cdf06ecc31f 100644 --- a/spec/frontend/issue_show/components/description_spec.js +++ b/spec/frontend/issue_show/components/description_spec.js @@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import mountComponent from 'helpers/vue_mount_component_helper'; import Description from '~/issue_show/components/description.vue'; import TaskList from '~/task_list'; -import { descriptionProps as props } from '../mock_data'; +import { descriptionProps as props } from '../mock_data/mock_data'; jest.mock('~/task_list'); diff --git a/spec/frontend/issue_show/components/edit_actions_spec.js b/spec/frontend/issue_show/components/edit_actions_spec.js index 54707879f63..50c27cb5bda 100644 --- a/spec/frontend/issue_show/components/edit_actions_spec.js +++ b/spec/frontend/issue_show/components/edit_actions_spec.js @@ -1,113 +1,163 @@ -import Vue from 'vue'; -import editActions from '~/issue_show/components/edit_actions.vue'; +import { GlButton, GlModal } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import IssuableEditActions from '~/issue_show/components/edit_actions.vue'; import eventHub from '~/issue_show/event_hub'; -import Store from '~/issue_show/stores'; -describe('Edit Actions components', () => { - let vm; +import { + getIssueStateQueryResponse, + updateIssueStateQueryResponse, +} from '../mock_data/apollo_mock'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Edit Actions component', () => { + let wrapper; + let fakeApollo; + let mockIssueStateData; + + const mockResolvers = { + Query: { + issueState() { + return { + __typename: 'IssueState', + rawData: mockIssueStateData(), + }; + }, + }, + }; - beforeEach((done) => { - const Component = Vue.extend(editActions); - const store = new Store({ - titleHtml: '', - descriptionHtml: '', - issuableRef: '', - }); - store.formState.title = 'test'; + const modalId = 'delete-issuable-modal-1'; - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + const createComponent = ({ props, data } = {}) => { + fakeApollo = createMockApollo([], mockResolvers); - vm = new Component({ + wrapper = shallowMountExtended(IssuableEditActions, { + apolloProvider: fakeApollo, propsData: { + formState: { + title: 'GitLab Issue', + }, canDestroy: true, - formState: store.formState, issuableType: 'issue', + ...props, }, - }).$mount(); + data() { + return { + issueState: {}, + modalId, + ...data, + }; + }, + }); + }; - Vue.nextTick(done); - }); + async function deleteIssuable(localWrapper) { + localWrapper.findComponent(GlModal).vm.$emit('primary'); + } - it('renders all buttons as enabled', () => { - expect(vm.$el.querySelectorAll('.disabled').length).toBe(0); + const findModal = () => wrapper.findComponent(GlModal); + const findEditButtons = () => wrapper.findAllComponents(GlButton); + const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button'); + const findSaveButton = () => wrapper.findByTestId('issuable-save-button'); + const findCancelButton = () => wrapper.findByTestId('issuable-cancel-button'); - expect(vm.$el.querySelectorAll('[disabled]').length).toBe(0); + beforeEach(() => { + mockIssueStateData = jest.fn(); + createComponent(); }); - it('does not render delete button if canUpdate is false', (done) => { - vm.canDestroy = false; - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-danger')).toBeNull(); + afterEach(() => { + wrapper.destroy(); + }); - done(); + it('renders all buttons as enabled', () => { + const buttons = findEditButtons().wrappers; + buttons.forEach((button) => { + expect(button.attributes('disabled')).toBeFalsy(); }); }); - it('disables submit button when title is blank', (done) => { - vm.formState.title = ''; + it('does not render the delete button if canDestroy is false', () => { + createComponent({ props: { canDestroy: false } }); + expect(findDeleteButton().exists()).toBe(false); + }); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled'); + it('disables save button when title is blank', () => { + createComponent({ props: { formState: { title: '', issue_type: '' } } }); - done(); - }); + expect(findSaveButton().attributes('disabled')).toBe('true'); }); - it('should not show delete button if showDeleteButton is false', (done) => { - vm.showDeleteButton = false; + it('does not render the delete button if showDeleteButton is false', () => { + createComponent({ props: { showDeleteButton: false } }); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-danger')).toBeNull(); - done(); - }); + expect(findDeleteButton().exists()).toBe(false); }); describe('updateIssuable', () => { - it('sends update.issauble event when clicking save button', () => { - vm.$el.querySelector('.btn-confirm').click(); - - expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - it('disabled button after clicking save button', (done) => { - vm.$el.querySelector('.btn-confirm').click(); - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled'); + it('sends update.issauble event when clicking save button', () => { + findSaveButton().vm.$emit('click', { preventDefault: jest.fn() }); - done(); - }); + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); }); }); describe('closeForm', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + it('emits close.form when clicking cancel', () => { - vm.$el.querySelector('.btn-default').click(); + findCancelButton().vm.$emit('click'); expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); }); }); - describe('deleteIssuable', () => { - it('sends delete.issuable event when clicking save button', () => { - jest.spyOn(window, 'confirm').mockReturnValue(true); - vm.$el.querySelector('.btn-danger').click(); + describe('renders create modal with the correct information', () => { + it('renders correct modal id', () => { + expect(findModal().attributes('modalid')).toBe(modalId); + }); + }); - expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); + describe('deleteIssuable', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - it('does no actions when confirm is false', (done) => { - jest.spyOn(window, 'confirm').mockReturnValue(false); - vm.$el.querySelector('.btn-danger').click(); + it('does not send the `delete.issuable` event when clicking delete button', () => { + findDeleteButton().vm.$emit('click'); + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); - Vue.nextTick(() => { - expect(eventHub.$emit).not.toHaveBeenCalledWith('delete.issuable'); + it('sends the `delete.issuable` event when clicking the delete confirm button', async () => { + expect(eventHub.$emit).toHaveBeenCalledTimes(0); + await deleteIssuable(wrapper); + expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); + expect(eventHub.$emit).toHaveBeenCalledTimes(1); + }); + }); - expect(vm.$el.querySelector('.btn-danger .fa')).toBeNull(); + describe('with Apollo cache mock', () => { + it('renders the right delete button text per apollo cache type', async () => { + mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); + await waitForPromises(); + expect(findDeleteButton().text()).toBe('Delete issue'); + }); - done(); - }); + it('should not change the delete button text per apollo cache mutation', async () => { + mockIssueStateData.mockResolvedValue(updateIssueStateQueryResponse); + await waitForPromises(); + expect(findDeleteButton().text()).toBe('Delete issue'); }); }); }); diff --git a/spec/frontend/issue_show/components/fields/type_spec.js b/spec/frontend/issue_show/components/fields/type_spec.js new file mode 100644 index 00000000000..0c8af60d50d --- /dev/null +++ b/spec/frontend/issue_show/components/fields/type_spec.js @@ -0,0 +1,84 @@ +import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import IssueTypeField, { i18n } from '~/issue_show/components/fields/type.vue'; +import { IssuableTypes } from '~/issue_show/constants'; +import { + getIssueStateQueryResponse, + updateIssueStateQueryResponse, +} from '../../mock_data/apollo_mock'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Issue type field component', () => { + let wrapper; + let fakeApollo; + let mockIssueStateData; + + const mockResolvers = { + Query: { + issueState() { + return { + __typename: 'IssueState', + rawData: mockIssueStateData(), + }; + }, + }, + Mutation: { + updateIssueState: jest.fn().mockResolvedValue(updateIssueStateQueryResponse), + }, + }; + + const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup); + const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown); + const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem); + + const createComponent = ({ data } = {}) => { + fakeApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMount(IssueTypeField, { + localVue, + apolloProvider: fakeApollo, + data() { + return { + issueState: {}, + ...data, + }; + }, + }); + }; + + beforeEach(() => { + mockIssueStateData = jest.fn(); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a form group with the correct label', () => { + expect(findTypeFromGroup().attributes('label')).toBe(i18n.label); + }); + + it('renders a form select with the `issue_type` value', () => { + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + }); + + describe('with Apollo cache mock', () => { + it('renders the selected issueType', async () => { + mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); + await waitForPromises(); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + }); + + it('updates the `issue_type` in the apollo cache when the value is changed', async () => { + findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident); + await wrapper.vm.$nextTick(); + expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js index 6d4807c4261..28498cb90ec 100644 --- a/spec/frontend/issue_show/components/form_spec.js +++ b/spec/frontend/issue_show/components/form_spec.js @@ -2,6 +2,7 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Autosave from '~/autosave'; import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue'; +import IssueTypeField from '~/issue_show/components/fields/type.vue'; import formComponent from '~/issue_show/components/form.vue'; import LockedWarning from '~/issue_show/components/locked_warning.vue'; import eventHub from '~/issue_show/event_hub'; @@ -39,6 +40,7 @@ describe('Inline edit form component', () => { }; const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate); + const findIssuableTypeField = () => wrapper.findComponent(IssueTypeField); const findLockedWarning = () => wrapper.findComponent(LockedWarning); const findAlert = () => wrapper.findComponent(GlAlert); @@ -68,6 +70,21 @@ describe('Inline edit form component', () => { expect(findDescriptionTemplate().exists()).toBe(true); }); + it.each` + issuableType | value + ${'issue'} | ${true} + ${'epic'} | ${false} + `( + 'when `issue_type` is set to "$issuableType" rendering the type select will be "$value"', + ({ issuableType, value }) => { + createComponent({ + issuableType, + }); + + expect(findIssuableTypeField().exists()).toBe(value); + }, + ); + it('hides locked warning by default', () => { createComponent(); diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js index f46b6ba6f54..6b9f5b17e99 100644 --- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js @@ -9,7 +9,7 @@ import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; import INVALID_URL from '~/lib/utils/invalid_url'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; -import { descriptionProps } from '../../mock_data'; +import { descriptionProps } from '../../mock_data/mock_data'; const mockAlert = { __typename: 'AlertManagementAlert', diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js index 9cb7059dd7f..d043693b863 100644 --- a/spec/frontend/issue_show/issue_spec.js +++ b/spec/frontend/issue_show/issue_spec.js @@ -5,7 +5,7 @@ import { initIssuableApp } from '~/issue_show/issue'; import * as parseData from '~/issue_show/utils/parse_data'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; -import { appProps } from './mock_data'; +import { appProps } from './mock_data/mock_data'; const mock = new MockAdapter(axios); mock.onGet().reply(200); diff --git a/spec/frontend/issue_show/mock_data/apollo_mock.js b/spec/frontend/issue_show/mock_data/apollo_mock.js new file mode 100644 index 00000000000..bfd31e74393 --- /dev/null +++ b/spec/frontend/issue_show/mock_data/apollo_mock.js @@ -0,0 +1,9 @@ +export const getIssueStateQueryResponse = { + issueType: 'issue', + isDirty: false, +}; + +export const updateIssueStateQueryResponse = { + issueType: 'incident', + isDirty: true, +}; diff --git a/spec/frontend/issue_show/mock_data.js b/spec/frontend/issue_show/mock_data/mock_data.js index fd08c95b454..a73826954c3 100644 --- a/spec/frontend/issue_show/mock_data.js +++ b/spec/frontend/issue_show/mock_data/mock_data.js @@ -48,6 +48,7 @@ export const appProps = { initialDescriptionHtml: 'test', initialDescriptionText: 'test', lockVersion: 1, + issueType: 'issue', markdownPreviewPath: '/', markdownDocsPath: '/', projectNamespace: '/', diff --git a/spec/frontend/issues_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js index fe3d2114463..a7f3dd81517 100644 --- a/spec/frontend/issues_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js @@ -302,7 +302,6 @@ describe('Issuables list component', () => { my_reaction_emoji: 'airplane', scope: 'all', state: 'opened', - utf8: '✓', weight: '0', milestone: 'v3.0', labels: 'Aquapod,Astro', @@ -312,7 +311,7 @@ describe('Issuables list component', () => { describe('when page is not present in params', () => { const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0¬[label_name][]=Afterpod¬[milestone_title][]=13'; + '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0¬[label_name][]=Afterpod¬[milestone_title][]=13'; beforeEach(() => { setUrl(query); @@ -356,7 +355,7 @@ describe('Issuables list component', () => { describe('when page is present in the param', () => { const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0&page=3'; + '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&page=3'; beforeEach(() => { setUrl(query); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 5d83bf0142f..d78a436c618 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -18,6 +18,15 @@ import { PAGE_SIZE_MANUAL, PARAM_DUE_DATE, RELATIVE_POSITION_DESC, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_EPIC, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_WEIGHT, urlSortParams, } from '~/issues_list/constants'; import eventHub from '~/issues_list/eventhub'; @@ -39,8 +48,8 @@ describe('IssuesListApp component', () => { endpoint: 'api/endpoint', exportCsvPath: 'export/csv/path', hasBlockedIssuesFeature: true, - hasIssues: true, hasIssueWeightsFeature: true, + hasProjectIssues: true, isSignedIn: false, issuesPath: 'path/to/issues', jiraIntegrationPath: 'jira/integration/path', @@ -320,7 +329,7 @@ describe('IssuesListApp component', () => { beforeEach(async () => { global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` }); - wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); await waitForPromises(); }); @@ -336,7 +345,7 @@ describe('IssuesListApp component', () => { describe('when "Open" tab has no issues', () => { beforeEach(async () => { - wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); await waitForPromises(); }); @@ -356,7 +365,7 @@ describe('IssuesListApp component', () => { url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST), }); - wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount }); + wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount }); await waitForPromises(); }); @@ -374,7 +383,7 @@ describe('IssuesListApp component', () => { describe('when user is logged in', () => { beforeEach(() => { wrapper = mountComponent({ - provide: { hasIssues: false, isSignedIn: true }, + provide: { hasProjectIssues: false, isSignedIn: true }, mountFn: mount, }); }); @@ -413,7 +422,7 @@ describe('IssuesListApp component', () => { describe('when user is logged out', () => { beforeEach(() => { wrapper = mountComponent({ - provide: { hasIssues: false, isSignedIn: false }, + provide: { hasProjectIssues: false, isSignedIn: false }, }); }); @@ -430,6 +439,119 @@ describe('IssuesListApp component', () => { }); }); + describe('tokens', () => { + const mockCurrentUser = { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'avatar/url', + }; + + describe('when user is signed out', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + isSignedIn: false, + }, + }); + }); + + it('does not render My-Reaction or Confidential tokens', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_CONFIDENTIAL }, + ]); + }); + }); + + describe('when iterations are not available', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + projectIterationsPath: '', + }, + }); + }); + + it('does not render Iteration token', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_ITERATION }, + ]); + }); + }); + + describe('when epics are not available', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + groupEpicsPath: '', + }, + }); + }); + + it('does not render Epic token', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_EPIC }, + ]); + }); + }); + + describe('when weights are not available', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { + groupEpicsPath: '', + }, + }); + }); + + it('does not render Weight token', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_WEIGHT }, + ]); + }); + }); + + describe('when all tokens are available', () => { + const originalGon = window.gon; + + beforeEach(() => { + window.gon = { + ...originalGon, + current_user_id: mockCurrentUser.id, + current_user_fullname: mockCurrentUser.name, + current_username: mockCurrentUser.username, + current_user_avatar_url: mockCurrentUser.avatar_url, + }; + + wrapper = mountComponent({ + provide: { + isSignedIn: true, + projectIterationsPath: 'project/iterations/path', + groupEpicsPath: 'group/epics/path', + hasIssueWeightsFeature: true, + }, + }); + }); + + it('renders all tokens', () => { + expect(findIssuableList().props('searchTokens')).toMatchObject([ + { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_MILESTONE }, + { type: TOKEN_TYPE_LABEL }, + { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_CONFIDENTIAL }, + { type: TOKEN_TYPE_ITERATION }, + { type: TOKEN_TYPE_EPIC }, + { type: TOKEN_TYPE_WEIGHT }, + ]); + }); + }); + }); + describe('events', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index ce2880d177a..99267fb6e31 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -21,8 +21,8 @@ export const locationSearch = [ 'confidential=no', 'iteration_title=season:+%234', 'not[iteration_title]=season:+%2320', - 'epic_id=12', - 'not[epic_id]=34', + 'epic_id=gitlab-org%3A%3A%2612', + 'not[epic_id]=gitlab-org%3A%3A%2634', 'weight=1', 'not[weight]=3', ].join('&'); @@ -53,8 +53,8 @@ export const filteredTokens = [ { type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } }, - { type: 'epic_id', value: { data: '12', operator: OPERATOR_IS } }, - { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, + { type: 'epic_id', value: { data: 'gitlab-org::&12', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: 'gitlab-org::&34', operator: OPERATOR_IS_NOT } }, { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, { type: 'filtered-search-term', value: { data: 'find' } }, @@ -84,7 +84,7 @@ export const apiParams = { iteration_title: 'season: #4', 'not[iteration_title]': 'season: #20', epic_id: '12', - 'not[epic_id]': '34', + 'not[epic_id]': 'gitlab-org::&34', weight: '1', 'not[weight]': '3', }; @@ -111,8 +111,8 @@ export const urlParams = { confidential: 'no', iteration_title: 'season: #4', 'not[iteration_title]': 'season: #20', - epic_id: '12', - 'not[epic_id]': '34', + epic_id: 'gitlab-org%3A%3A%2612', + 'not[epic_id]': 'gitlab-org::&34', weight: '1', 'not[weight]': '3', }; diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues_list/utils_spec.js index 17127753972..e377c35a0aa 100644 --- a/spec/frontend/issues_list/utils_spec.js +++ b/spec/frontend/issues_list/utils_spec.js @@ -82,7 +82,10 @@ describe('getFilterTokens', () => { describe('convertToParams', () => { it('returns api params given filtered tokens', () => { - expect(convertToParams(filteredTokens, API_PARAM)).toEqual(apiParams); + expect(convertToParams(filteredTokens, API_PARAM)).toEqual({ + ...apiParams, + epic_id: 'gitlab-org::&12', + }); }); it('returns api params given filtered tokens with special values', () => { @@ -92,7 +95,10 @@ describe('convertToParams', () => { }); it('returns url params given filtered tokens', () => { - expect(convertToParams(filteredTokens, URL_PARAM)).toEqual(urlParams); + expect(convertToParams(filteredTokens, URL_PARAM)).toEqual({ + ...urlParams, + epic_id: 'gitlab-org::&12', + }); }); it('returns url params given filtered tokens with special values', () => { diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index 9f49cb4007a..172b6e4831c 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -73,6 +73,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-label="Will be mapped to" class="gl-icon s16" data-testid="arrow-right-icon" + role="img" > <use href="#arrow-right" @@ -109,6 +110,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-hidden="true" class="gl-button-icon dropdown-chevron gl-icon s16" data-testid="chevron-down-icon" + role="img" > <use href="#chevron-down" @@ -135,6 +137,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-hidden="true" class="gl-search-box-by-type-search-icon gl-icon s16" data-testid="search-icon" + role="img" > <use href="#search" @@ -198,6 +201,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-label="Will be mapped to" class="gl-icon s16" data-testid="arrow-right-icon" + role="img" > <use href="#arrow-right" @@ -234,6 +238,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-hidden="true" class="gl-button-icon dropdown-chevron gl-icon s16" data-testid="chevron-down-icon" + role="img" > <use href="#chevron-down" @@ -260,6 +265,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = aria-hidden="true" class="gl-search-box-by-type-search-icon gl-icon s16" data-testid="search-icon" + role="img" > <use href="#search" diff --git a/spec/frontend/jira_import/components/jira_import_progress_spec.js b/spec/frontend/jira_import/components/jira_import_progress_spec.js index b56230e94fc..04b2a2da622 100644 --- a/spec/frontend/jira_import/components/jira_import_progress_spec.js +++ b/spec/frontend/jira_import/components/jira_import_progress_spec.js @@ -64,7 +64,7 @@ describe('JiraImportProgress', () => { }); it('shows the time of import', () => { - expect(getParagraphText()).toContain('Time of import: Apr 8, 2020 12:17pm GMT+0000'); + expect(getParagraphText()).toContain('Time of import: Apr 8, 2020 12:17pm UTC'); }); it('shows the project key of the import', () => { diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 9d1135e26c8..482d0df4e9a 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoader, GlAlert, GlEmptyState } from '@gitlab/ui'; +import { GlSkeletonLoader, GlAlert, GlEmptyState, GlPagination } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -25,6 +25,10 @@ describe('Job table app', () => { const findTabs = () => wrapper.findComponent(JobsTableTabs); const findAlert = () => wrapper.findComponent(GlAlert); const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findPagination = () => wrapper.findComponent(GlPagination); + + const findPrevious = () => findPagination().findAll('.page-item').at(0); + const findNext = () => findPagination().findAll('.page-item').at(1); const createMockApolloProvider = (handler) => { const requestHandlers = [[getJobsQuery, handler]]; @@ -32,8 +36,17 @@ describe('Job table app', () => { return createMockApollo(requestHandlers); }; - const createComponent = (handler = successHandler, mountFn = shallowMount) => { + const createComponent = ({ + handler = successHandler, + mountFn = shallowMount, + data = {}, + } = {}) => { wrapper = mountFn(JobsTableApp, { + data() { + return { + ...data, + }; + }, provide: { projectPath, }, @@ -52,6 +65,7 @@ describe('Job table app', () => { expect(findSkeletonLoader().exists()).toBe(true); expect(findTable().exists()).toBe(false); + expect(findPagination().exists()).toBe(false); }); }); @@ -65,9 +79,10 @@ describe('Job table app', () => { it('should display the jobs table with data', () => { expect(findTable().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(false); + expect(findPagination().exists()).toBe(true); }); - it('should retfech jobs query on fetchJobsByStatus event', async () => { + it('should refetch jobs query on fetchJobsByStatus event', async () => { jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn()); expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); @@ -78,9 +93,72 @@ describe('Job table app', () => { }); }); + describe('pagination', () => { + it('should disable the next page button on the last page', async () => { + createComponent({ + handler: successHandler, + mountFn: mount, + data: { + pagination: { + currentPage: 3, + }, + jobs: { + pageInfo: { + hasPreviousPage: true, + startCursor: 'abc', + endCursor: 'bcd', + }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + wrapper.setData({ + jobs: { + pageInfo: { + hasNextPage: false, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(findPrevious().exists()).toBe(true); + expect(findNext().exists()).toBe(true); + expect(findNext().classes('disabled')).toBe(true); + }); + + it('should disable the previous page button on the first page', async () => { + createComponent({ + handler: successHandler, + mountFn: mount, + data: { + pagination: { + currentPage: 1, + }, + jobs: { + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'abc', + endCursor: 'bcd', + }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(findPrevious().exists()).toBe(true); + expect(findPrevious().classes('disabled')).toBe(true); + expect(findNext().exists()).toBe(true); + }); + }); + describe('error state', () => { it('should show an alert if there is an error fetching the data', async () => { - createComponent(failedHandler); + createComponent({ handler: failedHandler }); await waitForPromises(); @@ -90,7 +168,7 @@ describe('Job table app', () => { describe('empty state', () => { it('should display empty state if there are no jobs and tab scope is null', async () => { - createComponent(emptyHandler, mount); + createComponent({ handler: emptyHandler, mountFn: mount }); await waitForPromises(); @@ -99,7 +177,7 @@ describe('Job table app', () => { }); it('should not display empty state if there are jobs and tab scope is not null', async () => { - createComponent(successHandler, mount); + createComponent({ handler: successHandler, mountFn: mount }); await waitForPromises(); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 6180cd8e94d..df0ccb19cb7 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -101,13 +101,13 @@ describe('Date time utils', () => { it('should format date properly', () => { const formattedDate = datetimeUtility.formatDate(new Date('07/23/2016')); - expect(formattedDate).toBe('Jul 23, 2016 12:00am GMT+0000'); + expect(formattedDate).toBe('Jul 23, 2016 12:00am UTC'); }); it('should format ISO date properly', () => { const formattedDate = datetimeUtility.formatDate('2016-07-23T00:00:00.559Z'); - expect(formattedDate).toBe('Jul 23, 2016 12:00am GMT+0000'); + expect(formattedDate).toBe('Jul 23, 2016 12:00am UTC'); }); it('should throw an error if date is invalid', () => { @@ -878,7 +878,7 @@ describe('localTimeAgo', () => { it.each` timeagoArg | title ${false} | ${'some time'} - ${true} | ${'Feb 18, 2020 10:22pm GMT+0000'} + ${true} | ${'Feb 18, 2020 10:22pm UTC'} `('converts $seconds seconds to $approximation', ({ timeagoArg, title }) => { const element = document.querySelector('time'); datetimeUtility.localTimeAgo($(element), timeagoArg); @@ -889,17 +889,6 @@ describe('localTimeAgo', () => { }); }); -describe('dateFromParams', () => { - it('returns the expected date object', () => { - const expectedDate = new Date('2019-07-17T00:00:00.000Z'); - const date = datetimeUtility.dateFromParams(2019, 6, 17); - - expect(date.getYear()).toBe(expectedDate.getYear()); - expect(date.getMonth()).toBe(expectedDate.getMonth()); - expect(date.getDate()).toBe(expectedDate.getDate()); - }); -}); - describe('differenceInSeconds', () => { const startDateTime = new Date('2019-07-17T00:00:00.000Z'); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index f4483f5098b..e743678ea90 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -80,18 +80,22 @@ describe('Number Utils', () => { describe('numberToHumanSize', () => { it('should return bytes', () => { expect(numberToHumanSize(654)).toEqual('654 bytes'); + expect(numberToHumanSize(-654)).toEqual('-654 bytes'); }); it('should return KiB', () => { expect(numberToHumanSize(1079)).toEqual('1.05 KiB'); + expect(numberToHumanSize(-1079)).toEqual('-1.05 KiB'); }); it('should return MiB', () => { expect(numberToHumanSize(10485764)).toEqual('10.00 MiB'); + expect(numberToHumanSize(-10485764)).toEqual('-10.00 MiB'); }); it('should return GiB', () => { expect(numberToHumanSize(10737418240)).toEqual('10.00 GiB'); + expect(numberToHumanSize(-10737418240)).toEqual('-10.00 GiB'); }); }); diff --git a/spec/frontend/lib/utils/table_utility_spec.js b/spec/frontend/lib/utils/table_utility_spec.js new file mode 100644 index 00000000000..75b9252aa40 --- /dev/null +++ b/spec/frontend/lib/utils/table_utility_spec.js @@ -0,0 +1,11 @@ +import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; +import * as tableUtils from '~/lib/utils/table_utility'; + +describe('table_utility', () => { + describe('thWidthClass', () => { + it('returns the width class including default table header classes', () => { + const width = 50; + expect(tableUtils.thWidthClass(width)).toBe(`gl-w-${width}p ${DEFAULT_TH_CLASSES}`); + }); + }); +}); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index e12cd8b0e37..305d3de3c53 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -471,6 +471,7 @@ describe('URL utility', () => { ${'notaurl'} | ${false} ${'../relative_url'} | ${false} ${'<a></a>'} | ${false} + ${'//other-host.test'} | ${false} `('returns $valid for $url', ({ url, valid }) => { expect(urlUtils.isRootRelative(url)).toBe(valid); }); @@ -650,45 +651,24 @@ describe('URL utility', () => { }); describe('queryToObject', () => { - it('converts search query into an object', () => { - const searchQuery = '?one=1&two=2'; - - expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' }); - }); - - it('removes undefined values from the search query', () => { - const searchQuery = '?one=1&two=2&three'; - - expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' }); - }); - - describe('with gatherArrays=false', () => { - it('overwrites values with the same array-key and does not change the key', () => { - const searchQuery = '?one[]=1&one[]=2&two=2&two=3'; - - expect(urlUtils.queryToObject(searchQuery)).toEqual({ 'one[]': '2', two: '3' }); - }); - }); - - describe('with gatherArrays=true', () => { - const options = { gatherArrays: true }; - it('gathers only values with the same array-key and strips `[]` from the key', () => { - const searchQuery = '?one[]=1&one[]=2&two=2&two=3'; - - expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: ['1', '2'], two: '3' }); - }); - - it('overwrites values with the same array-key name', () => { - const searchQuery = '?one=1&one[]=2&two=2&two=3'; - - expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: ['2'], two: '3' }); - }); - - it('overwrites values with the same key name', () => { - const searchQuery = '?one[]=1&one=2&two=2&two=3'; - - expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: '2', two: '3' }); - }); + it.each` + case | query | options | result + ${'converts query'} | ${'?one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'converts query without ?'} | ${'one=1&two=2'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'removes undefined values'} | ${'?one=1&two=2&three'} | ${undefined} | ${{ one: '1', two: '2' }} + ${'overwrites values with same key and does not change key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${undefined} | ${{ 'one[]': '2', two: '3' }} + ${'gathers values with the same array-key, strips `[]` from key'} | ${'?one[]=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['1', '2'], two: '3' }} + ${'overwrites values with the same array-key name'} | ${'?one=1&one[]=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: ['2'], two: '3' }} + ${'overwrites values with the same key name'} | ${'?one[]=1&one=2&two=2&two=3'} | ${{ gatherArrays: true }} | ${{ one: '2', two: '3' }} + ${'ignores plus symbols'} | ${'?search=a+b'} | ${{ legacySpacesDecode: true }} | ${{ search: 'a+b' }} + ${'ignores plus symbols in keys'} | ${'?search+term=a'} | ${{ legacySpacesDecode: true }} | ${{ 'search+term': 'a' }} + ${'ignores plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true, legacySpacesDecode: true }} | ${{ search: ['a+b'] }} + ${'replaces plus symbols with spaces'} | ${'?search=a+b'} | ${undefined} | ${{ search: 'a b' }} + ${'replaces plus symbols in keys with spaces'} | ${'?search+term=a'} | ${undefined} | ${{ 'search term': 'a' }} + ${'replaces plus symbols when gathering arrays'} | ${'?search[]=a+b'} | ${{ gatherArrays: true }} | ${{ search: ['a b'] }} + ${'replaces plus symbols when gathering arrays for values with same key'} | ${'?search[]=a+b&search[]=c+d'} | ${{ gatherArrays: true }} | ${{ search: ['a b', 'c d'] }} + `('$case', ({ query, options, result }) => { + expect(urlUtils.queryToObject(query, options)).toEqual(result); }); }); @@ -798,15 +778,29 @@ describe('URL utility', () => { ); }); - it('handles arrays properly', () => { + it('adds parameters from arrays', () => { const url = 'https://gitlab.com/test'; - expect(urlUtils.setUrlParams({ label_name: ['foo', 'bar'] }, url)).toEqual( - 'https://gitlab.com/test?label_name=foo&label_name=bar', + expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url)).toEqual( + 'https://gitlab.com/test?labels=foo&labels=bar', ); }); - it('handles arrays properly when railsArraySyntax=true', () => { + it('removes parameters from empty arrays', () => { + const url = 'https://gitlab.com/test?labels=foo&labels=bar'; + + expect(urlUtils.setUrlParams({ labels: [] }, url)).toEqual('https://gitlab.com/test'); + }); + + it('removes parameters from empty arrays while keeping other parameters', () => { + const url = 'https://gitlab.com/test?labels=foo&labels=bar&unrelated=unrelated'; + + expect(urlUtils.setUrlParams({ labels: [] }, url)).toEqual( + 'https://gitlab.com/test?unrelated=unrelated', + ); + }); + + it('adds parameters from arrays when railsArraySyntax=true', () => { const url = 'https://gitlab.com/test'; expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true)).toEqual( @@ -814,6 +808,14 @@ describe('URL utility', () => { ); }); + it('removes parameters from empty arrays when railsArraySyntax=true', () => { + const url = 'https://gitlab.com/test?labels%5B%5D=foo&labels%5B%5D=bar'; + + expect(urlUtils.setUrlParams({ labels: [] }, url, false, true)).toEqual( + 'https://gitlab.com/test', + ); + }); + it('decodes URI when decodeURI=true', () => { const url = 'https://gitlab.com/test'; diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js index b40d9d7d5e2..b107708ac2c 100644 --- a/spec/frontend/logs/components/environment_logs_spec.js +++ b/spec/frontend/logs/components/environment_logs_spec.js @@ -12,7 +12,6 @@ import { mockTrace, mockEnvironmentsEndpoint, mockDocumentationPath, - mockManagedAppsEndpoint, } from '../mock_data'; jest.mock('~/lib/utils/scroll_utils'); @@ -35,7 +34,7 @@ describe('EnvironmentLogs', () => { environmentName: mockEnvName, environmentsPath: mockEnvironmentsEndpoint, clusterApplicationsDocumentationPath: mockDocumentationPath, - clustersPath: mockManagedAppsEndpoint, + clustersPath: '/gitlab-org', }; const updateControlBtnsMock = jest.fn(); diff --git a/spec/frontend/logs/mock_data.js b/spec/frontend/logs/mock_data.js index 3fabab4bc59..14c8f7a2ba2 100644 --- a/spec/frontend/logs/mock_data.js +++ b/spec/frontend/logs/mock_data.js @@ -7,8 +7,6 @@ export const mockDocumentationPath = '/documentation.md'; export const mockLogsEndpoint = '/dummy_logs_path.json'; export const mockCursor = 'MOCK_CURSOR'; export const mockNextCursor = 'MOCK_NEXT_CURSOR'; -export const mockManagedAppName = 'kubernetes-cluster-1'; -export const mockManagedAppsEndpoint = `${mockProjectPath}/clusters.json`; const makeMockEnvironment = (id, name, advancedQuerying) => ({ id, @@ -25,31 +23,6 @@ export const mockEnvironments = [ makeMockEnvironment(102, 'review/a-feature', false), ]; -export const mockManagedApps = [ - { - cluster_type: 'project_type', - enabled: true, - environment_scope: '*', - name: 'kubernetes-cluster-1', - provider_type: 'user', - status: 'connected', - path: '/root/autodevops-deploy/-/clusters/15', - gitlab_managed_apps_logs_path: '/root/autodevops-deploy/-/logs?cluster_id=15', - enable_advanced_logs_querying: true, - }, - { - cluster_type: 'project_type', - enabled: true, - environment_scope: '*', - name: 'kubernetes-cluster-2', - provider_type: 'user', - status: 'connected', - path: '/root/autodevops-deploy/-/clusters/16', - gitlab_managed_apps_logs_path: null, - enable_advanced_logs_querying: false, - }, -]; - export const mockPodName = 'production-764c58d697-aaaaa'; export const mockPods = [ mockPodName, diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js index d5118bbde8c..9307a3b62fb 100644 --- a/spec/frontend/logs/stores/actions_spec.js +++ b/spec/frontend/logs/stores/actions_spec.js @@ -11,7 +11,6 @@ import { fetchEnvironments, fetchLogs, fetchMoreLogsPrepend, - fetchManagedApps, } from '~/logs/stores/actions'; import * as types from '~/logs/stores/mutation_types'; import logsPageState from '~/logs/stores/state'; @@ -31,8 +30,6 @@ import { mockResponse, mockCursor, mockNextCursor, - mockManagedApps, - mockManagedAppsEndpoint, } from '../mock_data'; jest.mock('~/flash'); @@ -219,30 +216,6 @@ describe('Logs Store actions', () => { }); }); - describe('fetchManagedApps', () => { - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - it('should commit RECEIVE_MANAGED_APPS_DATA_SUCCESS mutation on succesful fetch', () => { - mock.onGet(mockManagedAppsEndpoint).replyOnce(200, { clusters: mockManagedApps }); - return testAction(fetchManagedApps, mockManagedAppsEndpoint, state, [ - { type: types.RECEIVE_MANAGED_APPS_DATA_SUCCESS, payload: mockManagedApps }, - ]); - }); - - it('should commit RECEIVE_MANAGED_APPS_DATA_ERROR on wrong data', () => { - mock.onGet(mockManagedAppsEndpoint).replyOnce(500); - return testAction( - fetchManagedApps, - mockManagedAppsEndpoint, - state, - [{ type: types.RECEIVE_MANAGED_APPS_DATA_ERROR }], - [], - ); - }); - }); - describe('when the backend responds succesfully', () => { let expectedMutations; let expectedActions; diff --git a/spec/frontend/logs/stores/getters_spec.js b/spec/frontend/logs/stores/getters_spec.js index bca1ce4ca92..9d213d8c01f 100644 --- a/spec/frontend/logs/stores/getters_spec.js +++ b/spec/frontend/logs/stores/getters_spec.js @@ -1,14 +1,7 @@ import { trace, showAdvancedFilters } from '~/logs/stores/getters'; import logsPageState from '~/logs/stores/state'; -import { - mockLogsResult, - mockTrace, - mockEnvName, - mockEnvironments, - mockManagedApps, - mockManagedAppName, -} from '../mock_data'; +import { mockLogsResult, mockTrace, mockEnvName, mockEnvironments } from '../mock_data'; describe('Logs Store getters', () => { let state; @@ -79,43 +72,4 @@ describe('Logs Store getters', () => { }); }); }); - - describe('when no managedApps are set', () => { - beforeEach(() => { - state.environments.current = null; - state.environments.options = []; - state.managedApps.current = mockManagedAppName; - state.managedApps.options = []; - }); - - it('returns false', () => { - expect(showAdvancedFilters(state)).toBe(false); - }); - }); - - describe('when the managedApp supports filters', () => { - beforeEach(() => { - state.environments.current = null; - state.environments.options = mockEnvironments; - state.managedApps.current = mockManagedAppName; - state.managedApps.options = mockManagedApps; - }); - - it('returns true', () => { - expect(showAdvancedFilters(state)).toBe(true); - }); - }); - - describe('when the managedApp does not support filters', () => { - beforeEach(() => { - state.environments.current = null; - state.environments.options = mockEnvironments; - state.managedApps.options = mockManagedApps; - state.managedApps.current = mockManagedApps[1].name; - }); - - it('returns false', () => { - expect(showAdvancedFilters(state)).toBe(false); - }); - }); }); diff --git a/spec/frontend/logs/stores/mutations_spec.js b/spec/frontend/logs/stores/mutations_spec.js index 111c795ba52..988197a8350 100644 --- a/spec/frontend/logs/stores/mutations_spec.js +++ b/spec/frontend/logs/stores/mutations_spec.js @@ -11,8 +11,6 @@ import { mockSearch, mockCursor, mockNextCursor, - mockManagedApps, - mockManagedAppName, } from '../mock_data'; describe('Logs Store Mutations', () => { @@ -32,15 +30,6 @@ describe('Logs Store Mutations', () => { it('sets the environment', () => { mutations[types.SET_PROJECT_ENVIRONMENT](state, mockEnvName); expect(state.environments.current).toEqual(mockEnvName); - expect(state.managedApps.current).toBe(null); - }); - }); - - describe('SET_MANAGED_APP', () => { - it('sets the managed app', () => { - mutations[types.SET_MANAGED_APP](state, mockManagedAppName); - expect(state.managedApps.current).toBe(mockManagedAppName); - expect(state.environments.current).toBe(null); }); }); @@ -265,29 +254,4 @@ describe('Logs Store Mutations', () => { ); }); }); - - describe('RECEIVE_MANAGED_APPS_DATA_SUCCESS', () => { - it('receives managed apps data success', () => { - expect(state.managedApps.options).toEqual([]); - - mutations[types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, mockManagedApps); - - expect(state.managedApps.options.length).toEqual(1); - expect(state.managedApps.options).toEqual([mockManagedApps[0]]); - expect(state.managedApps.isLoading).toBe(false); - }); - }); - - describe('RECEIVE_MANAGED_APPS_DATA_ERROR', () => { - it('received managed apps data error', () => { - mutations[types.RECEIVE_MANAGED_APPS_DATA_ERROR](state); - - expect(state.managedApps).toEqual({ - options: [], - isLoading: false, - current: null, - fetchError: true, - }); - }); - }); }); diff --git a/spec/frontend/members/components/app_spec.js b/spec/frontend/members/components/app_spec.js index 05933e36b52..b9fdf8792fd 100644 --- a/spec/frontend/members/components/app_spec.js +++ b/spec/frontend/members/components/app_spec.js @@ -33,7 +33,7 @@ describe('MembersApp', () => { wrapper = shallowMount(MembersApp, { localVue, - provide: { + propsData: { namespace: MEMBER_TYPES.user, }, store, diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js index 28614b52706..6f1a6d0c223 100644 --- a/spec/frontend/members/components/members_tabs_spec.js +++ b/spec/frontend/members/components/members_tabs_spec.js @@ -6,7 +6,7 @@ import MembersTabs from '~/members/components/members_tabs.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { pagination } from '../mock_data'; -describe('MembersApp', () => { +describe('MembersTabs', () => { Vue.use(Vuex); let wrapper; @@ -111,10 +111,10 @@ describe('MembersApp', () => { const membersApps = wrapper.findAllComponents(MembersApp).wrappers; - expect(membersApps[0].attributes('namespace')).toBe(MEMBER_TYPES.user); - expect(membersApps[1].attributes('namespace')).toBe(MEMBER_TYPES.group); - expect(membersApps[2].attributes('namespace')).toBe(MEMBER_TYPES.invite); - expect(membersApps[3].attributes('namespace')).toBe(MEMBER_TYPES.accessRequest); + expect(membersApps[0].props('namespace')).toBe(MEMBER_TYPES.user); + expect(membersApps[1].props('namespace')).toBe(MEMBER_TYPES.group); + expect(membersApps[2].props('namespace')).toBe(MEMBER_TYPES.invite); + expect(membersApps[3].props('namespace')).toBe(MEMBER_TYPES.accessRequest); }); }); diff --git a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js index 01279581c55..313c237f51c 100644 --- a/spec/frontend/members/components/modals/remove_group_link_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_group_link_modal_spec.js @@ -109,6 +109,6 @@ describe('RemoveGroupLinkModal', () => { it('modal does not show when `removeGroupLinkModalVisible` is `false`', () => { createComponent({ removeGroupLinkModalVisible: false }); - expect(findModal().vm.$attrs.visible).toBe(false); + expect(findModal().props().visible).toBe(false); }); }); diff --git a/spec/frontend/members/components/table/expires_at_spec.js b/spec/frontend/members/components/table/expires_at_spec.js index 02fe3c6d684..2b8e6ab8f2a 100644 --- a/spec/frontend/members/components/table/expires_at_spec.js +++ b/spec/frontend/members/components/table/expires_at_spec.js @@ -54,7 +54,7 @@ describe('ExpiresAt', () => { const tooltipDirective = getTooltipDirective(expiredText); expect(tooltipDirective).not.toBeUndefined(); - expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000'); + expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am UTC'); }); }); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index c8b6bead450..a4a4c620921 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -88,7 +88,7 @@ describe('RoleDropdown', () => { }); it('renders dropdown header', () => { - expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true); + expect(getByTextInDropdownMenu('Change role').exists()).toBe(true); }); it('sets dropdown toggle and checks selected role', () => { diff --git a/spec/frontend/members/index_spec.js b/spec/frontend/members/index_spec.js index b07534ae4ed..efabe54f238 100644 --- a/spec/frontend/members/index_spec.js +++ b/spec/frontend/members/index_spec.js @@ -1,5 +1,5 @@ import { createWrapper } from '@vue/test-utils'; -import MembersApp from '~/members/components/app.vue'; +import MembersTabs from '~/members/components/members_tabs.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { initMembersApp } from '~/members/index'; import { members, pagination, dataAttribute } from './mock_data'; @@ -11,12 +11,13 @@ describe('initMembersApp', () => { const setup = () => { vm = initMembersApp(el, { - namespace: MEMBER_TYPES.user, - tableFields: ['account'], - tableAttrs: { table: { 'data-qa-selector': 'members_list' } }, - tableSortableFields: ['account'], - requestFormatter: () => ({}), - filteredSearchBar: { show: false }, + [MEMBER_TYPES.user]: { + tableFields: ['account'], + tableAttrs: { table: { 'data-qa-selector': 'members_list' } }, + tableSortableFields: ['account'], + requestFormatter: () => ({}), + filteredSearchBar: { show: false }, + }, }); wrapper = createWrapper(vm); }; @@ -35,10 +36,10 @@ describe('initMembersApp', () => { wrapper = null; }); - it('renders `MembersApp`', () => { + it('renders `MembersTabs`', () => { setup(); - expect(wrapper.find(MembersApp).exists()).toBe(true); + expect(wrapper.find(MembersTabs).exists()).toBe(true); }); it('parses and sets `members` in Vuex store', () => { diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index d0a7c36349b..4275db5fa9f 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -1,3 +1,5 @@ +import { MEMBER_TYPES } from '~/members/constants'; + export const member = { requestedAt: null, canUpdate: false, @@ -28,6 +30,7 @@ export const member = { usingLicense: false, groupSso: false, groupManagedAccount: false, + provisionedByThisGroup: false, validRoles: { Guest: 10, Reporter: 20, @@ -97,10 +100,12 @@ export const pagination = { }; export const dataAttribute = JSON.stringify({ - members, - pagination: paginationData, + [MEMBER_TYPES.user]: { + members, + pagination: paginationData, + member_path: '/groups/foo-bar/-/group_members/:id', + ldap_override_path: '/groups/ldap-group/-/group_members/:id/override', + }, source_id: 234, can_manage_members: true, - member_path: '/groups/foo-bar/-/group_members/:id', - ldap_override_path: '/groups/ldap-group/-/group_members/:id/override', }); diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 72696979722..9740e1c2edb 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -1,4 +1,4 @@ -import { DEFAULT_SORT } from '~/members/constants'; +import { DEFAULT_SORT, MEMBER_TYPES } from '~/members/constants'; import { generateBadges, isGroup, @@ -268,11 +268,13 @@ describe('Members Utils', () => { it('correctly parses the data attribute', () => { expect(parseDataAttributes(el)).toMatchObject({ - members, - pagination, + [MEMBER_TYPES.user]: { + members, + pagination, + memberPath: '/groups/foo-bar/-/group_members/:id', + }, sourceId: 234, canManageMembers: true, - memberPath: '/groups/foo-bar/-/group_members/:id', }); }); }); diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js index 1f0597bac67..9bf9e8ad7cc 100644 --- a/spec/frontend/monitoring/alert_widget_spec.js +++ b/spec/frontend/monitoring/alert_widget_spec.js @@ -1,7 +1,7 @@ import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import AlertWidget from '~/monitoring/components/alert_widget.vue'; const mockReadAlert = jest.fn(); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 98503636d33..08f9e07244f 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -7,7 +7,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` environmentstate="available" metricsdashboardbasepath="/monitoring/monitor-project/-/environments/1/metrics" metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" - prometheusstatus="" > <alerts-deprecation-warning-stub /> diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index afa63bcff29..754ddd96c9b 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -208,7 +208,7 @@ describe('Time series component', () => { }); it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (GMT+0000)'); + expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); }); it('formats tooltip content', () => { @@ -282,7 +282,7 @@ describe('Time series component', () => { }); it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (GMT+0000)'); + expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); }); it('formats tooltip sha', () => { @@ -301,7 +301,7 @@ describe('Time series component', () => { }); it('formats tooltip title', () => { - expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (GMT+0000)'); + expect(wrapper.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM (UTC)'); }); it('formats tooltip sha', () => { @@ -334,7 +334,7 @@ describe('Time series component', () => { it('formats tooltip title and sets tooltip content', () => { const formattedTooltipData = wrapper.vm.formatAnnotationsTooltipText(mockMarkPoint); - expect(formattedTooltipData.title).toBe('19 Feb 2020, 10:01AM (GMT+0000)'); + expect(formattedTooltipData.title).toBe('19 Feb 2020, 10:01AM (UTC)'); expect(formattedTooltipData.content).toBe(annotationsMetadata.tooltipData.content); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index a72dbbd0f41..c8951dff9ed 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -778,5 +778,31 @@ describe('Dashboard Panel', () => { expect(findRunbookLinks().at(0).attributes('href')).toBe(invalidUrl); }); }); + + describe('managed alert deprecation feature flag', () => { + beforeEach(() => { + setMetricsSavedToDb([metricId]); + }); + + it('shows alerts when alerts are not deprecated', () => { + createWrapper( + { alertsEndpoint: '/endpoint', prometheusAlertsAvailable: true }, + { provide: { glFeatures: { managedAlertsDeprecation: false } } }, + ); + + expect(findAlertsWidget().exists()).toBe(true); + expect(findMenuItemByText('Alerts').exists()).toBe(true); + }); + + it('hides alerts when alerts are deprecated', () => { + createWrapper( + { alertsEndpoint: '/endpoint', prometheusAlertsAvailable: true }, + { provide: { glFeatures: { managedAlertsDeprecation: true } } }, + ); + + expect(findAlertsWidget().exists()).toBe(false); + expect(findMenuItemByText('Alerts').exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 0c2f85c7298..7ca1b97d849 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,9 +1,8 @@ -import { GlAlert } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import VueDraggable from 'vuedraggable'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { ESC_KEY } from '~/lib/utils/keys'; import { objectToQuery } from '~/lib/utils/url_utility'; @@ -17,7 +16,6 @@ import LinksSection from '~/monitoring/components/links_section.vue'; import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; -import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import { metricsDashboardViewModel, metricsDashboardPanelCount, @@ -41,7 +39,7 @@ describe('Dashboard', () => { let mock; const createShallowWrapper = (props = {}, options = {}) => { - wrapper = shallowMount(Dashboard, { + wrapper = shallowMountExtended(Dashboard, { propsData: { ...dashboardProps, ...props }, store, stubs: { @@ -53,7 +51,7 @@ describe('Dashboard', () => { }; const createMountedWrapper = (props = {}, options = {}) => { - wrapper = mount(Dashboard, { + wrapper = mountExtended(Dashboard, { propsData: { ...dashboardProps, ...props }, store, stubs: { @@ -818,24 +816,28 @@ describe('Dashboard', () => { }); }); - describe('deprecation notice', () => { + describe('alerts deprecation', () => { beforeEach(() => { setupStoreWithData(store); }); - const findDeprecationNotice = () => - wrapper.find(AlertDeprecationWarning).findComponent(GlAlert); - - it('shows the deprecation notice when available', () => { - createMountedWrapper({}, { provide: { hasManagedPrometheus: true } }); - - expect(findDeprecationNotice().exists()).toBe(true); - }); - - it('hides the deprecation notice when not available', () => { - createMountedWrapper(); - - expect(findDeprecationNotice().exists()).toBe(false); - }); + const findDeprecationNotice = () => wrapper.findByTestId('alerts-deprecation-warning'); + + it.each` + managedAlertsDeprecation | hasManagedPrometheus | isVisible + ${false} | ${false} | ${false} + ${false} | ${true} | ${true} + ${true} | ${false} | ${false} + ${true} | ${true} | ${false} + `( + 'when the deprecation feature flag is $managedAlertsDeprecation and has managed prometheus is $hasManagedPrometheus', + ({ hasManagedPrometheus, managedAlertsDeprecation, isVisible }) => { + createMountedWrapper( + {}, + { provide: { hasManagedPrometheus, glFeatures: { managedAlertsDeprecation } } }, + ); + expect(findDeprecationNotice().exists()).toBe(isVisible); + }, + ); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 090613b0f1e..bea263f143a 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { queryToObject, diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index b7f741c449f..f60c531e3f6 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { backoffMockImplementation } from 'helpers/backoff_helper'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; import statusCodes from '~/lib/utils/http_status'; @@ -257,9 +257,9 @@ describe('Monitoring store actions', () => { 'receiveMetricsDashboardFailure', new Error('Request failed with status code 500'), ); - expect(createFlash).toHaveBeenCalledWith( - expect.stringContaining(mockDashboardsErrorResponse.message), - ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringContaining(mockDashboardsErrorResponse.message), + }); done(); }) .catch(done.fail); @@ -1148,9 +1148,9 @@ describe('Monitoring store actions', () => { return testAction(fetchVariableMetricLabelValues, { defaultQueryParams }, state, [], []).then( () => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - expect.stringContaining('error getting options for variable "label1"'), - ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringContaining('error getting options for variable "label1"'), + }); }, ); }); diff --git a/spec/frontend/nav/components/responsive_app_spec.js b/spec/frontend/nav/components/responsive_app_spec.js new file mode 100644 index 00000000000..7221ea2c5cd --- /dev/null +++ b/spec/frontend/nav/components/responsive_app_spec.js @@ -0,0 +1,173 @@ +import { shallowMount } from '@vue/test-utils'; +import ResponsiveApp from '~/nav/components/responsive_app.vue'; +import ResponsiveHeader from '~/nav/components/responsive_header.vue'; +import ResponsiveHome from '~/nav/components/responsive_home.vue'; +import TopNavContainerView from '~/nav/components/top_nav_container_view.vue'; +import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '~/nav/event_hub'; +import { resetMenuItemsActive } from '~/nav/utils/reset_menu_items_active'; +import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; +import { TEST_NAV_DATA } from '../mock_data'; + +const HTML_HEADER_CONTENT = '<div class="header-content"></div>'; +const HTML_MENU_EXPANDED = '<div class="menu-expanded"></div>'; +const HTML_HEADER_WITH_MENU_EXPANDED = + '<div></div><div class="header-content menu-expanded"></div>'; + +describe('~/nav/components/responsive_app.vue', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(ResponsiveApp, { + propsData: { + navData: TEST_NAV_DATA, + }, + stubs: { + KeepAliveSlots, + }, + }); + }; + const triggerResponsiveToggle = () => eventHub.$emit(EVENT_RESPONSIVE_TOGGLE); + + const findHome = () => wrapper.findComponent(ResponsiveHome); + const findMobileOverlay = () => wrapper.find('[data-testid="mobile-overlay"]'); + const findSubviewHeader = () => wrapper.findComponent(ResponsiveHeader); + const findSubviewContainer = () => wrapper.findComponent(TopNavContainerView); + const hasBodyResponsiveOpen = () => document.body.classList.contains('top-nav-responsive-open'); + const hasMobileOverlayVisible = () => findMobileOverlay().classes('mobile-nav-open'); + + beforeEach(() => { + document.body.innerHTML = ''; + // Add test class to reset state + assert that we're adding classes correctly + document.body.className = 'test-class'; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows home by default', () => { + expect(findHome().isVisible()).toBe(true); + expect(findHome().props()).toEqual({ + navData: resetMenuItemsActive(TEST_NAV_DATA), + }); + }); + + it.each` + bodyHtml | expectation + ${''} | ${false} + ${HTML_HEADER_CONTENT} | ${false} + ${HTML_MENU_EXPANDED} | ${false} + ${HTML_HEADER_WITH_MENU_EXPANDED} | ${true} + `( + 'with responsive toggle event and html set to $bodyHtml, responsive open = $expectation', + ({ bodyHtml, expectation }) => { + document.body.innerHTML = bodyHtml; + + triggerResponsiveToggle(); + + expect(hasBodyResponsiveOpen()).toBe(expectation); + }, + ); + + it.each` + events | expectation + ${[]} | ${false} + ${['bv::dropdown::show']} | ${true} + ${['bv::dropdown::show', 'bv::dropdown::hide']} | ${false} + `( + 'with root events $events, movile overlay visible = $expectation', + async ({ events, expectation }) => { + // `await...reduce(async` is like doing an `forEach(async (...))` excpet it works + await events.reduce(async (acc, evt) => { + await acc; + + wrapper.vm.$root.$emit(evt); + + await wrapper.vm.$nextTick(); + }, Promise.resolve()); + + expect(hasMobileOverlayVisible()).toBe(expectation); + }, + ); + }); + + describe('with menu expanded in body', () => { + beforeEach(() => { + document.body.innerHTML = HTML_HEADER_WITH_MENU_EXPANDED; + createComponent(); + }); + + it('sets the body responsive open', () => { + expect(hasBodyResponsiveOpen()).toBe(true); + }); + }); + + const projectsContainerProps = { + containerClass: 'gl-px-3', + frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.namespace, + frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.vuexModule, + linksPrimary: TEST_NAV_DATA.views.projects.linksPrimary, + linksSecondary: TEST_NAV_DATA.views.projects.linksSecondary, + }; + const groupsContainerProps = { + containerClass: 'gl-px-3', + frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_GROUPS.namespace, + frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_GROUPS.vuexModule, + linksPrimary: TEST_NAV_DATA.views.groups.linksPrimary, + linksSecondary: TEST_NAV_DATA.views.groups.linksSecondary, + }; + + describe.each` + view | header | containerProps + ${'projects'} | ${'Projects'} | ${projectsContainerProps} + ${'groups'} | ${'Groups'} | ${groupsContainerProps} + `('when menu item with $view is clicked', ({ view, header, containerProps }) => { + beforeEach(async () => { + createComponent(); + + findHome().vm.$emit('menu-item-click', { view }); + + await wrapper.vm.$nextTick(); + }); + + it('shows header', () => { + expect(findSubviewHeader().text()).toBe(header); + }); + + it('shows container subview', () => { + expect(findSubviewContainer().props()).toEqual(containerProps); + }); + + it('hides home', () => { + expect(findHome().isVisible()).toBe(false); + }); + + describe('when header back button is clicked', () => { + beforeEach(() => { + findSubviewHeader().vm.$emit('menu-item-click', { view: 'home' }); + }); + + it('shows home', () => { + expect(findHome().isVisible()).toBe(true); + }); + }); + }); + + describe('when destroyed', () => { + beforeEach(() => { + createComponent(); + wrapper.destroy(); + }); + + it('responsive toggle event does nothing', () => { + triggerResponsiveToggle(); + + expect(hasBodyResponsiveOpen()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/nav/components/responsive_header_spec.js b/spec/frontend/nav/components/responsive_header_spec.js new file mode 100644 index 00000000000..937c44727c7 --- /dev/null +++ b/spec/frontend/nav/components/responsive_header_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ResponsiveHeader from '~/nav/components/responsive_header.vue'; +import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; + +const TEST_SLOT_CONTENT = 'Test slot content'; + +describe('~/nav/components/top_nav_menu_sections.vue', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(ResponsiveHeader, { + slots: { + default: TEST_SLOT_CONTENT, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findMenuItem = () => wrapper.findComponent(TopNavMenuItem); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders slot', () => { + expect(wrapper.text()).toBe(TEST_SLOT_CONTENT); + }); + + it('renders back button', () => { + const button = findMenuItem(); + + const tooltip = getBinding(button.element, 'gl-tooltip').value.title; + + expect(tooltip).toBe('Go back'); + expect(button.props()).toEqual({ + menuItem: { + id: 'home', + view: 'home', + icon: 'angle-left', + }, + iconOnly: true, + }); + }); + + it('emits nothing', () => { + expect(wrapper.emitted()).toEqual({}); + }); + + describe('when back button is clicked', () => { + beforeEach(() => { + findMenuItem().vm.$emit('click'); + }); + + it('emits menu-item-click', () => { + expect(wrapper.emitted()).toEqual({ + 'menu-item-click': [[{ id: 'home', view: 'home', icon: 'angle-left' }]], + }); + }); + }); +}); diff --git a/spec/frontend/nav/components/responsive_home_spec.js b/spec/frontend/nav/components/responsive_home_spec.js new file mode 100644 index 00000000000..8f198d92747 --- /dev/null +++ b/spec/frontend/nav/components/responsive_home_spec.js @@ -0,0 +1,137 @@ +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ResponsiveHome from '~/nav/components/responsive_home.vue'; +import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; +import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; +import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue'; +import { TEST_NAV_DATA } from '../mock_data'; + +const TEST_SEARCH_MENU_ITEM = { + id: 'search', + title: 'search', + icon: 'search', + href: '/search', +}; + +const TEST_NEW_DROPDOWN_VIEW_MODEL = { + title: 'new', + menu_sections: [], +}; + +describe('~/nav/components/responsive_home.vue', () => { + let wrapper; + let menuItemClickListener; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ResponsiveHome, { + propsData: { + navData: TEST_NAV_DATA, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + listeners: { + 'menu-item-click': menuItemClickListener, + }, + }); + }; + + const findSearchMenuItem = () => wrapper.findComponent(TopNavMenuItem); + const findNewDropdown = () => wrapper.findComponent(TopNavNewDropdown); + const findMenuSections = () => wrapper.findComponent(TopNavMenuSections); + + beforeEach(() => { + menuItemClickListener = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + desc | fn + ${'does not show search menu item'} | ${findSearchMenuItem} + ${'does not show new dropdown'} | ${findNewDropdown} + `('$desc', ({ fn }) => { + expect(fn().exists()).toBe(false); + }); + + it('shows menu sections', () => { + expect(findMenuSections().props('sections')).toEqual([ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, + { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, + ]); + }); + + it('emits when menu sections emits', () => { + expect(menuItemClickListener).not.toHaveBeenCalled(); + + findMenuSections().vm.$emit('menu-item-click', TEST_NAV_DATA.primary[0]); + + expect(menuItemClickListener).toHaveBeenCalledWith(TEST_NAV_DATA.primary[0]); + }); + }); + + describe('without secondary', () => { + beforeEach(() => { + createComponent({ navData: { ...TEST_NAV_DATA, secondary: null } }); + }); + + it('shows menu sections', () => { + expect(findMenuSections().props('sections')).toEqual([ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, + ]); + }); + }); + + describe('with search view', () => { + beforeEach(() => { + createComponent({ + navData: { + ...TEST_NAV_DATA, + views: { search: TEST_SEARCH_MENU_ITEM }, + }, + }); + }); + + it('shows search menu item', () => { + expect(findSearchMenuItem().props()).toEqual({ + menuItem: TEST_SEARCH_MENU_ITEM, + iconOnly: true, + }); + }); + + it('shows tooltip for search', () => { + const tooltip = getBinding(findSearchMenuItem().element, 'gl-tooltip'); + expect(tooltip.value).toEqual({ title: TEST_SEARCH_MENU_ITEM.title }); + }); + }); + + describe('with new view', () => { + beforeEach(() => { + createComponent({ + navData: { + ...TEST_NAV_DATA, + views: { new: TEST_NEW_DROPDOWN_VIEW_MODEL }, + }, + }); + }); + + it('shows new dropdown', () => { + expect(findNewDropdown().props()).toEqual({ + viewModel: TEST_NEW_DROPDOWN_VIEW_MODEL, + }); + }); + + it('shows tooltip for new dropdown', () => { + const tooltip = getBinding(findNewDropdown().element, 'gl-tooltip'); + expect(tooltip.value).toEqual({ title: TEST_NEW_DROPDOWN_VIEW_MODEL.title }); + }); + }); +}); diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js index 06700ce748e..1d6ea99155b 100644 --- a/spec/frontend/nav/components/top_nav_app_spec.js +++ b/spec/frontend/nav/components/top_nav_app_spec.js @@ -1,5 +1,5 @@ -import { GlNavItemDropdown, GlTooltip } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { GlNavItemDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import TopNavApp from '~/nav/components/top_nav_app.vue'; import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; import { TEST_NAV_DATA } from '../mock_data'; @@ -7,8 +7,8 @@ import { TEST_NAV_DATA } from '../mock_data'; describe('~/nav/components/top_nav_app.vue', () => { let wrapper; - const createComponent = (mountFn = shallowMount) => { - wrapper = mountFn(TopNavApp, { + const createComponent = () => { + wrapper = shallowMount(TopNavApp, { propsData: { navData: TEST_NAV_DATA, }, @@ -17,7 +17,6 @@ describe('~/nav/components/top_nav_app.vue', () => { const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown); const findMenu = () => wrapper.findComponent(TopNavDropdownMenu); - const findTooltip = () => wrapper.findComponent(GlTooltip); afterEach(() => { wrapper.destroy(); @@ -31,7 +30,7 @@ describe('~/nav/components/top_nav_app.vue', () => { it('renders nav item dropdown', () => { expect(findNavItemDropdown().attributes('href')).toBeUndefined(); expect(findNavItemDropdown().attributes()).toMatchObject({ - icon: 'dot-grid', + icon: 'hamburger', text: TEST_NAV_DATA.activeTitle, 'no-flip': '', }); @@ -44,25 +43,5 @@ describe('~/nav/components/top_nav_app.vue', () => { views: TEST_NAV_DATA.views, }); }); - - it('renders tooltip', () => { - expect(findTooltip().attributes()).toMatchObject({ - 'boundary-padding': '0', - placement: 'right', - title: TopNavApp.TOOLTIP, - }); - }); - }); - - describe('when full mounted', () => { - beforeEach(() => { - createComponent(mount); - }); - - it('has dropdown toggle as tooltip target', () => { - const targetFn = findTooltip().props('target'); - - expect(targetFn()).toBe(wrapper.find('.js-top-nav-dropdown-toggle').element); - }); }); }); diff --git a/spec/frontend/nav/components/top_nav_container_view_spec.js b/spec/frontend/nav/components/top_nav_container_view_spec.js index b08d75f36ce..06d2179b859 100644 --- a/spec/frontend/nav/components/top_nav_container_view_spec.js +++ b/spec/frontend/nav/components/top_nav_container_view_spec.js @@ -4,7 +4,7 @@ import FrequentItemsApp from '~/frequent_items/components/app.vue'; import { FREQUENT_ITEMS_PROJECTS } from '~/frequent_items/constants'; import eventHub from '~/frequent_items/event_hub'; import TopNavContainerView from '~/nav/components/top_nav_container_view.vue'; -import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; +import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; import { TEST_NAV_DATA } from '../mock_data'; @@ -13,39 +13,39 @@ const DEFAULT_PROPS = { frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule, linksPrimary: TEST_NAV_DATA.primary, linksSecondary: TEST_NAV_DATA.secondary, + containerClass: 'test-frequent-items-container-class', }; const TEST_OTHER_PROPS = { namespace: 'projects', - currentUserName: '', - currentItem: {}, + currentUserName: 'test-user', + currentItem: { id: 'test' }, }; describe('~/nav/components/top_nav_container_view.vue', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = (props = {}, options = {}) => { wrapper = shallowMount(TopNavContainerView, { propsData: { ...DEFAULT_PROPS, ...TEST_OTHER_PROPS, ...props, }, + ...options, }); }; - const findMenuItems = (parent = wrapper) => parent.findAll(TopNavMenuItem); - const findMenuItemsModel = (parent = wrapper) => - findMenuItems(parent).wrappers.map((x) => x.props()); - const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]'); - const findMenuItemGroupsModel = () => findMenuItemGroups().wrappers.map(findMenuItemsModel); + const findMenuSections = () => wrapper.findComponent(TopNavMenuSections); const findFrequentItemsApp = () => { const parent = wrapper.findComponent(VuexModuleProvider); return { vuexModule: parent.props('vuexModule'), props: parent.findComponent(FrequentItemsApp).props(), + attributes: parent.findComponent(FrequentItemsApp).attributes(), }; }; + const findFrequentItemsContainer = () => wrapper.find('[data-testid="frequent-items-container"]'); afterEach(() => { wrapper.destroy(); @@ -67,34 +67,40 @@ describe('~/nav/components/top_nav_container_view.vue', () => { ); describe('default', () => { + const EXTRA_ATTRS = { 'data-test-attribute': 'foo' }; + beforeEach(() => { - createComponent(); + createComponent({}, { attrs: EXTRA_ATTRS }); + }); + + it('does not inherit extra attrs', () => { + expect(wrapper.attributes()).toEqual({ + class: expect.any(String), + }); }); it('renders frequent items app', () => { expect(findFrequentItemsApp()).toEqual({ vuexModule: DEFAULT_PROPS.frequentItemsVuexModule, - props: TEST_OTHER_PROPS, + props: expect.objectContaining(TEST_OTHER_PROPS), + attributes: expect.objectContaining(EXTRA_ATTRS), }); }); - it('renders menu item groups', () => { - expect(findMenuItemGroupsModel()).toEqual([ - TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })), - TEST_NAV_DATA.secondary.map((menuItem) => ({ menuItem })), - ]); - }); - - it('only the first group does not have margin top', () => { - expect(findMenuItemGroups().wrappers.map((x) => x.classes('gl-mt-3'))).toEqual([false, true]); + it('renders given container class', () => { + expect(findFrequentItemsContainer().classes(DEFAULT_PROPS.containerClass)).toBe(true); }); - it('only the first menu item does not have margin top', () => { - const actual = findMenuItems(findMenuItemGroups().at(1)).wrappers.map((x) => - x.classes('gl-mt-1'), - ); + it('renders menu sections', () => { + const sections = [ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, + { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, + ]; - expect(actual).toEqual([false, ...TEST_NAV_DATA.secondary.slice(1).fill(true)]); + expect(findMenuSections().props()).toEqual({ + sections, + withTopBorder: true, + }); }); }); @@ -106,8 +112,8 @@ describe('~/nav/components/top_nav_container_view.vue', () => { }); it('renders one menu item group', () => { - expect(findMenuItemGroupsModel()).toEqual([ - TEST_NAV_DATA.primary.map((menuItem) => ({ menuItem })), + expect(findMenuSections().props('sections')).toEqual([ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, ]); }); }); diff --git a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js index d9bba22238a..70df05a2781 100644 --- a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js +++ b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js @@ -1,67 +1,62 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue'; +import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue'; +import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; import { TEST_NAV_DATA } from '../mock_data'; -const SECONDARY_GROUP_CLASSES = TopNavDropdownMenu.SECONDARY_GROUP_CLASS.split(' '); - describe('~/nav/components/top_nav_dropdown_menu.vue', () => { let wrapper; - const createComponent = (props = {}) => { - wrapper = shallowMount(TopNavDropdownMenu, { + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(TopNavDropdownMenu, { propsData: { primary: TEST_NAV_DATA.primary, secondary: TEST_NAV_DATA.secondary, views: TEST_NAV_DATA.views, ...props, }, + stubs: { + // Stub the keep-alive-slots so we don't render frequent items which uses a store + KeepAliveSlots: true, + }, }); }; - const findMenuItems = (parent = wrapper) => parent.findAll('[data-testid="menu-item"]'); - const findMenuItemsModel = (parent = wrapper) => - findMenuItems(parent).wrappers.map((x) => ({ - menuItem: x.props('menuItem'), - isActive: x.classes('active'), - })); - const findMenuItemGroups = () => wrapper.findAll('[data-testid="menu-item-group"]'); - const findMenuItemGroupsModel = () => - findMenuItemGroups().wrappers.map((x) => ({ - classes: x.classes(), - items: findMenuItemsModel(x), - })); + const findMenuItems = () => wrapper.findAllComponents(TopNavMenuItem); + const findMenuSections = () => wrapper.find(TopNavMenuSections); const findMenuSidebar = () => wrapper.find('[data-testid="menu-sidebar"]'); const findMenuSubview = () => wrapper.findComponent(KeepAliveSlots); const hasFullWidthMenuSidebar = () => findMenuSidebar().classes('gl-w-full'); - const createItemsGroupModelExpectation = ({ - primary = TEST_NAV_DATA.primary, - secondary = TEST_NAV_DATA.secondary, - activeIndex = -1, - } = {}) => [ - { - classes: [], - items: primary.map((menuItem, index) => ({ isActive: index === activeIndex, menuItem })), - }, - { - classes: SECONDARY_GROUP_CLASSES, - items: secondary.map((menuItem) => ({ isActive: false, menuItem })), - }, - ]; + const withActiveIndex = (menuItems, activeIndex) => + menuItems.map((x, idx) => ({ + ...x, + active: idx === activeIndex, + })); afterEach(() => { wrapper.destroy(); }); + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(); + }); + describe('default', () => { beforeEach(() => { createComponent(); }); - it('renders menu item groups', () => { - expect(findMenuItemGroupsModel()).toEqual(createItemsGroupModelExpectation()); + it('renders menu sections', () => { + expect(findMenuSections().props()).toEqual({ + sections: [ + { id: 'primary', menuItems: TEST_NAV_DATA.primary }, + { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, + ], + withTopBorder: false, + }); }); it('has full width menu sidebar', () => { @@ -74,36 +69,25 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => { expect(subview.isVisible()).toBe(false); expect(subview.props()).toEqual({ slotKey: '' }); }); - - it('the first menu item in a group does not render margin top', () => { - const actual = findMenuItems(findMenuItemGroups().at(0)).wrappers.map((x) => - x.classes('gl-mt-1'), - ); - - expect(actual).toEqual([false, ...TEST_NAV_DATA.primary.slice(1).fill(true)]); - }); }); describe('with pre-initialized active view', () => { - const primaryWithActive = [ - TEST_NAV_DATA.primary[0], - { - ...TEST_NAV_DATA.primary[1], - active: true, - }, - ...TEST_NAV_DATA.primary.slice(2), - ]; - beforeEach(() => { - createComponent({ - primary: primaryWithActive, - }); + // We opt for a small integration test, to make sure the event is handled correctly + // as it would in prod. + createComponent( + { + primary: withActiveIndex(TEST_NAV_DATA.primary, 1), + }, + mount, + ); }); - it('renders menu item groups', () => { - expect(findMenuItemGroupsModel()).toEqual( - createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 1 }), - ); + it('renders menu sections', () => { + expect(findMenuSections().props('sections')).toStrictEqual([ + { id: 'primary', menuItems: withActiveIndex(TEST_NAV_DATA.primary, 1) }, + { id: 'secondary', menuItems: TEST_NAV_DATA.secondary }, + ]); }); it('does not have full width menu sidebar', () => { @@ -114,11 +98,11 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => { const subview = findMenuSubview(); expect(subview.isVisible()).toBe(true); - expect(subview.props('slotKey')).toBe(primaryWithActive[1].view); + expect(subview.props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view); }); it('does not change view if non-view menu item is clicked', async () => { - const secondaryLink = findMenuItems().at(primaryWithActive.length); + const secondaryLink = findMenuItems().at(TEST_NAV_DATA.primary.length); // Ensure this doesn't have a view expect(secondaryLink.props('menuItem').view).toBeUndefined(); @@ -127,10 +111,10 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => { await nextTick(); - expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[1].view); + expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view); }); - describe('when other view menu item is clicked', () => { + describe('when menu item is clicked', () => { let primaryLink; beforeEach(async () => { @@ -144,13 +128,20 @@ describe('~/nav/components/top_nav_dropdown_menu.vue', () => { }); it('changes active view', () => { - expect(findMenuSubview().props('slotKey')).toBe(primaryWithActive[0].view); + expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[0].view); }); it('changes active status on menu item', () => { - expect(findMenuItemGroupsModel()).toStrictEqual( - createItemsGroupModelExpectation({ primary: primaryWithActive, activeIndex: 0 }), - ); + expect(findMenuSections().props('sections')).toStrictEqual([ + { + id: 'primary', + menuItems: withActiveIndex(TEST_NAV_DATA.primary, 0), + }, + { + id: 'secondary', + menuItems: withActiveIndex(TEST_NAV_DATA.secondary, -1), + }, + ]); }); }); }); diff --git a/spec/frontend/nav/components/top_nav_menu_item_spec.js b/spec/frontend/nav/components/top_nav_menu_item_spec.js index 579af13d08a..fd2b4d3b056 100644 --- a/spec/frontend/nav/components/top_nav_menu_item_spec.js +++ b/spec/frontend/nav/components/top_nav_menu_item_spec.js @@ -7,6 +7,7 @@ const TEST_MENU_ITEM = { icon: 'search', href: '/pretty/good/burger', view: 'burger-view', + data: { qa_selector: 'not-a-real-selector', method: 'post', testFoo: 'test' }, }; describe('~/nav/components/top_nav_menu_item.vue', () => { @@ -29,7 +30,10 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { const findButtonIcons = () => findButton() .findAllComponents(GlIcon) - .wrappers.map((x) => x.props('name')); + .wrappers.map((x) => ({ + name: x.props('name'), + classes: x.classes(), + })); beforeEach(() => { listener = jest.fn(); @@ -47,6 +51,16 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { expect(button.text()).toBe(TEST_MENU_ITEM.title); }); + it('renders button data attributes', () => { + const button = findButton(); + + expect(button.attributes()).toMatchObject({ + 'data-qa-selector': TEST_MENU_ITEM.data.qa_selector, + 'data-method': TEST_MENU_ITEM.data.method, + 'data-test-foo': TEST_MENU_ITEM.data.testFoo, + }); + }); + it('passes listeners to button', () => { expect(listener).not.toHaveBeenCalled(); @@ -54,11 +68,42 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { expect(listener).toHaveBeenCalledWith('TEST'); }); + + it('renders expected icons', () => { + expect(findButtonIcons()).toEqual([ + { + name: TEST_MENU_ITEM.icon, + classes: ['gl-mr-2!'], + }, + { + name: 'chevron-right', + classes: ['gl-ml-auto'], + }, + ]); + }); + }); + + describe('with icon-only', () => { + beforeEach(() => { + createComponent({ iconOnly: true }); + }); + + it('does not render title or view icon', () => { + expect(wrapper.text()).toBe(''); + }); + + it('only renders menuItem icon', () => { + expect(findButtonIcons()).toEqual([ + { + name: TEST_MENU_ITEM.icon, + classes: [], + }, + ]); + }); }); describe.each` desc | menuItem | expectedIcons - ${'default'} | ${TEST_MENU_ITEM} | ${[TEST_MENU_ITEM.icon, 'chevron-right']} ${'with no icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']} ${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]} ${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]} @@ -68,7 +113,32 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { }); it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => { - expect(findButtonIcons()).toEqual(expectedIcons); + expect(findButtonIcons().map((x) => x.name)).toEqual(expectedIcons); + }); + }); + + describe.each` + desc | active | cssClass | expectedClasses + ${'default'} | ${false} | ${''} | ${[]} + ${'with css class'} | ${false} | ${'test-css-class testing-123'} | ${['test-css-class', 'testing-123']} + ${'with css class & active'} | ${true} | ${'test-css-class'} | ${['test-css-class', ...TopNavMenuItem.ACTIVE_CLASS.split(' ')]} + `('$desc', ({ active, cssClass, expectedClasses }) => { + beforeEach(() => { + createComponent({ + menuItem: { + ...TEST_MENU_ITEM, + active, + css_class: cssClass, + }, + }); + }); + + it('renders expected classes', () => { + expect(wrapper.classes()).toStrictEqual([ + 'top-nav-menu-item', + 'gl-display-block', + ...expectedClasses, + ]); }); }); }); diff --git a/spec/frontend/nav/components/top_nav_menu_sections_spec.js b/spec/frontend/nav/components/top_nav_menu_sections_spec.js new file mode 100644 index 00000000000..d56542fe572 --- /dev/null +++ b/spec/frontend/nav/components/top_nav_menu_sections_spec.js @@ -0,0 +1,107 @@ +import { shallowMount } from '@vue/test-utils'; +import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue'; + +const TEST_SECTIONS = [ + { + id: 'primary', + menuItems: [{ id: 'test', href: '/test/href' }, { id: 'foo' }, { id: 'bar' }], + }, + { + id: 'secondary', + menuItems: [{ id: 'lorem' }, { id: 'ipsum' }], + }, +]; + +describe('~/nav/components/top_nav_menu_sections.vue', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TopNavMenuSections, { + propsData: { + sections: TEST_SECTIONS, + ...props, + }, + }); + }; + + const findMenuItemModels = (parent) => + parent.findAll('[data-testid="menu-item"]').wrappers.map((x) => ({ + menuItem: x.props('menuItem'), + classes: x.classes(), + })); + const findSectionModels = () => + wrapper.findAll('[data-testid="menu-section"]').wrappers.map((x) => ({ + classes: x.classes(), + menuItems: findMenuItemModels(x), + })); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders sections with menu items', () => { + expect(findSectionModels()).toEqual([ + { + classes: [], + menuItems: [ + { + menuItem: TEST_SECTIONS[0].menuItems[0], + classes: ['gl-w-full'], + }, + ...TEST_SECTIONS[0].menuItems.slice(1).map((menuItem) => ({ + menuItem, + classes: ['gl-w-full', 'gl-mt-1'], + })), + ], + }, + { + classes: [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-mt-3'], + menuItems: [ + { + menuItem: TEST_SECTIONS[1].menuItems[0], + classes: ['gl-w-full'], + }, + ...TEST_SECTIONS[1].menuItems.slice(1).map((menuItem) => ({ + menuItem, + classes: ['gl-w-full', 'gl-mt-1'], + })), + ], + }, + ]); + }); + + it('when clicked menu item with href, does nothing', () => { + const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(0); + + menuItem.vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({}); + }); + + it('when clicked menu item without href, emits "menu-item-click"', () => { + const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(1); + + menuItem.vm.$emit('click'); + + expect(wrapper.emitted('menu-item-click')).toEqual([[TEST_SECTIONS[0].menuItems[1]]]); + }); + }); + + describe('with withTopBorder=true', () => { + beforeEach(() => { + createComponent({ withTopBorder: true }); + }); + + it('renders border classes for top section', () => { + expect(findSectionModels().map((x) => x.classes)).toEqual([ + [...TopNavMenuSections.BORDER_CLASSES.split(' ')], + [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-mt-3'], + ]); + }); + }); +}); diff --git a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js new file mode 100644 index 00000000000..18210658b89 --- /dev/null +++ b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js @@ -0,0 +1,122 @@ +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue'; + +const TEST_VIEW_MODEL = { + title: 'Dropdown', + menu_sections: [ + { + title: 'Section 1', + menu_items: [ + { id: 'foo-1', title: 'Foo 1', href: '/foo/1' }, + { id: 'foo-2', title: 'Foo 2', href: '/foo/2' }, + { id: 'foo-3', title: 'Foo 3', href: '/foo/3' }, + ], + }, + { + title: 'Section 2', + menu_items: [ + { id: 'bar-1', title: 'Bar 1', href: '/bar/1' }, + { id: 'bar-2', title: 'Bar 2', href: '/bar/2' }, + ], + }, + ], +}; + +describe('~/nav/components/top_nav_menu_sections.vue', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(TopNavNewDropdown, { + propsData: { + viewModel: TEST_VIEW_MODEL, + ...props, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownContents = () => + findDropdown() + .findAll('[data-testid]') + .wrappers.map((child) => { + const type = child.attributes('data-testid'); + + if (type === 'divider') { + return { type }; + } else if (type === 'header') { + return { type, text: child.text() }; + } + + return { + type, + text: child.text(), + href: child.attributes('href'), + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders dropdown parent', () => { + expect(findDropdown().props()).toMatchObject({ + text: TEST_VIEW_MODEL.title, + textSrOnly: true, + icon: 'plus', + }); + }); + + it('renders dropdown content', () => { + expect(findDropdownContents()).toEqual([ + { + type: 'header', + text: TEST_VIEW_MODEL.menu_sections[0].title, + }, + ...TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({ + type: 'item', + href, + text: title, + })), + { + type: 'divider', + }, + { + type: 'header', + text: TEST_VIEW_MODEL.menu_sections[1].title, + }, + ...TEST_VIEW_MODEL.menu_sections[1].menu_items.map(({ title, href }) => ({ + type: 'item', + href, + text: title, + })), + ]); + }); + }); + + describe('with only 1 section', () => { + beforeEach(() => { + createComponent({ + viewModel: { + ...TEST_VIEW_MODEL, + menu_sections: TEST_VIEW_MODEL.menu_sections.slice(0, 1), + }, + }); + }); + + it('renders dropdown content without headers and dividers', () => { + expect(findDropdownContents()).toEqual( + TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({ + type: 'item', + href, + text: title, + })), + ); + }); + }); +}); diff --git a/spec/frontend/nav/mock_data.js b/spec/frontend/nav/mock_data.js index 2987d8deb16..c2ad86a4605 100644 --- a/spec/frontend/nav/mock_data.js +++ b/spec/frontend/nav/mock_data.js @@ -25,11 +25,15 @@ export const TEST_NAV_DATA = { namespace: 'projects', currentUserName: '', currentItem: {}, + linksPrimary: [{ id: 'project-link', href: '/path/to/projects', title: 'Project Link' }], + linksSecondary: [], }, groups: { namespace: 'groups', currentUserName: '', currentItem: {}, + linksPrimary: [], + linksSecondary: [{ id: 'group-link', href: '/path/to/groups', title: 'Group Link' }], }, }, }; diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index b140eea9439..537622b7918 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -328,20 +328,45 @@ describe('issue_comment_form component', () => { mountComponent({ mountFunction: mount }); }); - it('should save note when cmd+enter is pressed', () => { - jest.spyOn(wrapper.vm, 'handleSave'); + describe('when no draft exists', () => { + it('should save note when cmd+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); - findTextArea().trigger('keydown.enter', { metaKey: true }); + findTextArea().trigger('keydown.enter', { metaKey: true }); - expect(wrapper.vm.handleSave).toHaveBeenCalled(); + expect(wrapper.vm.handleSave).toHaveBeenCalledWith(); + }); + + it('should save note when ctrl+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); + + findTextArea().trigger('keydown.enter', { ctrlKey: true }); + + expect(wrapper.vm.handleSave).toHaveBeenCalledWith(); + }); }); - it('should save note when ctrl+enter is pressed', () => { - jest.spyOn(wrapper.vm, 'handleSave'); + describe('when a draft exists', () => { + beforeEach(() => { + store.registerModule('batchComments', batchComments()); + store.state.batchComments.drafts = [{ note: 'A' }]; + }); + + it('should save note draft when cmd+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSaveDraft'); + + findTextArea().trigger('keydown.enter', { metaKey: true }); + + expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); + }); + + it('should save note draft when ctrl+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSaveDraft'); - findTextArea().trigger('keydown.enter', { ctrlKey: true }); + findTextArea().trigger('keydown.enter', { ctrlKey: true }); - expect(wrapper.vm.handleSave).toHaveBeenCalled(); + expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); + }); }); }); }); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index c6a7d7ead98..925dbcc09ec 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -20,7 +20,7 @@ const createUnallowedNote = () => describe('DiscussionActions', () => { let wrapper; - const createComponentFactory = (shallow = true) => (props) => { + const createComponentFactory = (shallow = true) => (props, options) => { const store = createStore(); const mountFn = shallow ? shallowMount : mount; @@ -34,6 +34,7 @@ describe('DiscussionActions', () => { shouldShowJumpToNextDiscussion: true, ...props, }, + ...options, }); }; @@ -90,17 +91,17 @@ describe('DiscussionActions', () => { describe('events handling', () => { const createComponent = createComponentFactory(false); - beforeEach(() => { - createComponent(); - }); - it('emits showReplyForm event when clicking on reply placeholder', () => { + createComponent({}, { attachTo: document.body }); + jest.spyOn(wrapper.vm, '$emit'); wrapper.find(ReplyPlaceholder).find('textarea').trigger('focus'); expect(wrapper.vm.$emit).toHaveBeenCalledWith('showReplyForm'); }); it('emits resolve event when clicking on resolve button', () => { + createComponent(); + jest.spyOn(wrapper.vm, '$emit'); wrapper.find(ResolveDiscussionButton).find('button').trigger('click'); expect(wrapper.vm.$emit).toHaveBeenCalledWith('resolve'); diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js index 2a4cd0df0c7..3932f818c4e 100644 --- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js +++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js @@ -6,31 +6,34 @@ const placeholderText = 'Test Button Text'; describe('ReplyPlaceholder', () => { let wrapper; - const findTextarea = () => wrapper.find({ ref: 'textarea' }); - - beforeEach(() => { + const createComponent = ({ options = {} } = {}) => { wrapper = shallowMount(ReplyPlaceholder, { propsData: { placeholderText, }, + ...options, }); - }); + }; + + const findTextarea = () => wrapper.find({ ref: 'textarea' }); afterEach(() => { wrapper.destroy(); }); - it('emits focus event on button click', () => { - findTextarea().trigger('focus'); + it('emits focus event on button click', async () => { + createComponent({ options: { attachTo: document.body } }); + + await findTextarea().trigger('focus'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted()).toEqual({ - focus: [[]], - }); + expect(wrapper.emitted()).toEqual({ + focus: [[]], }); }); it('should render reply button', () => { + createComponent(); + expect(findTextarea().attributes('placeholder')).toEqual(placeholderText); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 735bc2b70dd..a364a524e7b 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -56,6 +56,18 @@ describe('noteable_discussion component', () => { expect(wrapper.find('.discussion-header').exists()).toBe(true); }); + it('should hide actions when diff refs do not exists', async () => { + const discussion = { ...discussionMock }; + discussion.diff_file = { ...mockDiffFile, diff_refs: null }; + discussion.diff_discussion = true; + discussion.expanded = false; + + wrapper.setProps({ discussion }); + await nextTick(); + + expect(wrapper.vm.canShowReplyActions).toBe(false); + }); + describe('actions', () => { it('should toggle reply form', async () => { await nextTick(); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 9b7456d54bc..7eef2017dfb 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -25,7 +25,19 @@ import { } from '../mock_data'; const TEST_ERROR_MESSAGE = 'Test error message'; -jest.mock('~/flash'); +const mockFlashClose = jest.fn(); +jest.mock('~/flash', () => { + const flash = jest.fn().mockImplementation(() => { + return { + close: mockFlashClose, + }; + }); + + return { + createFlash: flash, + deprecatedCreateFlash: flash, + }; +}); describe('Actions Notes Store', () => { let commit; @@ -254,42 +266,144 @@ describe('Actions Notes Store', () => { }); describe('poll', () => { - beforeEach((done) => { - axiosMock - .onGet(notesDataMock.notesPath) - .reply(200, { notes: [], last_fetched_at: '123456' }, { 'poll-interval': '1000' }); + const pollInterval = 6000; + const pollResponse = { notes: [], last_fetched_at: '123456' }; + const pollHeaders = { 'poll-interval': `${pollInterval}` }; + const successMock = () => + axiosMock.onGet(notesDataMock.notesPath).reply(200, pollResponse, pollHeaders); + const failureMock = () => axiosMock.onGet(notesDataMock.notesPath).reply(500); + const advanceAndRAF = async (time) => { + if (time) { + jest.advanceTimersByTime(time); + } + + return new Promise((resolve) => requestAnimationFrame(resolve)); + }; + const advanceXMoreIntervals = async (number) => { + const timeoutLength = pollInterval * number; + return advanceAndRAF(timeoutLength); + }; + const startPolling = async () => { + await store.dispatch('poll'); + await advanceAndRAF(2); + }; + const cleanUp = async () => { + jest.clearAllTimers(); + + return store.dispatch('stopPolling'); + }; + + beforeEach((done) => { store.dispatch('setNotesData', notesDataMock).then(done).catch(done.fail); }); - it('calls service with last fetched state', (done) => { - store - .dispatch('poll') - .then(() => { - jest.advanceTimersByTime(2); - }) - .then(() => new Promise((resolve) => requestAnimationFrame(resolve))) - .then(() => { - expect(store.state.lastFetchedAt).toBe('123456'); - - jest.advanceTimersByTime(1500); - }) - .then( - () => - new Promise((resolve) => { - requestAnimationFrame(resolve); - }), - ) - .then(() => { - const expectedGetRequests = 2; - expect(axiosMock.history.get.length).toBe(expectedGetRequests); - expect(axiosMock.history.get[expectedGetRequests - 1].headers).toMatchObject({ - 'X-Last-Fetched-At': '123456', - }); - }) - .then(() => store.dispatch('stopPolling')) - .then(done) - .catch(done.fail); + afterEach(() => { + return cleanUp(); + }); + + it('calls service with last fetched state', async () => { + successMock(); + + await startPolling(); + + expect(store.state.lastFetchedAt).toBe('123456'); + + await advanceXMoreIntervals(1); + + expect(axiosMock.history.get).toHaveLength(2); + expect(axiosMock.history.get[1].headers).toMatchObject({ + 'X-Last-Fetched-At': '123456', + }); + }); + + describe('polling side effects', () => { + it('retries twice', async () => { + failureMock(); + + await startPolling(); + + // This is the first request, not a retry + expect(axiosMock.history.get).toHaveLength(1); + + await advanceXMoreIntervals(1); + + // Retry #1 + expect(axiosMock.history.get).toHaveLength(2); + + await advanceXMoreIntervals(1); + + // Retry #2 + expect(axiosMock.history.get).toHaveLength(3); + + await advanceXMoreIntervals(10); + + // There are no more retries + expect(axiosMock.history.get).toHaveLength(3); + }); + + it('shows the error display on the second failure', async () => { + failureMock(); + + await startPolling(); + + expect(axiosMock.history.get).toHaveLength(1); + expect(Flash).not.toHaveBeenCalled(); + + await advanceXMoreIntervals(1); + + expect(axiosMock.history.get).toHaveLength(2); + expect(Flash).toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledTimes(1); + }); + + it('resets the failure counter on success', async () => { + // We can't get access to the actual counter in the polling closure. + // So we can infer that it's reset by ensuring that the error is only + // shown when we cause two failures in a row - no successes between + + axiosMock + .onGet(notesDataMock.notesPath) + .replyOnce(500) // cause one error + .onGet(notesDataMock.notesPath) + .replyOnce(200, pollResponse, pollHeaders) // then a success + .onGet(notesDataMock.notesPath) + .reply(500); // and then more errors + + await startPolling(); // Failure #1 + await advanceXMoreIntervals(1); // Success #1 + await advanceXMoreIntervals(1); // Failure #2 + + // That was the first failure AFTER a success, so we should NOT see the error displayed + expect(Flash).not.toHaveBeenCalled(); + + // Now we'll allow another failure + await advanceXMoreIntervals(1); // Failure #3 + + // Since this is the second failure in a row, the error should happen + expect(Flash).toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledTimes(1); + }); + + it('hides the error display if it exists on success', async () => { + jest.mock(); + failureMock(); + + await startPolling(); + await advanceXMoreIntervals(2); + + // After two errors, the error should be displayed + expect(Flash).toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledTimes(1); + + axiosMock.reset(); + successMock(); + + await advanceXMoreIntervals(1); + + expect(mockFlashClose).toHaveBeenCalled(); + expect(mockFlashClose).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js index 272e9b71f67..5eecfd395e2 100644 --- a/spec/frontend/operation_settings/components/metrics_settings_spec.js +++ b/spec/frontend/operation_settings/components/metrics_settings_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlLink, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { timezones } from '~/monitoring/format_date'; @@ -56,7 +56,7 @@ describe('operation settings external dashboard component', () => { it('renders header text', () => { mountComponent(); - expect(wrapper.find('.js-section-header').text()).toBe('Metrics dashboard'); + expect(wrapper.find('.js-section-header').text()).toBe('Metrics'); }); describe('expand/collapse button', () => { @@ -77,13 +77,13 @@ describe('operation settings external dashboard component', () => { }); it('renders descriptive text', () => { - expect(subHeader.text()).toContain('Manage Metrics Dashboard settings.'); + expect(subHeader.text()).toContain('Manage metrics dashboard settings.'); }); it('renders help page link', () => { const link = subHeader.find(GlLink); - expect(link.text()).toBe('Learn more'); + expect(link.text()).toBe('Learn more.'); expect(link.attributes().href).toBe(helpPage); }); }); @@ -203,10 +203,10 @@ describe('operation settings external dashboard component', () => { .$nextTick() .then(jest.runAllTicks) .then(() => - expect(createFlash).toHaveBeenCalledWith( - `There was an error saving your changes. ${message}`, - 'alert', - ), + expect(createFlash).toHaveBeenCalledWith({ + message: `There was an error saving your changes. ${message}`, + type: 'alert', + }), ); }); }); diff --git a/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap new file mode 100644 index 00000000000..881d441e116 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileSha renders 1`] = ` +<div + class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-word-break-all gl-py-2 gl-border-b-solid gl-border-gray-100 gl-border-b-1" +> + <!----> + + <span> + <div + class="gl-px-4" + > + + bar: + foo + + <gl-button-stub + aria-label="Copy this value" + buttontextclasses="" + category="tertiary" + data-clipboard-text="foo" + icon="copy-to-clipboard" + size="small" + title="Copy SHA" + variant="default" + /> + </div> + </span> +</div> +`; diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js index 11dad7ba34d..3132ec61942 100644 --- a/spec/frontend/packages/details/components/app_spec.js +++ b/spec/frontend/packages/details/components/app_spec.js @@ -1,5 +1,6 @@ -import { GlEmptyState, GlModal } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import stubChildren from 'helpers/stub_children'; @@ -34,6 +35,7 @@ describe('PackagesApp', () => { let store; const fetchPackageVersions = jest.fn(); const deletePackage = jest.fn(); + const deletePackageFile = jest.fn(); const defaultProjectName = 'bar'; const { location } = window; @@ -59,6 +61,7 @@ describe('PackagesApp', () => { actions: { deletePackage, fetchPackageVersions, + deletePackageFile, }, getters, }); @@ -82,8 +85,8 @@ describe('PackagesApp', () => { const packageTitle = () => wrapper.find(PackageTitle); const emptyState = () => wrapper.find(GlEmptyState); const deleteButton = () => wrapper.find('.js-delete-button'); - const deleteModal = () => wrapper.find(GlModal); - const modalDeleteButton = () => wrapper.find({ ref: 'modal-delete-button' }); + const findDeleteModal = () => wrapper.find({ ref: 'deleteModal' }); + const findDeleteFileModal = () => wrapper.find({ ref: 'deleteFileModal' }); const versionsTab = () => wrapper.find('.js-versions-tab > a'); const packagesLoader = () => wrapper.find(PackagesListLoader); const packagesVersionRows = () => wrapper.findAll(PackageListRow); @@ -107,10 +110,12 @@ describe('PackagesApp', () => { window.location = location; }); - it('renders the app and displays the package title', () => { + it('renders the app and displays the package title', async () => { createComponent(); - expect(packageTitle()).toExist(); + await nextTick(); + + expect(packageTitle().exists()).toBe(true); }); it('renders an empty state component when no an invalid package is passed as a prop', () => { @@ -118,7 +123,7 @@ describe('PackagesApp', () => { packageEntity: {}, }); - expect(emptyState()).toExist(); + expect(emptyState().exists()).toBe(true); }); it('package history has the right props', () => { @@ -152,7 +157,16 @@ describe('PackagesApp', () => { }); it('shows the delete confirmation modal when delete is clicked', () => { - expect(deleteModal()).toExist(); + expect(findDeleteModal().exists()).toBe(true); + }); + }); + + describe('deleting package files', () => { + it('shows the delete confirmation modal when delete is clicked', () => { + createComponent(); + findPackageFiles().vm.$emit('delete-file', mavenFiles[0]); + + expect(findDeleteFileModal().exists()).toBe(true); }); }); @@ -228,13 +242,7 @@ describe('PackagesApp', () => { }); describe('tracking and delete', () => { - const doDelete = async () => { - deleteButton().trigger('click'); - await wrapper.vm.$nextTick(); - modalDeleteButton().trigger('click'); - }; - - describe('delete', () => { + describe('delete package', () => { const originalReferrer = document.referrer; const setReferrer = (value = defaultProjectName) => { Object.defineProperty(document, 'referrer', { @@ -250,9 +258,9 @@ describe('PackagesApp', () => { }); }); - it('calls the proper vuex action', async () => { + it('calls the proper vuex action', () => { createComponent({ packageEntity: npmPackage }); - await doDelete(); + findDeleteModal().vm.$emit('primary'); expect(deletePackage).toHaveBeenCalled(); }); @@ -260,7 +268,7 @@ describe('PackagesApp', () => { setReferrer(); deletePackage.mockResolvedValue(); createComponent({ packageEntity: npmPackage }); - await doDelete(); + findDeleteModal().vm.$emit('primary'); await deletePackage(); expect(window.location.replace).toHaveBeenCalledWith( 'project_url?showSuccessDeleteAlert=true', @@ -271,7 +279,7 @@ describe('PackagesApp', () => { setReferrer('baz'); deletePackage.mockResolvedValue(); createComponent({ packageEntity: npmPackage }); - await doDelete(); + findDeleteModal().vm.$emit('primary'); await deletePackage(); expect(window.location.replace).toHaveBeenCalledWith( 'group_url?showSuccessDeleteAlert=true', @@ -279,6 +287,17 @@ describe('PackagesApp', () => { }); }); + describe('delete file', () => { + it('calls the proper vuex action', () => { + createComponent({ packageEntity: npmPackage }); + + findPackageFiles().vm.$emit('delete-file', mavenFiles[0]); + findDeleteFileModal().vm.$emit('primary'); + + expect(deletePackageFile).toHaveBeenCalled(); + }); + }); + describe('tracking', () => { let eventSpy; let utilSpy; @@ -295,9 +314,9 @@ describe('PackagesApp', () => { expect(utilSpy).toHaveBeenCalledWith('conan'); }); - it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, async () => { + it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => { createComponent({ packageEntity: npmPackage }); - await doDelete(); + findDeleteModal().vm.$emit('primary'); expect(eventSpy).toHaveBeenCalledWith( category, TrackingActions.DELETE_PACKAGE, @@ -305,6 +324,56 @@ describe('PackagesApp', () => { ); }); + it(`canceling a package deletion tracks ${TrackingActions.CANCEL_DELETE_PACKAGE}`, () => { + createComponent({ packageEntity: npmPackage }); + + findDeleteModal().vm.$emit('canceled'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.CANCEL_DELETE_PACKAGE, + expect.any(Object), + ); + }); + + it(`request a file deletion tracks ${TrackingActions.REQUEST_DELETE_PACKAGE_FILE}`, () => { + createComponent({ packageEntity: npmPackage }); + + findPackageFiles().vm.$emit('delete-file', mavenFiles[0]); + + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.REQUEST_DELETE_PACKAGE_FILE, + expect.any(Object), + ); + }); + + it(`confirming a file deletion tracks ${TrackingActions.DELETE_PACKAGE_FILE}`, () => { + createComponent({ packageEntity: npmPackage }); + + findPackageFiles().vm.$emit('delete-file', npmPackage); + findDeleteFileModal().vm.$emit('primary'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.REQUEST_DELETE_PACKAGE_FILE, + expect.any(Object), + ); + }); + + it(`canceling a file deletion tracks ${TrackingActions.CANCEL_DELETE_PACKAGE_FILE}`, () => { + createComponent({ packageEntity: npmPackage }); + + findPackageFiles().vm.$emit('delete-file', npmPackage); + findDeleteFileModal().vm.$emit('canceled'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.CANCEL_DELETE_PACKAGE_FILE, + expect.any(Object), + ); + }); + it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { createComponent({ packageEntity: conanPackage }); diff --git a/spec/frontend/packages/details/components/file_sha_spec.js b/spec/frontend/packages/details/components/file_sha_spec.js new file mode 100644 index 00000000000..7bfcf78baab --- /dev/null +++ b/spec/frontend/packages/details/components/file_sha_spec.js @@ -0,0 +1,33 @@ +import { shallowMount } from '@vue/test-utils'; + +import FileSha from '~/packages/details/components/file_sha.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +describe('FileSha', () => { + let wrapper; + + const defaultProps = { sha: 'foo', title: 'bar' }; + + function createComponent() { + wrapper = shallowMount(FileSha, { + propsData: { + ...defaultProps, + }, + stubs: { + ClipboardButton, + DetailsRow, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages/details/components/installations_commands_spec.js b/spec/frontend/packages/details/components/installations_commands_spec.js index 065bf503585..164f9f69741 100644 --- a/spec/frontend/packages/details/components/installations_commands_spec.js +++ b/spec/frontend/packages/details/components/installations_commands_spec.js @@ -7,6 +7,7 @@ import MavenInstallation from '~/packages/details/components/maven_installation. import NpmInstallation from '~/packages/details/components/npm_installation.vue'; import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; import { conanPackage, @@ -15,6 +16,7 @@ import { nugetPackage, pypiPackage, composerPackage, + terraformModule, } from '../../mock_data'; describe('InstallationCommands', () => { @@ -32,6 +34,7 @@ describe('InstallationCommands', () => { const nugetInstallation = () => wrapper.find(NugetInstallation); const pypiInstallation = () => wrapper.find(PypiInstallation); const composerInstallation = () => wrapper.find(ComposerInstallation); + const terraformInstallation = () => wrapper.findComponent(TerraformInstallation); afterEach(() => { wrapper.destroy(); @@ -46,6 +49,7 @@ describe('InstallationCommands', () => { ${nugetPackage} | ${nugetInstallation} ${pypiPackage} | ${pypiInstallation} ${composerPackage} | ${composerInstallation} + ${terraformModule} | ${terraformInstallation} `('renders', ({ packageEntity, selector }) => { it(`${packageEntity.package_type} instructions exist`, () => { createComponent({ packageEntity }); diff --git a/spec/frontend/packages/details/components/package_files_spec.js b/spec/frontend/packages/details/components/package_files_spec.js index bcf1b6d56f0..e8e5a24d3a3 100644 --- a/spec/frontend/packages/details/components/package_files_spec.js +++ b/spec/frontend/packages/details/components/package_files_spec.js @@ -1,4 +1,6 @@ +import { GlDropdown, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue/'; import stubChildren from 'helpers/stub_children'; import component from '~/packages/details/components/package_files.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -12,16 +14,21 @@ describe('Package Files', () => { const findAllRows = () => wrapper.findAll('[data-testid="file-row"'); const findFirstRow = () => findAllRows().at(0); const findSecondRow = () => findAllRows().at(1); - const findFirstRowDownloadLink = () => findFirstRow().find('[data-testid="download-link"'); - const findFirstRowCommitLink = () => findFirstRow().find('[data-testid="commit-link"'); - const findSecondRowCommitLink = () => findSecondRow().find('[data-testid="commit-link"'); + const findFirstRowDownloadLink = () => findFirstRow().find('[data-testid="download-link"]'); + const findFirstRowCommitLink = () => findFirstRow().find('[data-testid="commit-link"]'); + const findSecondRowCommitLink = () => findSecondRow().find('[data-testid="commit-link"]'); const findFirstRowFileIcon = () => findFirstRow().find(FileIcon); const findFirstRowCreatedAt = () => findFirstRow().find(TimeAgoTooltip); + const findFirstActionMenu = () => findFirstRow().findComponent(GlDropdown); + const findActionMenuDelete = () => findFirstActionMenu().find('[data-testid="delete-file"]'); + const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton); + const findFirstRowShaComponent = (id) => wrapper.find(`[data-testid="${id}"]`); - const createComponent = (packageFiles = npmFiles) => { + const createComponent = ({ packageFiles = npmFiles, canDelete = true } = {}) => { wrapper = mount(component, { propsData: { packageFiles, + canDelete, }, stubs: { ...stubChildren(component), @@ -43,7 +50,7 @@ describe('Package Files', () => { }); it('renders multiple files for a package that contains more than one file', () => { - createComponent(mavenFiles); + createComponent({ packageFiles: mavenFiles }); expect(findAllRows()).toHaveLength(2); }); @@ -123,7 +130,7 @@ describe('Package Files', () => { }); describe('when package file has no pipeline associated', () => { it('does not exist', () => { - createComponent(mavenFiles); + createComponent({ packageFiles: mavenFiles }); expect(findFirstRowCommitLink().exists()).toBe(false); }); @@ -131,11 +138,122 @@ describe('Package Files', () => { describe('when only one file lacks an associated pipeline', () => { it('renders the commit when it exists and not otherwise', () => { - createComponent([npmFiles[0], mavenFiles[0]]); + createComponent({ packageFiles: [npmFiles[0], mavenFiles[0]] }); expect(findFirstRowCommitLink().exists()).toBe(true); expect(findSecondRowCommitLink().exists()).toBe(false); }); }); + + describe('action menu', () => { + describe('when the user can delete', () => { + it('exists', () => { + createComponent(); + + expect(findFirstActionMenu().exists()).toBe(true); + }); + + describe('menu items', () => { + describe('delete file', () => { + it('exists', () => { + createComponent(); + + expect(findActionMenuDelete().exists()).toBe(true); + }); + + it('emits a delete event when clicked', () => { + createComponent(); + + findActionMenuDelete().vm.$emit('click'); + + const [[{ id }]] = wrapper.emitted('delete-file'); + expect(id).toBe(npmFiles[0].id); + }); + }); + }); + }); + + describe('when the user can not delete', () => { + const canDelete = false; + + it('does not exist', () => { + createComponent({ canDelete }); + + expect(findFirstActionMenu().exists()).toBe(false); + }); + }); + }); + }); + + describe('additional details', () => { + describe('details toggle button', () => { + it('exists', () => { + createComponent(); + + expect(findFirstToggleDetailsButton().exists()).toBe(true); + }); + + it('is hidden when no details is present', () => { + const [{ ...noShaFile }] = npmFiles; + noShaFile.file_sha256 = null; + noShaFile.file_md5 = null; + noShaFile.file_sha1 = null; + createComponent({ packageFiles: [noShaFile] }); + + expect(findFirstToggleDetailsButton().exists()).toBe(false); + }); + + it('toggles the details row', async () => { + createComponent(); + + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + + findFirstToggleDetailsButton().vm.$emit('click'); + await nextTick(); + + expect(findFirstRowShaComponent('sha-256').exists()).toBe(true); + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-up'); + + findFirstToggleDetailsButton().vm.$emit('click'); + await nextTick(); + + expect(findFirstRowShaComponent('sha-256').exists()).toBe(false); + expect(findFirstToggleDetailsButton().props('icon')).toBe('angle-down'); + }); + }); + + describe('file shas', () => { + const showShaFiles = () => { + findFirstToggleDetailsButton().vm.$emit('click'); + return nextTick(); + }; + + it.each` + selector | title | sha + ${'sha-256'} | ${'SHA-256'} | ${'file_sha256'} + ${'md5'} | ${'MD5'} | ${'file_md5'} + ${'sha-1'} | ${'SHA-1'} | ${'file_sha1'} + `('has a $title row', async ({ selector, title, sha }) => { + createComponent(); + + await showShaFiles(); + + expect(findFirstRowShaComponent(selector).props()).toMatchObject({ + title, + sha, + }); + }); + + it('does not display a row when the data is missing', async () => { + const [{ ...missingMd5 }] = npmFiles; + missingMd5.file_md5 = null; + + createComponent({ packageFiles: [missingMd5] }); + + await showShaFiles(); + + expect(findFirstRowShaComponent('md5').exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages/details/store/actions_spec.js index d11ee548b72..b16e50debc4 100644 --- a/spec/frontend/packages/details/store/actions_spec.js +++ b/spec/frontend/packages/details/store/actions_spec.js @@ -1,10 +1,18 @@ import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants'; -import { fetchPackageVersions, deletePackage } from '~/packages/details/store/actions'; +import { + fetchPackageVersions, + deletePackage, + deletePackageFile, +} from '~/packages/details/store/actions'; import * as types from '~/packages/details/store/mutation_types'; -import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; +import { + DELETE_PACKAGE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, +} from '~/packages/shared/constants'; import { npmPackage as packageEntity } from '../../mock_data'; jest.mock('~/flash.js'); @@ -74,7 +82,10 @@ describe('Actions Package details store', () => { packageEntity.project_id, packageEntity.id, ); - expect(createFlash).toHaveBeenCalledWith(FETCH_PACKAGE_VERSIONS_ERROR); + expect(createFlash).toHaveBeenCalledWith({ + message: FETCH_PACKAGE_VERSIONS_ERROR, + type: 'warning', + }); done(); }, ); @@ -96,7 +107,48 @@ describe('Actions Package details store', () => { Api.deleteProjectPackage = jest.fn().mockRejectedValue(); testAction(deletePackage, undefined, { packageEntity }, [], [], () => { - expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + type: 'warning', + }); + done(); + }); + }); + }); + + describe('deletePackageFile', () => { + const fileId = 'a_file_id'; + + it('should call Api.deleteProjectPackageFile and commit the right data', (done) => { + const packageFiles = [{ id: 'foo' }, { id: fileId }]; + Api.deleteProjectPackageFile = jest.fn().mockResolvedValue(); + testAction( + deletePackageFile, + fileId, + { packageEntity, packageFiles }, + [{ type: types.UPDATE_PACKAGE_FILES, payload: [{ id: 'foo' }] }], + [], + () => { + expect(Api.deleteProjectPackageFile).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + fileId, + ); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + type: 'success', + }); + done(); + }, + ); + }); + it('should create flash on API error', (done) => { + Api.deleteProjectPackageFile = jest.fn().mockRejectedValue(); + testAction(deletePackageFile, fileId, { packageEntity }, [], [], () => { + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + type: 'warning', + }); done(); }); }); diff --git a/spec/frontend/packages/details/store/mutations_spec.js b/spec/frontend/packages/details/store/mutations_spec.js index 6bc5fb7241f..296ed02d786 100644 --- a/spec/frontend/packages/details/store/mutations_spec.js +++ b/spec/frontend/packages/details/store/mutations_spec.js @@ -28,4 +28,13 @@ describe('Mutations package details Store', () => { expect(mockState.packageEntity.versions).toEqual(fakeVersions); }); }); + describe('UPDATE_PACKAGE_FILES', () => { + it('should update the packageFiles', () => { + const files = [1, 2, 3]; + + mutations[types.UPDATE_PACKAGE_FILES](mockState, files); + + expect(mockState.packageFiles).toEqual(files); + }); + }); }); diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js index 52966c1be5e..adccb7436e1 100644 --- a/spec/frontend/packages/list/stores/actions_spec.js +++ b/spec/frontend/packages/list/stores/actions_spec.js @@ -2,7 +2,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { MISSING_DELETE_PATH_ERROR } from '~/packages/list/constants'; import * as actions from '~/packages/list/stores/actions'; import * as types from '~/packages/list/stores/mutation_types'; @@ -241,7 +241,9 @@ describe('Actions Package list store', () => { `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => { testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => { expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR)); - expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE); + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + }); done(); }); }); diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js index 06009daba54..33b47cca68b 100644 --- a/spec/frontend/packages/mock_data.js +++ b/spec/frontend/packages/mock_data.js @@ -79,6 +79,9 @@ export const npmFiles = [ pipelines: [ { id: 1, project: { commit_url: 'http://foo.bar' }, git_commit_message: 'foo bar baz?' }, ], + file_sha256: 'file_sha256', + file_md5: 'file_md5', + file_sha1: 'file_sha1', }, ]; @@ -175,6 +178,20 @@ export const composerPackage = { version: '1.0.0', }; +export const terraformModule = { + created_at: '2015-12-10', + id: 2, + name: 'Test/system-22', + package_type: 'terraform_module', + project_path: 'foo/bar/baz', + projectPathName: 'foo/bar/baz', + project_id: 1, + updated_at: '2015-12-10', + version: '0.1', + versions: [], + _links, +}; + export const mockTags = [ { name: 'foo-1', diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap index f4e617ecafe..b576f1b2553 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -11,7 +11,7 @@ exports[`packages_list_row renders 1`] = ` <!----> <div - class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-fill-1" + class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" > <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1" @@ -42,7 +42,7 @@ exports[`packages_list_row renders 1`] = ` </div> <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-fill-1" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1" > <div class="gl-display-flex" diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/__snapshots__/terraform_installation_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/__snapshots__/terraform_installation_spec.js.snap new file mode 100644 index 00000000000..427160b45e3 --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/__snapshots__/terraform_installation_spec.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TerraformInstallation renders all the messages 1`] = ` +<div> + <h3 + class="gl-font-lg" + > + Provision instructions + </h3> + + <code-instruction-stub + copytext="Copy Terraform Command" + instruction="module \\"Test/system-22\\" { + source = \\"foo/Test/system-22\\" + version = \\"0.1\\" +}" + label="Copy and paste into your Terraform configuration, insert the variables, and run Terraform init:" + multiline="true" + trackingaction="" + trackinglabel="" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <code-instruction-stub + copytext="Copy Terraform Setup Command" + instruction="credentials \\"gitlab.com\\" { + token = \\"<TOKEN>\\" +}" + label="To authorize access to the Terraform registry:" + multiline="true" + trackingaction="" + trackinglabel="" + /> + + <gl-sprintf-stub + message="For more information on the Terraform registry, %{linkStart}see our documentation%{linkEnd}." + /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js new file mode 100644 index 00000000000..87e0059344c --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js @@ -0,0 +1,93 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { terraformModule, mavenFiles, npmPackage } from 'jest/packages/mock_data'; +import component from '~/packages_and_registries/infrastructure_registry/components/details_title.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PackageTitle', () => { + let wrapper; + let store; + + function createComponent({ packageFiles = mavenFiles, packageEntity = terraformModule } = {}) { + store = new Vuex.Store({ + state: { + packageEntity, + packageFiles, + }, + getters: { + packagePipeline: ({ packageEntity: { pipeline = null } }) => pipeline, + }, + }); + + wrapper = shallowMount(component, { + localVue, + store, + stubs: { + TitleArea, + }, + }); + return wrapper.vm.$nextTick(); + } + + const findTitleArea = () => wrapper.findComponent(TitleArea); + const packageSize = () => wrapper.find('[data-testid="package-size"]'); + const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]'); + const packageRef = () => wrapper.find('[data-testid="package-ref"]'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('module title', () => { + it('is correctly bound', async () => { + await createComponent(); + + expect(findTitleArea().props('title')).toBe(terraformModule.name); + }); + }); + + describe('calculates the package size', () => { + it('correctly calculates the size', async () => { + await createComponent(); + + expect(packageSize().props('text')).toBe('300 bytes'); + }); + }); + + describe('package ref', () => { + it('does not display the ref if missing', async () => { + await createComponent(); + + expect(packageRef().exists()).toBe(false); + }); + + it('correctly shows the package ref if there is one', async () => { + await createComponent({ packageEntity: npmPackage }); + expect(packageRef().props()).toMatchObject({ + text: npmPackage.pipeline.ref, + icon: 'branch', + }); + }); + }); + + describe('pipeline project', () => { + it('does not display the project if missing', async () => { + await createComponent(); + + expect(pipelineProject().exists()).toBe(false); + }); + + it('correctly shows the pipeline project if there is one', async () => { + await createComponent({ packageEntity: npmPackage }); + + expect(pipelineProject().props()).toMatchObject({ + text: npmPackage.pipeline.project.name, + icon: 'review-list', + link: npmPackage.pipeline.project.web_url, + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js new file mode 100644 index 00000000000..7a129794d54 --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js @@ -0,0 +1,61 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { terraformModule as packageEntity } from 'jest/packages/mock_data'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('TerraformInstallation', () => { + let wrapper; + + const store = new Vuex.Store({ + state: { + packageEntity, + projectPath: 'foo', + }, + }); + + const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); + + function createComponent() { + wrapper = shallowMount(TerraformInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct command', () => { + expect(findCodeInstructions().at(0).props('instruction')).toMatchInlineSnapshot(` + "module \\"Test/system-22\\" { + source = \\"foo/Test/system-22\\" + version = \\"0.1\\" + }" + `); + }); + }); + + describe('setup commands', () => { + it('renders the correct command', () => { + expect(findCodeInstructions().at(1).props('instruction')).toMatchInlineSnapshot(` + "credentials \\"gitlab.com\\" { + token = \\"<TOKEN>\\" + }" + `); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index a725941f7f6..8266f9bee89 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -10,6 +10,8 @@ import { UNAVAILABLE_USER_FEATURE_TEXT, } from '~/packages_and_registries/settings/project/constants'; import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; +import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import { expirationPolicyPayload, @@ -28,15 +30,19 @@ describe('Registry Settings App', () => { isAdmin: false, adminSettingsPath: 'settingsPath', enableHistoricEntries: false, + helpPagePath: 'helpPagePath', + showCleanupPolicyOnAlert: false, }; const findSettingsComponent = () => wrapper.find(SettingsForm); const findAlert = () => wrapper.find(GlAlert); + const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); const mountComponent = (provide = defaultProvidedValues, config) => { wrapper = shallowMount(component, { stubs: { GlSprintf, + SettingsBlock, }, mocks: { $toast: { @@ -66,6 +72,26 @@ describe('Registry Settings App', () => { wrapper.destroy(); }); + describe('cleanup is on alert', () => { + it('exist when showCleanupPolicyOnAlert is true and has the correct props', () => { + mountComponent({ + ...defaultProvidedValues, + showCleanupPolicyOnAlert: true, + }); + + expect(findCleanupAlert().exists()).toBe(true); + expect(findCleanupAlert().props()).toMatchObject({ + projectPath: 'path', + }); + }); + + it('is hidden when showCleanupPolicyOnAlert is false', async () => { + mountComponent(); + + expect(findCleanupAlert().exists()).toBe(false); + }); + }); + describe('isEdited status', () => { it.each` description | apiResponse | workingCopy | result diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap new file mode 100644 index 00000000000..2cded2ead2e --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CleanupPolicyEnabledAlert renders 1`] = ` +<gl-alert-stub + class="gl-mt-2" + dismissible="true" + dismisslabel="Dismiss" + primarybuttonlink="" + primarybuttontext="" + secondarybuttonlink="" + secondarybuttontext="" + title="" + variant="info" +> + <gl-sprintf-stub + message="Cleanup policies are now available for this project. %{linkStart}Click here to get started.%{linkEnd}" + /> +</gl-alert-stub> +`; diff --git a/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js b/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js new file mode 100644 index 00000000000..269e087f5ac --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js @@ -0,0 +1,49 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import component from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +describe('CleanupPolicyEnabledAlert', () => { + let wrapper; + + const defaultProps = { + projectPath: 'foo', + cleanupPoliciesSettingsPath: 'label-bar', + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + const mountComponent = (props) => { + wrapper = shallowMount(component, { + stubs: { + LocalStorageSync, + }, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('when dismissed is not visible', async () => { + mountComponent(); + + expect(findAlert().exists()).toBe(true); + findAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findAlert().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js index d22e0474e06..4280a78c202 100644 --- a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; -import * as flash from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import PromoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; @@ -103,7 +103,7 @@ describe('Promote milestone modal', () => { wrapper.findComponent(GlModal).vm.$emit('primary'); await waitForPromises(); - expect(flash.deprecatedCreateFlash).toHaveBeenCalledWith(dummyError); + expect(createFlash).toHaveBeenCalledWith({ message: dummyError }); }); }); }); diff --git a/spec/frontend/pages/projects/forks/new/components/app_spec.js b/spec/frontend/pages/projects/forks/new/components/app_spec.js index e1820606704..a7b4b9c42bd 100644 --- a/spec/frontend/pages/projects/forks/new/components/app_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/app_spec.js @@ -13,6 +13,7 @@ describe('App component', () => { projectPath: 'project-name', projectDescription: 'some project description', projectVisibility: 'private', + restrictedVisibilityLevels: [], }; const createComponent = (props = {}) => { diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index 6d853120232..c80ccfa8256 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -1,4 +1,5 @@ -import { GlFormInputGroup, GlFormInput, GlForm } from '@gitlab/ui'; +import { GlFormInputGroup, GlFormInput, GlForm, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +import { getByRole, getAllByRole } from '@testing-library/dom'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; @@ -15,6 +16,13 @@ describe('ForkForm component', () => { let wrapper; let axiosMock; + const PROJECT_VISIBILITY_TYPE = { + private: + 'Private Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', + internal: 'Internal The project can be accessed by any logged in user.', + public: 'Public The project can be accessed without any authentication.', + }; + const GON_GITLAB_URL = 'https://gitlab.com'; const GON_API_VERSION = 'v7'; @@ -37,6 +45,7 @@ describe('ForkForm component', () => { projectPath: 'project-name', projectDescription: 'some project description', projectVisibility: 'private', + restrictedVisibilityLevels: [], }; const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { @@ -61,6 +70,8 @@ describe('ForkForm component', () => { stubs: { GlFormInputGroup, GlFormInput, + GlFormRadioGroup, + GlFormRadio, }, }); }; @@ -81,6 +92,7 @@ describe('ForkForm component', () => { axiosMock.restore(); }); + const findFormSelectOptions = () => wrapper.find('select[name="namespace"]').findAll('option'); const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]'); const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]'); const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]'); @@ -203,24 +215,145 @@ describe('ForkForm component', () => { }); describe('visibility level', () => { + it('displays the correct description', () => { + mockGetRequest(); + createComponent(); + + const formRadios = wrapper.findAll(GlFormRadio); + + Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibilityType, index) => { + expect(formRadios.at(index).text()).toBe(PROJECT_VISIBILITY_TYPE[visibilityType]); + }); + }); + + it('displays all 3 visibility levels', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.findAll(GlFormRadio)).toHaveLength(3); + }); + + describe('when the namespace is changed', () => { + const namespaces = [ + { + visibility: 'private', + }, + { + visibility: 'internal', + }, + { + visibility: 'public', + }, + ]; + + beforeEach(() => { + mockGetRequest(); + }); + + it('resets the visibility to default "private"', async () => { + createFullComponent({ projectVisibility: 'public' }, { namespaces }); + + expect(wrapper.vm.form.fields.visibility.value).toBe('public'); + await findFormSelectOptions().at(1).setSelected(); + + await wrapper.vm.$nextTick(); + + expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true); + }); + + it('sets the visibility to be null when restrictedVisibilityLevels is set', async () => { + createFullComponent({ restrictedVisibilityLevels: [10] }, { namespaces }); + + await findFormSelectOptions().at(1).setSelected(); + + await wrapper.vm.$nextTick(); + + const container = getByRole(wrapper.element, 'radiogroup', { name: /visibility/i }); + const visibilityRadios = getAllByRole(container, 'radio'); + expect(visibilityRadios.filter((e) => e.checked)).toHaveLength(0); + }); + }); + + it.each` + project | restrictedVisibilityLevels + ${'private'} | ${[]} + ${'internal'} | ${[]} + ${'public'} | ${[]} + ${'private'} | ${[0]} + ${'private'} | ${[10]} + ${'private'} | ${[20]} + ${'private'} | ${[0, 10]} + ${'private'} | ${[0, 20]} + ${'private'} | ${[10, 20]} + ${'private'} | ${[0, 10, 20]} + ${'internal'} | ${[0]} + ${'internal'} | ${[10]} + ${'internal'} | ${[20]} + ${'internal'} | ${[0, 10]} + ${'internal'} | ${[0, 20]} + ${'internal'} | ${[10, 20]} + ${'internal'} | ${[0, 10, 20]} + ${'public'} | ${[0]} + ${'public'} | ${[10]} + ${'public'} | ${[0, 10]} + ${'public'} | ${[0, 20]} + ${'public'} | ${[10, 20]} + ${'public'} | ${[0, 10, 20]} + `('checks the correct radio button', async ({ project, restrictedVisibilityLevels }) => { + mockGetRequest(); + createFullComponent({ + projectVisibility: project, + restrictedVisibilityLevels, + }); + + if (restrictedVisibilityLevels.length === 0) { + expect(wrapper.find('[name="visibility"]:checked').attributes('value')).toBe(project); + } else { + expect(wrapper.find('[name="visibility"]:checked').exists()).toBe(false); + } + }); + it.each` - project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled - ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} - ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} - ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} - ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} - ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} - ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} - ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} - ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} - ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined} + project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled | restrictedVisibilityLevels + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} | ${[]} + ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} | ${[]} + ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[]} + ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} | ${[]} + ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined} | ${[]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[0]} + ${'internal'} | ${'internal'} | ${'true'} | ${undefined} | ${'true'} | ${[0]} + ${'public'} | ${'public'} | ${'true'} | ${undefined} | ${undefined} | ${[0]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[10]} + ${'internal'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} | ${[10]} + ${'public'} | ${'public'} | ${undefined} | ${'true'} | ${undefined} | ${[10]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[20]} + ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} | ${[20]} + ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} | ${[20]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[10, 20]} + ${'internal'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} | ${[10, 20]} + ${'public'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[10, 20]} + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]} + ${'internal'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]} + ${'public'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]} `( 'sets appropriate radio button disabled state', - async ({ project, namespace, privateIsDisabled, internalIsDisabled, publicIsDisabled }) => { + async ({ + project, + namespace, + privateIsDisabled, + internalIsDisabled, + publicIsDisabled, + restrictedVisibilityLevels, + }) => { mockGetRequest(); createComponent( { projectVisibility: project, + restrictedVisibilityLevels, }, { form: { fields: { namespace: { value: { visibility: namespace } } } }, @@ -235,7 +368,7 @@ describe('ForkForm component', () => { }); describe('onSubmit', () => { - beforeEach(() => { + const setupComponent = (fields = {}) => { jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); mockGetRequest(); @@ -245,9 +378,14 @@ describe('ForkForm component', () => { namespaces: MOCK_NAMESPACES_RESPONSE, form: { state: true, + ...fields, }, }, ); + }; + + beforeEach(() => { + setupComponent(); }); const selectedMockNamespaceIndex = 1; @@ -279,6 +417,22 @@ describe('ForkForm component', () => { expect(urlUtility.redirectTo).not.toHaveBeenCalled(); }); + + it('does not make POST request if no visbility is checked', async () => { + jest.spyOn(axios, 'post'); + + setupComponent({ + fields: { + visibility: { + value: null, + }, + }, + }); + + await submitForm(); + + expect(axios.post).not.toHaveBeenCalled(); + }); }); describe('with valid form', () => { @@ -330,7 +484,7 @@ describe('ForkForm component', () => { expect(urlUtility.redirectTo).not.toHaveBeenCalled(); expect(createFlash).toHaveBeenCalledWith({ - message: dummyError, + message: 'An error occurred while forking the project. Please try again.', }); }); }); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js index e7ac837a4c8..9f8dbf3d542 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import ForkGroupsList from '~/pages/projects/forks/new/components/fork_groups_list.vue'; import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue'; diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index c4c48ea7517..4ba9120d196 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -66,7 +66,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <gl-area-chart-stub annotations="" data="[object Object]" - formattooltiptext="function () { [native code] }" + formattooltiptext="[Function]" height="200" includelegendavgmax="true" legendaveragetext="Avg" diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap index 350669433f0..59b42de2485 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap @@ -92,6 +92,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` aria-hidden="true" class="gl-icon s16" data-testid="completed-icon" + role="img" > <use href="#check-circle-filled" @@ -114,6 +115,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = ` aria-hidden="true" class="gl-icon s16" data-testid="completed-icon" + role="img" > <use href="#check-circle-filled" diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap index c9d8ab4566c..091edc7505c 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap @@ -81,6 +81,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` aria-hidden="true" class="gl-text-green-500 gl-icon s16" data-testid="completed-icon" + role="img" > <use href="#check-circle-filled" @@ -142,6 +143,7 @@ exports[`Learn GitLab Design B renders correctly 1`] = ` aria-hidden="true" class="gl-text-green-500 gl-icon s16" data-testid="completed-icon" + role="img" > <use href="#check-circle-filled" diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index 27cd0fe34bf..de0d70a07d7 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -1,3 +1,4 @@ +import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; @@ -27,6 +28,7 @@ describe('Interval Pattern Input Component', () => { const findAllLabels = () => wrapper.findAll('label'); const findSelectedRadio = () => wrapper.findAll('input[type="radio"]').wrappers.find((x) => x.element.checked); + const findIcon = () => wrapper.findComponent(GlIcon); const findSelectedRadioKey = () => findSelectedRadio()?.attributes('data-testid'); const selectEveryDayRadio = () => findEveryDayRadio().trigger('click'); const selectEveryWeekRadio = () => findEveryWeekRadio().trigger('click'); @@ -40,6 +42,11 @@ describe('Interval Pattern Input Component', () => { wrapper = mount(IntervalPatternInput, { propsData: { ...props }, + provide: { + glFeatures: { + ciDailyLimitForPipelineSchedules: true, + }, + }, data() { return { randomHour: data?.hour || mockHour, @@ -202,4 +209,24 @@ describe('Interval Pattern Input Component', () => { expect(findSelectedRadioKey()).toBe(customKey); }); }); + + describe('Custom cron syntax quota info', () => { + it('the info message includes 5 minutes', () => { + createWrapper({ dailyLimit: '288' }); + + expect(findIcon().attributes('title')).toContain('5 minutes'); + }); + + it('the info message includes 60 minutes', () => { + createWrapper({ dailyLimit: '24' }); + + expect(findIcon().attributes('title')).toContain('60 minutes'); + }); + + it('the info message icon is not shown when there is no daily limit', () => { + createWrapper(); + + expect(findIcon().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js b/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js new file mode 100644 index 00000000000..2c8eb8e459f --- /dev/null +++ b/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js @@ -0,0 +1,160 @@ +import { setHTMLFixture } from 'helpers/fixtures'; +import { initSidebarTracking } from '~/pages/shared/nav/sidebar_tracking'; + +describe('~/pages/shared/nav/sidebar_tracking.js', () => { + beforeEach(() => { + setHTMLFixture(` + <aside class="nav-sidebar"> + <div class="nav-sidebar-inner-scroll"> + <ul class="sidebar-top-level-items"> + <li data-track-label="project_information_menu" class="home"> + <a aria-label="Project information" class="shortcuts-project-information has-sub-items" href=""> + <span class="nav-icon-container"> + <svg class="s16" data-testid="project-icon"> + <use xlink:href="/assets/icons-1b2dadc4c3d49797908ba67b8f10da5d63dd15d859bde28d66fb60bbb97a4dd5.svg#project"></use> + </svg> + </span> + <span class="nav-item-name">Project information</span> + </a> + <ul class="sidebar-sub-level-items"> + <li class="fly-out-top-item"> + <a aria-label="Project information" href="#"> + <strong class="fly-out-top-item-name">Project information</strong> + </a> + </li> + <li class="divider fly-out-top-item"></li> + <li data-track-label="activity" class=""> + <a aria-label="Activity" class="shortcuts-project-activity" href=#"> + <span>Activity</span> + </a> + </li> + <li data-track-label="labels" class=""> + <a aria-label="Labels" href="#"> + <span>Labels</span> + </a> + </li> + <li data-track-label="members" class=""> + <a aria-label="Members" href="#"> + <span>Members</span> + </a> + </li> + </ul> + </li> + </ul> + </div> + </aside> + `); + + initSidebarTracking(); + }); + + describe('sidebar is not collapsed', () => { + describe('menu is not expanded', () => { + it('sets the proper data tracking attributes when clicking on menu', () => { + const menu = document.querySelector('li[data-track-label="project_information_menu"]'); + const menuLink = menu.querySelector('a'); + + menu.classList.add('is-over', 'is-showing-fly-out'); + menuLink.click(); + + expect(menu.dataset).toMatchObject({ + trackAction: 'click_menu', + trackExtra: JSON.stringify({ + sidebar_display: 'Expanded', + menu_display: 'Fly out', + }), + }); + }); + + it('sets the proper data tracking attributes when clicking on submenu', () => { + const menu = document.querySelector('li[data-track-label="activity"]'); + const menuLink = menu.querySelector('a'); + const submenuList = document.querySelector('ul.sidebar-sub-level-items'); + + submenuList.classList.add('fly-out-list'); + menuLink.click(); + + expect(menu.dataset).toMatchObject({ + trackAction: 'click_menu_item', + trackExtra: JSON.stringify({ + sidebar_display: 'Expanded', + menu_display: 'Fly out', + }), + }); + }); + }); + + describe('menu is expanded', () => { + it('sets the proper data tracking attributes when clicking on menu', () => { + const menu = document.querySelector('li[data-track-label="project_information_menu"]'); + const menuLink = menu.querySelector('a'); + + menu.classList.add('active'); + menuLink.click(); + + expect(menu.dataset).toMatchObject({ + trackAction: 'click_menu', + trackExtra: JSON.stringify({ + sidebar_display: 'Expanded', + menu_display: 'Expanded', + }), + }); + }); + + it('sets the proper data tracking attributes when clicking on submenu', () => { + const menu = document.querySelector('li[data-track-label="activity"]'); + const menuLink = menu.querySelector('a'); + + menu.classList.add('active'); + menuLink.click(); + + expect(menu.dataset).toMatchObject({ + trackAction: 'click_menu_item', + trackExtra: JSON.stringify({ + sidebar_display: 'Expanded', + menu_display: 'Expanded', + }), + }); + }); + }); + }); + + describe('sidebar is collapsed', () => { + beforeEach(() => { + document.querySelector('aside.nav-sidebar').classList.add('js-sidebar-collapsed'); + }); + + it('sets the proper data tracking attributes when clicking on menu', () => { + const menu = document.querySelector('li[data-track-label="project_information_menu"]'); + const menuLink = menu.querySelector('a'); + + menu.classList.add('is-over', 'is-showing-fly-out'); + menuLink.click(); + + expect(menu.dataset).toMatchObject({ + trackAction: 'click_menu', + trackExtra: JSON.stringify({ + sidebar_display: 'Collapsed', + menu_display: 'Fly out', + }), + }); + }); + + it('sets the proper data tracking attributes when clicking on submenu', () => { + const menu = document.querySelector('li[data-track-label="activity"]'); + const menuLink = menu.querySelector('a'); + const submenuList = document.querySelector('ul.sidebar-sub-level-items'); + + submenuList.classList.add('fly-out-list'); + menuLink.click(); + + expect(menu.dataset).toMatchObject({ + trackAction: 'click_menu_item', + trackExtra: JSON.stringify({ + sidebar_display: 'Collapsed', + menu_display: 'Fly out', + }), + }); + }); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 1cac8ef8ee2..f36d6262b5f 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -2,15 +2,23 @@ import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { mockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ContentEditor from '~/content_editor/components/content_editor.vue'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; +import { + WIKI_CONTENT_EDITOR_TRACKING_LABEL, + CONTENT_EDITOR_LOADED_ACTION, + SAVED_USING_CONTENT_EDITOR_ACTION, +} from '~/pages/shared/wikis/constants'; + import MarkdownField from '~/vue_shared/components/markdown/field.vue'; describe('WikiForm', () => { let wrapper; let mock; + let trackingSpy; const findForm = () => wrapper.find('form'); const findTitle = () => wrapper.find('#wiki_title'); @@ -19,9 +27,11 @@ describe('WikiForm', () => { const findMessage = () => wrapper.find('#wiki_message'); const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' }); - const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use new editor' }); + const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use the new editor' }); + const findDismissContentEditorAlertButton = () => + wrapper.findByRole('button', { name: 'Try this later' }); const findSwitchToOldEditorButton = () => - wrapper.findByRole('button', { name: 'Switch to old editor' }); + wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' }); const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'More Information.' }); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); @@ -55,15 +65,12 @@ describe('WikiForm', () => { persisted: true, title: 'My page', - content: 'My page content', + content: ' My page content ', format: 'markdown', path: '/project/path/-/wikis/home', }; - function createWrapper( - persisted = false, - { pageInfo, glFeatures } = { glFeatures: { wikiContentEditor: false } }, - ) { + function createWrapper(persisted = false, { pageInfo } = {}) { wrapper = extendedWrapper( mount( WikiForm, @@ -79,7 +86,6 @@ describe('WikiForm', () => { ...(persisted ? pageInfoPersisted : pageInfoNew), ...pageInfo, }, - glFeatures, }, }, { attachToDocument: true }, @@ -88,6 +94,7 @@ describe('WikiForm', () => { } beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); mock = new MockAdapter(axios); }); @@ -124,6 +131,12 @@ describe('WikiForm', () => { expect(findMessage().element.value).toBe('Update My page'); }); + it('does not trim page content by default', () => { + createWrapper(true); + + expect(findContent().element.value).toBe(' My page content '); + }); + it.each` value | text ${'markdown'} | ${'[Link Title](page-slug)'} @@ -178,10 +191,10 @@ describe('WikiForm', () => { describe('when wiki content is updated', () => { beforeEach(() => { - createWrapper(); + createWrapper(true); const input = findContent(); - input.setValue('Lorem ipsum dolar sit!'); + input.setValue(' Lorem ipsum dolar sit! '); input.element.dispatchEvent(new Event('input')); return wrapper.vm.$nextTick(); @@ -193,13 +206,25 @@ describe('WikiForm', () => { expect(e.preventDefault).toHaveBeenCalledTimes(1); }); - it('when form submitted, unsets before unload warning', async () => { - triggerFormSubmit(); + describe('form submit', () => { + beforeEach(async () => { + triggerFormSubmit(); - await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + }); - const e = dispatchBeforeUnload(); - expect(e.preventDefault).not.toHaveBeenCalled(); + it('when form submitted, unsets before unload warning', async () => { + const e = dispatchBeforeUnload(); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('does not trigger tracking event', async () => { + expect(trackingSpy).not.toHaveBeenCalled(); + }); + + it('does not trim page content', () => { + expect(findContent().element.value).toBe(' Lorem ipsum dolar sit! '); + }); }); }); @@ -251,9 +276,9 @@ describe('WikiForm', () => { ); }); - describe('when feature flag wikiContentEditor is enabled', () => { + describe('wiki content editor', () => { beforeEach(() => { - createWrapper(true, { glFeatures: { wikiContentEditor: true } }); + createWrapper(true); }); it.each` @@ -261,7 +286,7 @@ describe('WikiForm', () => { ${'markdown'} | ${true} ${'rdoc'} | ${false} `( - 'switch to new editor button exists: $buttonExists if format is $format', + 'gl-alert containing "use new editor" button exists: $buttonExists if format is $format', async ({ format, buttonExists }) => { setFormat(format); @@ -271,6 +296,12 @@ describe('WikiForm', () => { }, ); + it('gl-alert containing "use new editor" button is dismissed on clicking dismiss button', async () => { + await findDismissContentEditorAlertButton().trigger('click'); + + expect(findUseNewEditorButton().exists()).toBe(false); + }); + const assertOldEditorIsVisible = () => { expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); @@ -284,7 +315,7 @@ describe('WikiForm', () => { ); }; - it('shows old editor by default', assertOldEditorIsVisible); + it('shows classic editor by default', assertOldEditorIsVisible); describe('switch format to rdoc', () => { beforeEach(async () => { @@ -293,7 +324,7 @@ describe('WikiForm', () => { await wrapper.vm.$nextTick(); }); - it('continues to show the old editor', assertOldEditorIsVisible); + it('continues to show the classic editor', assertOldEditorIsVisible); describe('switch format back to markdown', () => { beforeEach(async () => { @@ -303,7 +334,7 @@ describe('WikiForm', () => { }); it( - 'still shows the old editor and does not automatically switch to the content editor ', + 'still shows the classic editor and does not automatically switch to the content editor ', assertOldEditorIsVisible, ); }); @@ -328,12 +359,12 @@ describe('WikiForm', () => { expect(findSubmitButton().props('disabled')).toBe(true); }); - describe('clicking "switch to old editor"', () => { + describe('clicking "switch to classic editor"', () => { beforeEach(() => { return findSwitchToOldEditorButton().trigger('click'); }); - it('switches to old editor directly without showing a modal', () => { + it('switches to classic editor directly without showing a modal', () => { expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); }); @@ -351,11 +382,12 @@ describe('WikiForm', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); + it('shows a tip to send feedback', () => { + expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor'); + }); + it('shows warnings that the rich text editor is in beta and may not work properly', () => { expect(wrapper.text()).toContain( - "Switching will discard any changes you've made in the new editor.", - ); - expect(wrapper.text()).toContain( "This editor is in beta and may not display the page's contents properly.", ); }); @@ -368,6 +400,15 @@ describe('WikiForm', () => { expect(wrapper.findComponent(ContentEditor).exists()).toBe(true); }); + it('sends tracking event when editor loads', async () => { + // wait for content editor to load + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { + label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, + }); + }); + it('disables the format dropdown', () => { expect(findFormat().element.getAttribute('disabled')).toBeDefined(); }); @@ -400,9 +441,19 @@ describe('WikiForm', () => { }); }); + it('triggers tracking event on form submit', async () => { + triggerFormSubmit(); + + await wrapper.vm.$nextTick(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { + label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, + }); + }); + it('updates content from content editor on form submit', async () => { // old value - expect(findContent().element.value).toBe('My page content'); + expect(findContent().element.value).toBe(' My page content '); // wait for content editor to load await waitForPromises(); @@ -414,7 +465,7 @@ describe('WikiForm', () => { expect(findContent().element.value).toBe('hello **world**'); }); - describe('clicking "switch to old editor"', () => { + describe('clicking "switch to classic editor"', () => { let modal; beforeEach(async () => { @@ -428,7 +479,7 @@ describe('WikiForm', () => { expect(modal.vm.show).toHaveBeenCalled(); }); - describe('confirming "switch to old editor" in the modal', () => { + describe('confirming "switch to classic editor" in the modal', () => { beforeEach(async () => { wrapper.vm.contentEditor.tiptapEditor.commands.setContent( '<p>hello __world__ from content editor</p>', @@ -440,7 +491,7 @@ describe('WikiForm', () => { await wrapper.vm.$nextTick(); }); - it('switches to old editor', () => { + it('switches to classic editor', () => { expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); expect(wrapper.findComponent(MarkdownField).exists()).toBe(true); }); @@ -451,8 +502,8 @@ describe('WikiForm', () => { ); }); - it('the old editor retains its old value and does not use the content from the content editor', () => { - expect(findContent().element.value).toBe('My page content'); + it('the classic editor retains its old value and does not use the content from the content editor', () => { + expect(findContent().element.value).toBe(' My page content '); }); }); }); diff --git a/spec/frontend/pages/users/activity_calendar_spec.js b/spec/frontend/pages/users/activity_calendar_spec.js new file mode 100644 index 00000000000..b33e92e14b2 --- /dev/null +++ b/spec/frontend/pages/users/activity_calendar_spec.js @@ -0,0 +1,16 @@ +import { getLevelFromContributions } from '~/pages/users/activity_calendar'; + +describe('getLevelFromContributions', () => { + it.each([ + [0, 0], + [1, 1], + [9, 1], + [10, 2], + [19, 2], + [20, 3], + [30, 4], + [99, 4], + ])('.getLevelFromContributions(%i, %i)', (count, expected) => { + expect(getLevelFromContributions(count)).toBe(expected); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js index 9e677425807..39081e07e52 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js @@ -118,7 +118,8 @@ describe('Pipeline Editor | Commit section', () => { }); it('calls the mutation with the CREATE action', () => { - expect(mockMutate).toHaveBeenCalledTimes(1); + // the extra calls are for updating client queries (currentBranch and lastCommitBranch) + expect(mockMutate).toHaveBeenCalledTimes(3); expect(mockMutate).toHaveBeenCalledWith({ mutation: commitCreate, update: expect.any(Function), @@ -138,7 +139,7 @@ describe('Pipeline Editor | Commit section', () => { }); it('calls the mutation with the UPDATE action', () => { - expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledTimes(3); expect(mockMutate).toHaveBeenCalledWith({ mutation: commitCreate, update: expect.any(Function), @@ -158,7 +159,7 @@ describe('Pipeline Editor | Commit section', () => { }); it('calls the mutation with the current branch', () => { - expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledTimes(3); expect(mockMutate).toHaveBeenCalledWith({ mutation: commitCreate, update: expect.any(Function), @@ -181,7 +182,7 @@ describe('Pipeline Editor | Commit section', () => { it('a second commit submits the latest sha, keeping the form updated', async () => { await submitCommit(); - expect(mockMutate).toHaveBeenCalledTimes(2); + expect(mockMutate).toHaveBeenCalledTimes(6); expect(mockMutate).toHaveBeenCalledWith({ mutation: commitCreate, update: expect.any(Function), diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js b/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js index 8a4f07c4d88..e435c0dcc08 100644 --- a/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js +++ b/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js @@ -1,13 +1,11 @@ import { getByRole } from '@testing-library/dom'; import { mount } from '@vue/test-utils'; import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; -import PipelineVisualReference from '~/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue'; describe('First pipeline card', () => { let wrapper; const defaultProvide = { - ciExamplesHelpPagePath: '/pipelines/examples', runnerHelpPagePath: '/help/runners', }; @@ -20,9 +18,9 @@ describe('First pipeline card', () => { }; const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name }).href; - const findPipelinesLink = () => getLinkByName(/examples and templates/i); const findRunnersLink = () => getLinkByName(/make sure your instance has runners available/i); - const findVisualReference = () => wrapper.findComponent(PipelineVisualReference); + const findInstructionsList = () => wrapper.find('ol'); + const findAllInstructions = () => findInstructionsList().findAll('li'); beforeEach(() => { createComponent(); @@ -37,11 +35,11 @@ describe('First pipeline card', () => { }); it('renders the content', () => { - expect(findVisualReference().exists()).toBe(true); + expect(findInstructionsList().exists()).toBe(true); + expect(findAllInstructions()).toHaveLength(3); }); - it('renders the links', () => { + it('renders the link', () => { expect(findRunnersLink()).toContain(defaultProvide.runnerHelpPagePath); - expect(findPipelinesLink()).toContain(defaultProvide.ciExamplesHelpPagePath); }); }); diff --git a/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js b/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js deleted file mode 100644 index e4834544484..00000000000 --- a/spec/frontend/pipeline_editor/components/drawer/ui/pipeline_visual_reference_spec.js +++ /dev/null @@ -1,31 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DemoJobPill from '~/pipeline_editor/components/drawer/ui/demo_job_pill.vue'; -import PipelineVisualReference from '~/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue'; - -describe('Demo job pill', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(PipelineVisualReference); - }; - - const findAllDemoJobPills = () => wrapper.findAllComponents(DemoJobPill); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders all stage names', () => { - expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.build); - expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.test); - expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.stageNames.deploy); - }); - - it('renders all job pills', () => { - expect(findAllDemoJobPills()).toHaveLength(4); - }); -}); diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js index 7a5b01fb04a..6f9245e39aa 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -9,6 +9,7 @@ import { mockCommitSha, mockProjectPath, mockProjectNamespace, + mockDefaultBranch, } from '../../mock_data'; describe('Pipeline Editor | Text editor component', () => { @@ -32,12 +33,14 @@ describe('Pipeline Editor | Text editor component', () => { }, }; - const createComponent = (opts = {}, mountFn = shallowMount) => { + const createComponent = (glFeatures = {}, mountFn = shallowMount) => { wrapper = mountFn(TextEditor, { provide: { projectPath: mockProjectPath, projectNamespace: mockProjectNamespace, ciConfigPath: mockCiConfigPath, + defaultBranch: mockDefaultBranch, + glFeatures, }, attrs: { value: mockCiYml, @@ -54,7 +57,6 @@ describe('Pipeline Editor | Text editor component', () => { stubs: { EditorLite: MockEditorLite, }, - ...opts, }); }; @@ -66,7 +68,6 @@ describe('Pipeline Editor | Text editor component', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; mockUse.mockClear(); mockRegisterCiSchema.mockClear(); @@ -100,25 +101,37 @@ describe('Pipeline Editor | Text editor component', () => { }); }); - describe('register CI schema', () => { - beforeEach(async () => { - createComponent(); - - // Since the editor will have already mounted, the event will have fired. - // To ensure we properly test this, we clear the mock and re-remit the event. - mockRegisterCiSchema.mockClear(); - mockUse.mockClear(); + describe('CI schema', () => { + describe('when `schema_linting` feature flag is on', () => { + beforeEach(() => { + createComponent({ schemaLinting: true }); + // Since the editor will have already mounted, the event will have fired. + // To ensure we properly test this, we clear the mock and re-remit the event. + mockRegisterCiSchema.mockClear(); + mockUse.mockClear(); + findEditor().vm.$emit(EDITOR_READY_EVENT); + }); - findEditor().vm.$emit(EDITOR_READY_EVENT); + it('configures editor with syntax highlight', () => { + expect(mockUse).toHaveBeenCalledTimes(1); + expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); + expect(mockRegisterCiSchema).toHaveBeenCalledWith({ + projectNamespace: mockProjectNamespace, + projectPath: mockProjectPath, + ref: mockCommitSha, + }); + }); }); - it('configures editor with syntax highlight', async () => { - expect(mockUse).toHaveBeenCalledTimes(1); - expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); - expect(mockRegisterCiSchema).toHaveBeenCalledWith({ - projectNamespace: mockProjectNamespace, - projectPath: mockProjectPath, - ref: mockCommitSha, + describe('when `schema_linting` feature flag is off', () => { + beforeEach(() => { + createComponent(); + findEditor().vm.$emit(EDITOR_READY_EVENT); + }); + + it('does not call the register CI schema function', () => { + expect(mockUse).not.toHaveBeenCalled(); + expect(mockRegisterCiSchema).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index d6763a7de41..e731ad8695e 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -11,7 +11,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; -import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql'; +import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.graphql'; import { mockBranchPaginationLimit, mockDefaultBranch, @@ -22,6 +22,7 @@ import { mockTotalBranches, mockTotalBranchResults, mockTotalSearchResults, + mockNewBranch, } from '../../mock_data'; const localVue = createLocalVue(); @@ -31,9 +32,12 @@ describe('Pipeline editor branch switcher', () => { let wrapper; let mockApollo; let mockAvailableBranchQuery; + let mockCurrentBranchQuery; + let mockLastCommitBranchQuery; const createComponent = ( - { isQueryLoading, mountFn, options } = { + { currentBranch, isQueryLoading, mountFn, options } = { + currentBranch: mockDefaultBranch, isQueryLoading: false, mountFn: shallowMount, options: {}, @@ -58,8 +62,8 @@ describe('Pipeline editor branch switcher', () => { }, data() { return { - branches: ['main'], - currentBranch: mockDefaultBranch, + availableBranches: ['main'], + currentBranch, }; }, ...options, @@ -67,8 +71,18 @@ describe('Pipeline editor branch switcher', () => { }; const createComponentWithApollo = (mountFn = shallowMount) => { - const handlers = [[getAvailableBranches, mockAvailableBranchQuery]]; - mockApollo = createMockApollo(handlers); + const handlers = [[getAvailableBranchesQuery, mockAvailableBranchQuery]]; + const resolvers = { + Query: { + currentBranch() { + return mockCurrentBranchQuery(); + }, + lastCommitBranch() { + return mockLastCommitBranchQuery(); + }, + }, + }; + mockApollo = createMockApollo(handlers, resolvers); createComponent({ mountFn, @@ -76,11 +90,6 @@ describe('Pipeline editor branch switcher', () => { localVue, apolloProvider: mockApollo, mocks: {}, - data() { - return { - currentBranch: mockDefaultBranch, - }; - }, }, }); }; @@ -90,15 +99,40 @@ describe('Pipeline editor branch switcher', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll); + const defaultBranchInDropdown = () => findDropdownItems().at(0); + + const setMockResolvedValues = ({ availableBranches, currentBranch, lastCommitBranch }) => { + if (availableBranches) { + mockAvailableBranchQuery.mockResolvedValue(availableBranches); + } + + if (currentBranch) { + mockCurrentBranchQuery.mockResolvedValue(currentBranch); + } + + mockLastCommitBranchQuery.mockResolvedValue(lastCommitBranch || ''); + }; beforeEach(() => { mockAvailableBranchQuery = jest.fn(); + mockCurrentBranchQuery = jest.fn(); + mockLastCommitBranchQuery = jest.fn(); }); afterEach(() => { wrapper.destroy(); }); + const testErrorHandling = () => { + expect(wrapper.emitted('showError')).toBeDefined(); + expect(wrapper.emitted('showError')[0]).toEqual([ + { + reasons: [wrapper.vm.$options.i18n.fetchError], + type: DEFAULT_FAILURE, + }, + ]); + }; + describe('when querying for the first time', () => { beforeEach(() => { createComponentWithApollo(); @@ -111,7 +145,10 @@ describe('Pipeline editor branch switcher', () => { describe('after querying', () => { beforeEach(async () => { - mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + setMockResolvedValues({ + availableBranches: mockProjectBranches, + currentBranch: mockDefaultBranch, + }); createComponentWithApollo(mount); await waitForPromises(); }); @@ -126,10 +163,8 @@ describe('Pipeline editor branch switcher', () => { }); it('renders current branch with a check mark', () => { - const defaultBranchInDropdown = findDropdownItems().at(0); - - expect(defaultBranchInDropdown.text()).toBe(mockDefaultBranch); - expect(defaultBranchInDropdown.props('isChecked')).toBe(true); + expect(defaultBranchInDropdown().text()).toBe(mockDefaultBranch); + expect(defaultBranchInDropdown().props('isChecked')).toBe(true); }); it('does not render check mark for other branches', () => { @@ -142,7 +177,10 @@ describe('Pipeline editor branch switcher', () => { describe('on fetch error', () => { beforeEach(async () => { - mockAvailableBranchQuery.mockResolvedValue(new Error()); + setMockResolvedValues({ + availableBranches: new Error(), + currentBranch: mockDefaultBranch, + }); createComponentWithApollo(); await waitForPromises(); }); @@ -152,20 +190,17 @@ describe('Pipeline editor branch switcher', () => { }); it('shows an error message', () => { - expect(wrapper.emitted('showError')).toBeDefined(); - expect(wrapper.emitted('showError')[0]).toEqual([ - { - reasons: [wrapper.vm.$options.i18n.fetchError], - type: DEFAULT_FAILURE, - }, - ]); + testErrorHandling(); }); }); describe('when switching branches', () => { beforeEach(async () => { jest.spyOn(window.history, 'pushState').mockImplementation(() => {}); - mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + setMockResolvedValues({ + availableBranches: mockProjectBranches, + currentBranch: mockDefaultBranch, + }); createComponentWithApollo(mount); await waitForPromises(); }); @@ -212,14 +247,32 @@ describe('Pipeline editor branch switcher', () => { describe('when searching', () => { beforeEach(async () => { - mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + setMockResolvedValues({ + availableBranches: mockProjectBranches, + currentBranch: mockDefaultBranch, + }); createComponentWithApollo(mount); await waitForPromises(); + }); + + afterEach(() => { + mockAvailableBranchQuery.mockClear(); + }); + + it('shows error message on fetch error', async () => { + mockAvailableBranchQuery.mockResolvedValue(new Error()); + + findSearchBox().vm.$emit('input', 'te'); + await waitForPromises(); - mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches); + testErrorHandling(); }); describe('with a search term', () => { + beforeEach(async () => { + mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches); + }); + it('calls query with correct variables', async () => { findSearchBox().vm.$emit('input', 'te'); await waitForPromises(); @@ -253,6 +306,7 @@ describe('Pipeline editor branch switcher', () => { describe('without a search term', () => { beforeEach(async () => { + mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches); findSearchBox().vm.$emit('input', 'te'); await waitForPromises(); @@ -296,7 +350,10 @@ describe('Pipeline editor branch switcher', () => { describe('when scrolling to the bottom of the list', () => { beforeEach(async () => { - mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + setMockResolvedValues({ + availableBranches: mockProjectBranches, + currentBranch: mockDefaultBranch, + }); createComponentWithApollo(); await waitForPromises(); }); @@ -326,6 +383,15 @@ describe('Pipeline editor branch switcher', () => { searchPattern: '*', }); }); + + it('shows error message on fetch error', async () => { + mockAvailableBranchQuery.mockResolvedValue(new Error()); + + findInfiniteScroll().vm.$emit('bottomReached'); + await waitForPromises(); + + testErrorHandling(); + }); }); describe('when search term exists', () => { @@ -343,4 +409,35 @@ describe('Pipeline editor branch switcher', () => { }); }); }); + + describe('when committing a new branch', () => { + const createNewBranch = async () => { + setMockResolvedValues({ + currentBranch: mockNewBranch, + lastCommitBranch: mockNewBranch, + }); + await wrapper.vm.$apollo.queries.currentBranch.refetch(); + await wrapper.vm.$apollo.queries.lastCommitBranch.refetch(); + }; + + beforeEach(async () => { + setMockResolvedValues({ + availableBranches: mockProjectBranches, + currentBranch: mockDefaultBranch, + }); + createComponentWithApollo(mount); + await waitForPromises(); + await createNewBranch(); + }); + + it('sets new branch as current branch', () => { + expect(defaultBranchInDropdown().text()).toBe(mockNewBranch); + expect(defaultBranchInDropdown().props('isChecked')).toBe(true); + }); + + it('adds new branch to branch switcher', () => { + expect(defaultBranchInDropdown().text()).toBe(mockNewBranch); + expect(findDropdownItems()).toHaveLength(mockTotalBranchResults + 1); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index e08fce3ceb9..cadcdf6ae2e 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -5,6 +5,7 @@ export const mockProjectNamespace = 'user1'; export const mockProjectPath = 'project1'; export const mockProjectFullPath = `${mockProjectNamespace}/${mockProjectPath}`; export const mockDefaultBranch = 'main'; +export const mockNewBranch = 'new-branch'; export const mockNewMergeRequestPath = '/-/merge_requests/new'; export const mockCommitSha = 'aabbccdd'; export const mockCommitNextSha = 'eeffgghh'; diff --git a/spec/frontend/pipelines/components/dag/mock_data.js b/spec/frontend/pipelines/components/dag/mock_data.js index e7e93804195..f27e7cf3d6b 100644 --- a/spec/frontend/pipelines/components/dag/mock_data.js +++ b/spec/frontend/pipelines/components/dag/mock_data.js @@ -398,6 +398,8 @@ export const multiNote = { }, }; +export const missingJob = 'missing_job'; + /* It is important that the base include parallel jobs as well as non-parallel jobs with spaces in the name to prevent @@ -657,4 +659,16 @@ export const mockParsedGraphQLNodes = [ ], __typename: 'CiGroup', }, + { + category: 'production', + name: 'production_e', + size: 1, + jobs: [ + { + name: 'production_e', + needs: [missingJob], + }, + ], + __typename: 'CiGroup', + }, ]; diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 4914a9a1ced..bb7e27b5ec2 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; import { IID_FAILURE, LAYER_VIEW, @@ -17,7 +18,6 @@ import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector. import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import * as parsingUtils from '~/pipelines/components/parsing_utils'; -import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql'; import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data'; const defaultProvide = { diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index 96f2cd1e371..c7d95526a0c 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -14,6 +14,7 @@ describe('Linked pipeline', () => { let wrapper; const findButton = () => wrapper.find(GlButton); + const findDownstreamPipelineTitle = () => wrapper.find('[data-testid="downstream-title"]'); const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]'); const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); @@ -119,6 +120,11 @@ describe('Linked pipeline', () => { expect(findPipelineLabel().exists()).toBe(true); }); + it('should have the name of the trigger job on the card when it is a child pipeline', () => { + createWrapper(downstreamProps); + expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.source_job.name); + }); + it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { createWrapper(upstreamProps); expect(findPipelineLabel().exists()).toBe(true); diff --git a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap index c67b91ae190..16c28791514 100644 --- a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap +++ b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap @@ -21,3 +21,10 @@ exports[`Links Inner component with one need matches snapshot and has expected p <path d=\\"M202,118L42,118C72,118,72,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> </svg> </div>" `; + +exports[`Links Inner component with same stage needs matches snapshot and has expected path 1`] = ` +"<div class=\\"gl-display-flex gl-relative\\" totalgroups=\\"10\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> + <path d=\\"M192,108L22,108C52,108,52,118,82,118\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M202,118L32,118C62,118,62,128,92,128\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + </svg> </div>" +`; diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index bb1f0965469..8f39c8c2405 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -10,6 +10,7 @@ import { pipelineData, pipelineDataWithNoNeeds, rootRect, + sameStageNeeds, } from '../pipeline_graph/mock_data'; describe('Links Inner component', () => { @@ -40,7 +41,7 @@ describe('Links Inner component', () => { // We create fixture so that each job has an empty div that represent // the JobPill in the DOM. Each `JobPill` would have different coordinates, - // so we increment their coordinates on each iteration to simulat different positions. + // so we increment their coordinates on each iteration to simulate different positions. const setFixtures = ({ stages }) => { const jobs = createJobsHash(stages); const arrayOfJobs = Object.keys(jobs); @@ -81,7 +82,6 @@ describe('Links Inner component', () => { afterEach(() => { jest.restoreAllMocks(); wrapper.destroy(); - wrapper = null; }); describe('basic SVG creation', () => { @@ -160,6 +160,25 @@ describe('Links Inner component', () => { }); }); + describe('with same stage needs', () => { + beforeEach(() => { + setFixtures(sameStageNeeds); + createComponent({ pipelineData: sameStageNeeds.stages }); + }); + + it('renders the correct number of links', () => { + expect(findAllLinksPath()).toHaveLength(2); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + describe('with a large number of needs', () => { beforeEach(() => { setFixtures(largePipelineData); diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index 57d846c53c8..31f0e72c279 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -7,7 +7,9 @@ import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline. import { mockCancelledPipelineHeader, mockFailedPipelineHeader, + mockFailedPipelineNoPermissions, mockRunningPipelineHeader, + mockRunningPipelineNoPermissions, mockSuccessfulPipelineHeader, } from './mock_data'; @@ -168,5 +170,19 @@ describe('Pipeline details header', () => { }); }); }); + + describe('Permissions', () => { + it('should not display the cancel action if user does not have permission', () => { + wrapper = createComponent(mockRunningPipelineNoPermissions); + + expect(findCancelButton().exists()).toBe(false); + }); + + it('should not display the retry action if user does not have permission', () => { + wrapper = createComponent(mockFailedPipelineNoPermissions); + + expect(findRetryButton().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 16f15b20824..7e3c3727c9d 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -10,6 +10,7 @@ export const mockPipelineHeader = { id: 123, userPermissions: { destroyPipeline: true, + updatePipeline: true, }, createdAt: threeWeeksAgo.toISOString(), user: { @@ -34,6 +35,31 @@ export const mockFailedPipelineHeader = { }, }; +export const mockFailedPipelineNoPermissions = { + id: 123, + userPermissions: { + destroyPipeline: false, + updatePipeline: false, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, + status: PIPELINE_RUNNING, + retryable: true, + cancelable: false, + detailedStatus: { + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + export const mockRunningPipelineHeader = { ...mockPipelineHeader, status: PIPELINE_RUNNING, @@ -48,6 +74,31 @@ export const mockRunningPipelineHeader = { }, }; +export const mockRunningPipelineNoPermissions = { + id: 123, + userPermissions: { + destroyPipeline: false, + updatePipeline: false, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, + status: PIPELINE_RUNNING, + retryable: false, + cancelable: true, + detailedStatus: { + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + export const mockCancelledPipelineHeader = { ...mockPipelineHeader, status: PIPELINE_CANCELED, diff --git a/spec/frontend/pipelines/notification/pipeline_notification_spec.js b/spec/frontend/pipelines/notification/pipeline_notification_spec.js deleted file mode 100644 index 79aa337ba9d..00000000000 --- a/spec/frontend/pipelines/notification/pipeline_notification_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import { GlBanner } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import PipelineNotification from '~/pipelines/components/notification/pipeline_notification.vue'; -import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql'; - -describe('Pipeline notification', () => { - const localVue = createLocalVue(); - - let wrapper; - const dagDocPath = 'my/dag/path'; - - const createWrapper = (apolloProvider) => { - return shallowMount(PipelineNotification, { - localVue, - provide: { - dagDocPath, - }, - apolloProvider, - }); - }; - - const createWrapperWithApollo = async ({ callouts = [], isLoading = false } = {}) => { - localVue.use(VueApollo); - - const mappedCallouts = callouts.map((callout) => { - return { featureName: callout, __typename: 'UserCallout' }; - }); - - const mockCalloutsResponse = { - data: { - currentUser: { - id: 45, - __typename: 'User', - callouts: { - id: 5, - __typename: 'UserCalloutConnection', - nodes: mappedCallouts, - }, - }, - }, - }; - const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse); - const requestHandlers = [[getUserCallouts, getUserCalloutsHandler]]; - - const apolloWrapper = createWrapper(createMockApollo(requestHandlers)); - if (!isLoading) { - await nextTick(); - } - - return apolloWrapper; - }; - - const findBanner = () => wrapper.findComponent(GlBanner); - - afterEach(() => { - wrapper.destroy(); - }); - - it('shows the banner if the user has never seen it', async () => { - wrapper = await createWrapperWithApollo({ callouts: ['random'] }); - - expect(findBanner().exists()).toBe(true); - }); - - it('does not show the banner while the user callout query is loading', async () => { - wrapper = await createWrapperWithApollo({ callouts: ['random'], isLoading: true }); - - expect(findBanner().exists()).toBe(false); - }); - - it('does not show the banner if the user has previously dismissed it', async () => { - wrapper = await createWrapperWithApollo({ callouts: ['pipeline_needs_banner'.toUpperCase()] }); - - expect(findBanner().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/pipelines/parsing_utils_spec.js b/spec/frontend/pipelines/parsing_utils_spec.js index 96748ae9e5c..074009ae056 100644 --- a/spec/frontend/pipelines/parsing_utils_spec.js +++ b/spec/frontend/pipelines/parsing_utils_spec.js @@ -10,7 +10,7 @@ import { getMaxNodes, } from '~/pipelines/components/parsing_utils'; -import { mockParsedGraphQLNodes } from './components/dag/mock_data'; +import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data'; import { generateResponse, mockPipelineResponse } from './graph/mock_data'; describe('DAG visualization parsing utilities', () => { @@ -24,6 +24,12 @@ describe('DAG visualization parsing utilities', () => { expect(unfilteredLinks[0]).toHaveProperty('target', 'test_a'); expect(unfilteredLinks[0]).toHaveProperty('value', 10); }); + + it('does not generate a link for non-existing jobs', () => { + const sources = unfilteredLinks.map(({ source }) => source); + + expect(sources.includes(missingJob)).toBe(false); + }); }); describe('filterByAncestors', () => { @@ -88,7 +94,7 @@ describe('DAG visualization parsing utilities', () => { These lengths are determined by the mock data. If the data changes, the numbers may also change. */ - expect(parsed.nodes).toHaveLength(21); + expect(parsed.nodes).toHaveLength(mockParsedGraphQLNodes.length); expect(cleanedNodes).toHaveLength(12); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index a79917bfd48..db77e0a0573 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -162,6 +162,38 @@ export const parallelNeedData = { ], }; +export const sameStageNeeds = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build', name: 'build_1' }], + }, + ], + }, + { + name: 'build', + groups: [ + { + name: 'build_2', + jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_1'] }], + }, + ], + }, + { + name: 'build', + groups: [ + { + name: 'build_3', + jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_2'] }], + }, + ], + }, + ], +}; + export const largePipelineData = { stages: [ { diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js index 070d3bf7dac..5816bc06fe3 100644 --- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js @@ -111,6 +111,28 @@ describe('utils functions', () => { }); }); + it('removes needs which are not in the data', () => { + const inexistantJobName = 'job5'; + const jobsWithNeeds = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: { + name: jobName4, + script: 'echo deploy', + stage: 'deploy', + needs: [inexistantJobName], + }, + }; + + expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ + [jobName1]: [], + [jobName2]: [], + [jobName3]: [jobName1, jobName2], + [jobName4]: [], + }); + }); + it('handles parallel jobs by adding the group name as a need', () => { const size = 3; const jobOptimize1 = 'optimize_1'; diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index f9b59c5dc48..874ecbccf82 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -7,8 +7,8 @@ import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; -import { getExperimentVariant } from '~/experimentation/utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { getExperimentData, getExperimentVariant } from '~/experimentation/utils'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; @@ -23,6 +23,7 @@ import { stageReply, users, mockSearch, branches } from './mock_data'; jest.mock('~/flash'); jest.mock('~/experimentation/utils', () => ({ ...jest.requireActual('~/experimentation/utils'), + getExperimentData: jest.fn().mockReturnValue(false), getExperimentVariant: jest.fn().mockReturnValue('control'), })); @@ -48,6 +49,7 @@ describe('Pipelines', () => { resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, newPipelinePath: `${mockProjectPath}/pipelines/new`, codeQualityPagePath: `${mockProjectPath}/-/new/master?commit_message=Add+.gitlab-ci.yml+and+create+a+code+quality+job&file_name=.gitlab-ci.yml&template=Code-Quality`, + ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`, }; const noPermissions = { @@ -349,7 +351,7 @@ describe('Pipelines', () => { it('displays a warning message if raw text search is used', () => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(RAW_TEXT_WARNING, 'warning'); + expect(createFlash).toHaveBeenCalledWith({ message: RAW_TEXT_WARNING, type: 'warning' }); }); it('should update browser bar', () => { @@ -563,6 +565,7 @@ describe('Pipelines', () => { describe('when the code_quality_walkthrough experiment is active', () => { beforeAll(() => { + getExperimentData.mockImplementation((name) => name === 'code_quality_walkthrough'); getExperimentVariant.mockReturnValue('candidate'); }); @@ -574,6 +577,29 @@ describe('Pipelines', () => { }); }); + describe('when the ci_runner_templates experiment is active', () => { + beforeAll(() => { + getExperimentData.mockImplementation((name) => name === 'ci_runner_templates'); + getExperimentVariant.mockReturnValue('candidate'); + }); + + it('renders two buttons', () => { + expect(findEmptyState().findAllComponents(GlButton).length).toBe(2); + expect(findEmptyState().findAllComponents(GlButton).at(0).text()).toBe( + 'Install GitLab Runners', + ); + expect(findEmptyState().findAllComponents(GlButton).at(0).attributes('href')).toBe( + paths.ciRunnerSettingsPath, + ); + expect(findEmptyState().findAllComponents(GlButton).at(1).text()).toBe( + 'Learn about Runners', + ); + expect(findEmptyState().findAllComponents(GlButton).at(1).attributes('href')).toBe( + '/help/ci/quick_start/index.md', + ); + }); + }); + it('does not render filtered search', () => { expect(findFilteredSearch().exists()).toBe(false); }); diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index 6258b08dfbb..e931ddb8496 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/pipelines/stores/test_reports/actions'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index a3d7b63373c..42adefcd0bb 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -2,7 +2,7 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import UpdateUsername from '~/profile/account/components/update_username.vue'; @@ -146,7 +146,9 @@ describe('UpdateUsername component', () => { await expect(wrapper.vm.onConfirm()).rejects.toThrow(); - expect(createFlash).toBeCalledWith('Invalid username'); + expect(createFlash).toBeCalledWith({ + message: 'Invalid username', + }); }); it("shows a fallback error message if the error response doesn't have a `message` property", async () => { @@ -156,9 +158,9 @@ describe('UpdateUsername component', () => { await expect(wrapper.vm.onConfirm()).rejects.toThrow(); - expect(createFlash).toBeCalledWith( - 'An error occurred while updating your username, please try again.', - ); + expect(createFlash).toBeCalledWith({ + message: 'An error occurred while updating your username, please try again.', + }); }); }); }); diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js index 9688cb47799..0c8089430d0 100644 --- a/spec/frontend/projects/commit/components/form_modal_spec.js +++ b/spec/frontend/projects/commit/components/form_modal_spec.js @@ -159,12 +159,7 @@ describe('CommitFormModal', () => { }); it('Changes the target_project_id input value', async () => { - createComponent( - shallowMount, - {}, - { glFeatures: { pickIntoProject: true } }, - { isCherryPick: true }, - ); + createComponent(shallowMount, {}, {}, { isCherryPick: true }); findProjectsDropdown().vm.$emit('selectProject', '_changed_project_value_'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js index e2c993b8395..fdb12640b26 100644 --- a/spec/frontend/projects/commits/store/actions_spec.js +++ b/spec/frontend/projects/commits/store/actions_spec.js @@ -1,7 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import actions from '~/projects/commits/store/actions'; import * as types from '~/projects/commits/store/mutation_types'; import createState from '~/projects/commits/store/state'; @@ -39,7 +39,9 @@ describe('Project commits actions', () => { actions.receiveAuthorsError(mockDispatchContext); expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith('An error occurred fetching the project authors.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred fetching the project authors.', + }); }); }); diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index 40e31e24a14..b41b5028736 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { TEST_HOST } from 'helpers/test_constants'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit'; @@ -69,7 +69,7 @@ describe('ProtectedBranchEdit', () => { expect(mock.history.patch).toHaveLength(1); expect(toggle).not.toBeDisabled(); - expect(flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); })); }); @@ -81,7 +81,7 @@ describe('ProtectedBranchEdit', () => { it('flashes error', () => axios.waitForAll().then(() => { - expect(flash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); })); }); }); diff --git a/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap index 4be4fce1abf..f80e2ce6ecc 100644 --- a/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ b/spec/frontend/registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -26,6 +26,7 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` aria-hidden="true" class="gl-icon s8" data-testid="angle-right-icon" + role="img" > <use href="#angle-right" diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js index dc9063bde2c..c8fcb3116cd 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -313,10 +313,10 @@ describe('tags list row', () => { }); describe.each` - name | finderFunction | text | icon | clipboard - ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 GMT+0000 on 2020-11-03'} | ${'clock'} | ${false} - ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} - ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} + name | finderFunction | text | icon | clipboard + ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 UTC on 2020-11-03'} | ${'clock'} | ${false} + ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} + ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} `('$name details row', ({ finderFunction, text, icon, clipboard }) => { it(`has ${text} as text`, async () => { mountComponent(); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index 48acc06792d..b58a53f0af2 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; +import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import DeleteImage from '~/registry/explorer/components/delete_image.vue'; import CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue'; @@ -43,21 +44,22 @@ describe('List Page', () => { let wrapper; let apolloProvider; - const findDeleteModal = () => wrapper.find(GlModal); - const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findEmptyState = () => wrapper.find(GlEmptyState); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); - const findCliCommands = () => wrapper.find(CliCommands); - const findProjectEmptyState = () => wrapper.find(ProjectEmptyState); - const findGroupEmptyState = () => wrapper.find(GroupEmptyState); - const findRegistryHeader = () => wrapper.find(RegistryHeader); + const findCliCommands = () => wrapper.findComponent(CliCommands); + const findProjectEmptyState = () => wrapper.findComponent(ProjectEmptyState); + const findGroupEmptyState = () => wrapper.findComponent(GroupEmptyState); + const findRegistryHeader = () => wrapper.findComponent(RegistryHeader); - const findDeleteAlert = () => wrapper.find(GlAlert); - const findImageList = () => wrapper.find(ImageList); - const findRegistrySearch = () => wrapper.find(RegistrySearch); + const findDeleteAlert = () => wrapper.findComponent(GlAlert); + const findImageList = () => wrapper.findComponent(ImageList); + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); - const findDeleteImage = () => wrapper.find(DeleteImage); + const findDeleteImage = () => wrapper.findComponent(DeleteImage); + const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); const waitForApolloRequestRender = async () => { jest.runOnlyPendingTimers(); @@ -560,4 +562,33 @@ describe('List Page', () => { }, ); }); + + describe('cleanup is on alert', () => { + it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => { + mountComponent({ + config: { + showCleanupPolicyOnAlert: true, + projectPath: 'foo', + isGroupPage: false, + cleanupPoliciesSettingsPath: 'bar', + }, + }); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(true); + expect(findCleanupAlert().props()).toMatchObject({ + projectPath: 'foo', + cleanupPoliciesSettingsPath: 'bar', + }); + }); + + it('is hidden when showCleanupPolicyOnAlert is false', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/related_merge_requests/store/actions_spec.js b/spec/frontend/related_merge_requests/store/actions_spec.js index a14096388e6..3bd07c34b6f 100644 --- a/spec/frontend/related_merge_requests/store/actions_spec.js +++ b/spec/frontend/related_merge_requests/store/actions_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/related_merge_requests/store/actions'; import * as types from '~/related_merge_requests/store/mutation_types'; @@ -100,7 +100,9 @@ describe('RelatedMergeRequest store actions', () => { [{ type: 'requestData' }, { type: 'receiveDataError' }], () => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(expect.stringMatching('Something went wrong')); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), + }); done(); }, diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index cad593b76ea..e0a1343c39c 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -5,6 +5,58 @@ Object { "data": Array [ Object { "_links": Object { + "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=closed", + "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=closed", + "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2/edit", + "mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=merged", + "openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=opened", + "openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=opened", + "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.2", + "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2", + }, + "assets": Object { + "count": 4, + "links": Array [], + "sources": Array [ + Object { + "format": "zip", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.zip", + }, + Object { + "format": "tar.gz", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.gz", + }, + Object { + "format": "tar.bz2", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.bz2", + }, + Object { + "format": "tar", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar", + }, + ], + }, + "author": Object { + "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "username": "administrator", + "webUrl": "http://localhost/administrator", + }, + "commit": Object { + "shortId": "b83d6e39", + "title": "Merge branch 'branch-merged' into 'master'", + }, + "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", + "descriptionHtml": "<p data-sourcepos=\\"1:1-1:23\\" dir=\\"auto\\">An okay release <gl-emoji title=\\"shrug\\" data-name=\\"shrug\\" data-unicode-version=\\"9.0\\">🤷</gl-emoji></p>", + "evidences": Array [], + "milestones": Array [], + "name": "The second release", + "releasedAt": "2019-01-10T00:00:00Z", + "tagName": "v1.2", + "tagPath": "/releases-namespace/releases-project/-/tags/v1.2", + "upcomingRelease": true, + }, + Object { + "_links": Object { "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed", "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=closed", "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit", @@ -121,10 +173,10 @@ Object { }, ], "paginationInfo": Object { - "endCursor": "eyJpZCI6IjEifQ", + "endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9", "hasNextPage": false, "hasPreviousPage": false, - "startCursor": "eyJpZCI6IjEifQ", + "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMiJ9", }, } `; diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js new file mode 100644 index 00000000000..002d8939058 --- /dev/null +++ b/spec/frontend/releases/components/app_index_apollo_client_spec.js @@ -0,0 +1,394 @@ +import { cloneDeep } from 'lodash'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createFlash from '~/flash'; +import { historyPushState } from '~/lib/utils/common_utils'; +import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue'; +import ReleaseBlock from '~/releases/components/release_block.vue'; +import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; +import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; +import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; +import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; +import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; +import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +let mockQueryParams; +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + getParameterByName: jest + .fn() + .mockImplementation((parameterName) => mockQueryParams[parameterName]), + historyPushState: jest.fn(), +})); + +describe('app_index_apollo_client.vue', () => { + const originalAllReleasesQueryResponse = getJSONFixture( + 'graphql/releases/graphql/queries/all_releases.query.graphql.json', + ); + const projectPath = 'project/path'; + const newReleasePath = 'path/to/new/release/page'; + const before = 'beforeCursor'; + const after = 'afterCursor'; + + let wrapper; + let allReleases; + let singleRelease; + let noReleases; + let queryMock; + + const createComponent = ({ + singleResponse = Promise.resolve(singleRelease), + fullResponse = Promise.resolve(allReleases), + } = {}) => { + const apolloProvider = createMockApollo([ + [ + allReleasesQuery, + queryMock.mockImplementation((vars) => { + return vars.first === 1 ? singleResponse : fullResponse; + }), + ], + ]); + + wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, { + apolloProvider, + provide: { + newReleasePath, + projectPath, + }, + }); + }; + + beforeEach(() => { + mockQueryParams = {}; + + allReleases = cloneDeep(originalAllReleasesQueryResponse); + + singleRelease = cloneDeep(originalAllReleasesQueryResponse); + singleRelease.data.project.releases.nodes.splice( + 1, + singleRelease.data.project.releases.nodes.length, + ); + + noReleases = cloneDeep(originalAllReleasesQueryResponse); + noReleases.data.project.releases.nodes = []; + + queryMock = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + // Finders + const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader); + const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState); + const findNewReleaseButton = () => + wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease); + const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); + const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient); + const findSort = () => wrapper.findComponent(ReleasesSortApolloClient); + + // Tests + describe('component states', () => { + // These need to be defined as functions, since `singleRelease` and + // `allReleases` are generated in a `beforeEach`, and therefore + // aren't available at test definition time. + const getInProgressResponse = () => new Promise(() => {}); + const getErrorResponse = () => Promise.reject(new Error('Oops!')); + const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease); + const getFullRequestLoadedResponse = () => Promise.resolve(allReleases); + const getLoadedEmptyResponse = () => Promise.resolve(noReleases); + + const toDescription = (bool) => (bool ? 'does' : 'does not'); + + describe.each` + description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination + ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false} + ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false} + ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false} + ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false} + ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + `( + '$description', + ({ + singleResponseFn, + fullResponseFn, + loadingIndicator, + emptyState, + flashMessage, + releaseCount, + pagination, + }) => { + beforeEach(() => { + createComponent({ + singleResponse: singleResponseFn(), + fullResponse: fullResponseFn(), + }); + }); + + it(`${toDescription(loadingIndicator)} render a loading indicator`, () => { + expect(findLoadingIndicator().exists()).toBe(loadingIndicator); + }); + + it(`${toDescription(emptyState)} render an empty state`, () => { + expect(findEmptyState().exists()).toBe(emptyState); + }); + + it(`${toDescription(flashMessage)} show a flash message`, () => { + if (flashMessage) { + expect(createFlash).toHaveBeenCalledWith({ + message: ReleasesIndexApolloClientApp.i18n.errorMessage, + captureError: true, + error: expect.any(Error), + }); + } else { + expect(createFlash).not.toHaveBeenCalled(); + } + }); + + it(`renders ${releaseCount} release(s)`, () => { + expect(findAllReleaseBlocks()).toHaveLength(releaseCount); + }); + + it(`${toDescription(pagination)} render the pagination controls`, () => { + expect(findPagination().exists()).toBe(pagination); + }); + + it('does render the "New release" button', () => { + expect(findNewReleaseButton().exists()).toBe(true); + }); + + it('does render the sort controls', () => { + expect(findSort().exists()).toBe(true); + }); + }, + ); + }); + + describe('URL parameters', () => { + describe('when the URL contains no query parameters', () => { + beforeEach(() => { + createComponent(); + }); + + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + + describe('when the URL contains a "before" query parameter', () => { + beforeEach(() => { + mockQueryParams = { before }; + createComponent(); + }); + + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(1); + + expect(queryMock).toHaveBeenCalledWith({ + before, + last: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + + describe('when the URL contains an "after" query parameter', () => { + beforeEach(() => { + mockQueryParams = { after }; + createComponent(); + }); + + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + + describe('when the URL contains both "before" and "after" query parameters', () => { + beforeEach(() => { + mockQueryParams = { before, after }; + createComponent(); + }); + + it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + }); + + describe('New release button', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the new release button with the correct href', () => { + expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); + }); + }); + + describe('pagination', () => { + beforeEach(() => { + mockQueryParams = { before }; + createComponent(); + }); + + it('requeries the GraphQL endpoint when a pagination button is clicked', async () => { + expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]); + + mockQueryParams = { after }; + findPagination().vm.$emit('next', after); + + await wrapper.vm.$nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ before })], + [expect.objectContaining({ after })], + [expect.objectContaining({ after })], + ]); + }); + }); + + describe('sorting', () => { + beforeEach(() => { + createComponent(); + }); + + it(`sorts by ${DEFAULT_SORT} by default`, () => { + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); + }); + + it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => { + findSort().vm.$emit('input', CREATED_ASC); + + await wrapper.vm.$nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: CREATED_ASC })], + [expect.objectContaining({ sort: CREATED_ASC })], + ]); + + // URL manipulation is tested in more detail in the `describe` block below + expect(historyPushState).toHaveBeenCalled(); + }); + + it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => { + findSort().vm.$emit('input', DEFAULT_SORT); + + await wrapper.vm.$nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); + + expect(historyPushState).not.toHaveBeenCalled(); + }); + }); + + describe('sorting + pagination interaction', () => { + const nonPaginationQueryParam = 'nonPaginationQueryParam'; + + beforeEach(() => { + historyPushState.mockImplementation((newUrl) => { + mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams); + }); + }); + + describe.each` + queryParamsBefore | paramName | paramInitialValue + ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before} + ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after} + `( + 'when the URL contains a "$paramName" pagination cursor', + ({ queryParamsBefore, paramName, paramInitialValue }) => { + beforeEach(async () => { + mockQueryParams = queryParamsBefore; + createComponent(); + + findSort().vm.$emit('input', CREATED_ASC); + + await wrapper.vm.$nextTick(); + }); + + it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => { + const firstRequestVariables = queryMock.mock.calls[0][0]; + // Might be request #2 or #3, depending on the pagination direction + const mostRecentRequestVariables = + queryMock.mock.calls[queryMock.mock.calls.length - 1][0]; + + expect(firstRequestVariables[paramName]).toBe(paramInitialValue); + expect(mostRecentRequestVariables[paramName]).toBeUndefined(); + }); + + it(`updates the URL to not include the "${paramName}" URL query parameter`, () => { + expect(historyPushState).toHaveBeenCalledTimes(1); + + const updatedUrlQueryParams = Object.fromEntries( + new URL(historyPushState.mock.calls[0][0]).searchParams, + ); + + expect(updatedUrlQueryParams[paramName]).toBeUndefined(); + }); + }, + ); + }); +}); diff --git a/spec/frontend/releases/components/releases_empty_state_spec.js b/spec/frontend/releases/components/releases_empty_state_spec.js new file mode 100644 index 00000000000..495e6d863f7 --- /dev/null +++ b/spec/frontend/releases/components/releases_empty_state_spec.js @@ -0,0 +1,56 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; + +describe('releases_empty_state.vue', () => { + const documentationPath = 'path/to/releases/documentation'; + const illustrationPath = 'path/to/releases/empty/state/illustration'; + + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ReleasesEmptyState, { + provide: { + documentationPath, + illustrationPath, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a GlEmptyState and provides it with the correct props', () => { + const emptyStateProps = wrapper.findComponent(GlEmptyState).props(); + + expect(emptyStateProps).toEqual( + expect.objectContaining({ + title: ReleasesEmptyState.i18n.emptyStateTitle, + svgPath: illustrationPath, + }), + ); + }); + + it('renders the empty state text', () => { + expect(wrapper.findByText(ReleasesEmptyState.i18n.emptyStateText).exists()).toBe(true); + }); + + it('renders a link to the documentation', () => { + const documentationLink = wrapper.findByText(ReleasesEmptyState.i18n.moreInformation); + + expect(documentationLink.exists()).toBe(true); + + expect(documentationLink.attributes()).toEqual( + expect.objectContaining({ + 'aria-label': ReleasesEmptyState.i18n.releasesDocumentation, + href: documentationPath, + target: '_blank', + }), + ); + }); +}); diff --git a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js new file mode 100644 index 00000000000..a538afd5d38 --- /dev/null +++ b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js @@ -0,0 +1,126 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { historyPushState } from '~/lib/utils/common_utils'; +import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; + +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + historyPushState: jest.fn(), +})); + +describe('releases_pagination_apollo_client.vue', () => { + const startCursor = 'startCursor'; + const endCursor = 'endCursor'; + let wrapper; + let onPrev; + let onNext; + + const createComponent = (pageInfo) => { + onPrev = jest.fn(); + onNext = jest.fn(); + + wrapper = mountExtended(ReleasesPaginationApolloClient, { + propsData: { + pageInfo, + }, + listeners: { + prev: onPrev, + next: onNext, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const singlePageInfo = { + hasPreviousPage: false, + hasNextPage: false, + startCursor, + endCursor, + }; + + const onlyNextPageInfo = { + hasPreviousPage: false, + hasNextPage: true, + startCursor, + endCursor, + }; + + const onlyPrevPageInfo = { + hasPreviousPage: true, + hasNextPage: false, + startCursor, + endCursor, + }; + + const prevAndNextPageInfo = { + hasPreviousPage: true, + hasNextPage: true, + startCursor, + endCursor, + }; + + const findPrevButton = () => wrapper.findByTestId('prevButton'); + const findNextButton = () => wrapper.findByTestId('nextButton'); + + describe.each` + description | pageInfo | prevEnabled | nextEnabled + ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} + ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} + ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} + ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} + `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => { + describe(description, () => { + beforeEach(() => { + createComponent(pageInfo); + }); + + it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled'); + }); + + it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled'); + }); + }); + }); + + describe('button behavior', () => { + beforeEach(() => { + createComponent(prevAndNextPageInfo); + }); + + describe('next button behavior', () => { + beforeEach(() => { + findNextButton().trigger('click'); + }); + + it('emits an "next" event with the "after" cursor', () => { + expect(onNext.mock.calls).toEqual([[endCursor]]); + }); + + it('calls historyPushState with the new URL', () => { + expect(historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?after=${endCursor}`)], + ]); + }); + }); + + describe('prev button behavior', () => { + beforeEach(() => { + findPrevButton().trigger('click'); + }); + + it('emits an "prev" event with the "before" cursor', () => { + expect(onPrev.mock.calls).toEqual([[startCursor]]); + }); + + it('calls historyPushState with the new URL', () => { + expect(historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?before=${startCursor}`)], + ]); + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js new file mode 100644 index 00000000000..d93a932af01 --- /dev/null +++ b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js @@ -0,0 +1,103 @@ +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; +import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; + +describe('releases_sort_apollo_client.vue', () => { + let wrapper; + + const createComponent = (valueProp = RELEASED_AT_ASC) => { + wrapper = shallowMountExtended(ReleasesSortApolloClient, { + propsData: { + value: valueProp, + }, + stubs: { + GlSortingItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSorting = () => wrapper.findComponent(GlSorting); + const findSortingItems = () => wrapper.findAllComponents(GlSortingItem); + const findReleasedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Released date'); + const findCreatedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Created date'); + const getSortingItemsInfo = () => + findSortingItems().wrappers.map((item) => ({ + label: item.text(), + active: item.attributes().active === 'true', + })); + + describe.each` + valueProp | text | isAscending | items + ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + `('component states', ({ valueProp, text, isAscending, items }) => { + beforeEach(() => { + createComponent(valueProp); + }); + + it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => { + expect(findSorting().props()).toEqual( + expect.objectContaining({ + text, + isAscending, + }), + ); + }); + + it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => { + expect(getSortingItemsInfo()).toEqual(items); + }); + }); + + const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click'); + const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click'); + const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange'); + + const releasedAtDropdownItemDescription = 'released at dropdown item'; + const createdAtDropdownItemDescription = 'created at dropdown item'; + const sortDirectionButtonDescription = 'sort direction button'; + + describe.each` + initialValueProp | itemClickFn | itemToClickDescription | emittedEvent + ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC} + ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC} + ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC} + ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC} + ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC} + ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC} + `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => { + beforeEach(() => { + createComponent(initialValueProp); + itemClickFn(); + }); + + it(`emits ${ + emittedEvent || 'nothing' + } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => { + expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent); + }); + }); + + describe('prop validation', () => { + it('validates that the `value` prop is one of the expected sort strings', () => { + expect(() => { + createComponent('not a valid value'); + }).toThrow('Invalid prop: custom validator check failed'); + }); + }); +}); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 688ec4c0a50..6504a09df2f 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,7 +1,7 @@ import { cloneDeep } from 'lodash'; import { getJSONFixture } from 'helpers/fixtures'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { ASSET_LINK_TYPE } from '~/releases/constants'; import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql'; @@ -151,9 +151,9 @@ describe('Release edit/new actions', () => { it(`shows a flash message`, () => { return actions.fetchRelease({ commit: jest.fn(), state, rootState: state }).then(() => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while getting the release details.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while getting the release details.', + }); }); }); }); @@ -352,9 +352,9 @@ describe('Release edit/new actions', () => { .createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} }) .then(() => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while creating a new release.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while creating a new release.', + }); }); }); }); @@ -483,9 +483,9 @@ describe('Release edit/new actions', () => { await actions.updateRelease({ commit, dispatch, state, getters }); expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while saving the release details.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while saving the release details.', + }); }); }); @@ -503,9 +503,9 @@ describe('Release edit/new actions', () => { await actions.updateRelease({ commit, dispatch, state, getters }); expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while saving the release details.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while saving the release details.', + }); }); }; diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js index 1b83d071d17..9dda024bffd 100644 --- a/spec/frontend/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/reports/codequality_report/store/actions_spec.js @@ -20,6 +20,9 @@ describe('Codequality Reports actions', () => { it('should commit SET_PATHS mutation', (done) => { const paths = { basePath: 'basePath', + headPath: 'headPath', + baseBlobPath: 'baseBlobPath', + headBlobPath: 'headBlobPath', reportsPath: 'reportsPath', helpPath: 'codequalityHelpPath', }; diff --git a/spec/frontend/reports/codequality_report/store/mutations_spec.js b/spec/frontend/reports/codequality_report/store/mutations_spec.js index 9d4c05afd36..8bc6bb26c2a 100644 --- a/spec/frontend/reports/codequality_report/store/mutations_spec.js +++ b/spec/frontend/reports/codequality_report/store/mutations_spec.js @@ -13,16 +13,25 @@ describe('Codequality Reports mutations', () => { describe('SET_PATHS', () => { it('sets paths to given values', () => { const basePath = 'base.json'; + const headPath = 'head.json'; + const baseBlobPath = 'base/blob/path/'; + const headBlobPath = 'head/blob/path/'; const reportsPath = 'reports.json'; const helpPath = 'help.html'; mutations.SET_PATHS(localState, { basePath, + headPath, + baseBlobPath, + headBlobPath, reportsPath, helpPath, }); expect(localState.basePath).toEqual(basePath); + expect(localState.headPath).toEqual(headPath); + expect(localState.baseBlobPath).toEqual(baseBlobPath); + expect(localState.headBlobPath).toEqual(headBlobPath); expect(localState.reportsPath).toEqual(reportsPath); expect(localState.helpPath).toEqual(helpPath); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index f03df8cf2ac..495039b4ccb 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -5,6 +5,7 @@ import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue'; +import BlobReplace from '~/repository/components/blob_replace.vue'; let wrapper; const simpleMockData = { @@ -75,10 +76,11 @@ const factory = createFactory(shallowMount); const fullFactory = createFactory(mount); describe('Blob content viewer component', () => { - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findBlobHeader = () => wrapper.find(BlobHeader); - const findBlobHeaderEdit = () => wrapper.find(BlobHeaderEdit); - const findBlobContent = () => wrapper.find(BlobContent); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findBlobHeader = () => wrapper.findComponent(BlobHeader); + const findBlobHeaderEdit = () => wrapper.findComponent(BlobHeaderEdit); + const findBlobContent = () => wrapper.findComponent(BlobContent); + const findBlobReplace = () => wrapper.findComponent(BlobReplace); afterEach(() => { wrapper.destroy(); @@ -162,15 +164,23 @@ describe('Blob content viewer component', () => { }); describe('BlobHeader action slot', () => { - it('renders BlobHeaderEdit button in simple viewer', async () => { + const { ideEditPath, editBlobPath } = simpleMockData; + + it('renders BlobHeaderEdit buttons in simple viewer', async () => { fullFactory({ mockData: { blobInfo: simpleMockData }, stubs: { BlobContent: true, + BlobReplace: true, }, }); + await nextTick(); - expect(findBlobHeaderEdit().props('editPath')).toEqual('some_file.js/edit'); + + expect(findBlobHeaderEdit().props()).toMatchObject({ + editPath: editBlobPath, + webIdePath: ideEditPath, + }); }); it('renders BlobHeaderEdit button in rich viewer', async () => { @@ -178,10 +188,55 @@ describe('Blob content viewer component', () => { mockData: { blobInfo: richMockData }, stubs: { BlobContent: true, + BlobReplace: true, }, }); + await nextTick(); - expect(findBlobHeaderEdit().props('editPath')).toEqual('some_file.js/edit'); + + expect(findBlobHeaderEdit().props()).toMatchObject({ + editPath: editBlobPath, + webIdePath: ideEditPath, + }); + }); + + describe('BlobReplace', () => { + const { name, path } = simpleMockData; + + it('renders component', async () => { + window.gon.current_user_id = 1; + + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobReplace().props()).toMatchObject({ + name, + path, + }); + }); + + it('does not render if not logged in', async () => { + window.gon.current_user_id = null; + + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobReplace().exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/repository/components/blob_header_edit_spec.js b/spec/frontend/repository/components/blob_header_edit_spec.js new file mode 100644 index 00000000000..c0eb7c523c4 --- /dev/null +++ b/spec/frontend/repository/components/blob_header_edit_spec.js @@ -0,0 +1,82 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue'; +import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; + +const DEFAULT_PROPS = { + editPath: 'some_file.js/edit', + webIdePath: 'some_file.js/ide/edit', +}; + +describe('BlobHeaderEdit component', () => { + let wrapper; + + const createComponent = (consolidatedEditButton = false, props = {}) => { + wrapper = shallowMount(BlobHeaderEdit, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: { + glFeatures: { + consolidatedEditButton, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findButtons = () => wrapper.findAll(GlButton); + const findEditButton = () => findButtons().at(0); + const findWebIdeButton = () => findButtons().at(1); + const findWebIdeLink = () => wrapper.find(WebIdeLink); + + it('renders component', () => { + createComponent(); + + const { editPath, webIdePath } = DEFAULT_PROPS; + + expect(wrapper.props()).toMatchObject({ + editPath, + webIdePath, + }); + }); + + it('renders both buttons', () => { + createComponent(); + + expect(findButtons()).toHaveLength(2); + }); + + it('renders the Edit button', () => { + createComponent(); + + expect(findEditButton().attributes('href')).toBe(DEFAULT_PROPS.editPath); + expect(findEditButton().text()).toBe('Edit'); + expect(findEditButton()).not.toBeDisabled(); + }); + + it('renders the Web IDE button', () => { + createComponent(); + + expect(findWebIdeButton().attributes('href')).toBe(DEFAULT_PROPS.webIdePath); + expect(findWebIdeButton().text()).toBe('Web IDE'); + expect(findWebIdeButton()).not.toBeDisabled(); + }); + + it('renders WebIdeLink component', () => { + createComponent(true); + + const { editPath: editUrl, webIdePath: webIdeUrl } = DEFAULT_PROPS; + + expect(findWebIdeLink().props()).toMatchObject({ + editUrl, + webIdeUrl, + isBlob: true, + }); + }); +}); diff --git a/spec/frontend/repository/components/blob_replace_spec.js b/spec/frontend/repository/components/blob_replace_spec.js new file mode 100644 index 00000000000..4a6f147da22 --- /dev/null +++ b/spec/frontend/repository/components/blob_replace_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import BlobReplace from '~/repository/components/blob_replace.vue'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; + +const DEFAULT_PROPS = { + name: 'some name', + path: 'some/path', + canPushCode: true, + replacePath: 'some/replace/path', +}; + +const DEFAULT_INJECT = { + targetBranch: 'master', + originalBranch: 'master', +}; + +describe('BlobReplace component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(BlobReplace, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: { + ...DEFAULT_INJECT, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); + + it('renders component', () => { + createComponent(); + + const { name, path } = DEFAULT_PROPS; + + expect(wrapper.props()).toMatchObject({ + name, + path, + }); + }); + + it('renders UploadBlobModal', () => { + createComponent(); + + const { targetBranch, originalBranch } = DEFAULT_INJECT; + const { name, path, canPushCode, replacePath } = DEFAULT_PROPS; + const title = `Replace ${name}`; + + expect(findUploadBlobModal().props()).toMatchObject({ + modalTitle: title, + commitMessage: title, + targetBranch, + originalBranch, + canPushCode, + path, + replacePath, + primaryBtnText: 'Replace file', + }); + }); +}); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 6ba6f993db1..da28c9873d9 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -1,5 +1,6 @@ import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import TableRow from '~/repository/components/table/row.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; @@ -18,6 +19,10 @@ function factory(propsData = {}) { name: propsData.path, projectPath: 'gitlab-org/gitlab-ce', url: `https://test.com`, + totalEntries: 10, + }, + directives: { + GlHoverLoad: createMockDirective(), }, provide: { glFeatures: { refactorBlobViewer: true }, @@ -34,6 +39,8 @@ function factory(propsData = {}) { } describe('Repository table row component', () => { + const findRouterLink = () => vm.find(RouterLinkStub); + afterEach(() => { vm.destroy(); }); @@ -81,6 +88,21 @@ describe('Repository table row component', () => { }); }); + it('renders a gl-hover-load directive', () => { + factory({ + id: '1', + sha: '123', + path: 'test', + type: 'blob', + currentPath: '/', + }); + + const hoverLoadDirective = getBinding(findRouterLink().element, 'gl-hover-load'); + + expect(hoverLoadDirective).not.toBeUndefined(); + expect(hoverLoadDirective.value).toBeInstanceOf(Function); + }); + it.each` type | component | componentName ${'tree'} | ${RouterLinkStub} | ${'RouterLink'} diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 2930e39df8a..d397bc185e2 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -1,7 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; -import TreeContent, { INITIAL_FETCH_COUNT } from '~/repository/components/tree_content.vue'; +import TreeContent from '~/repository/components/tree_content.vue'; +import { TREE_INITIAL_FETCH_COUNT } from '~/repository/constants'; let vm; let $apollo; @@ -128,7 +129,7 @@ describe('Repository table component', () => { it('has limit of 1000 files on initial load', () => { factory('/'); - expect(INITIAL_FETCH_COUNT * vm.vm.pageSize).toBe(1000); + expect(TREE_INITIAL_FETCH_COUNT * vm.vm.pageSize).toBe(1000); }); }); }); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index ec85d5666fb..d93b1d7e5f1 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -200,4 +200,84 @@ describe('UploadBlobModal', () => { }); }, ); + + describe('blob file submission type', () => { + const submitForm = async () => { + wrapper.vm.uploadFile = jest.fn(); + wrapper.vm.replaceFile = jest.fn(); + wrapper.vm.submitForm(); + await wrapper.vm.$nextTick(); + }; + + const submitRequest = async () => { + mock = new MockAdapter(axios); + findModal().vm.$emit('primary', mockEvent); + await waitForPromises(); + }; + + describe('upload blob file', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the default "Upload New File" modal title ', () => { + expect(findModal().props('title')).toBe('Upload New File'); + }); + + it('display the defaul primary button text', () => { + expect(findModal().props('actionPrimary').text).toBe('Upload file'); + }); + + it('calls the default uploadFile when the form submit', async () => { + await submitForm(); + + expect(wrapper.vm.uploadFile).toHaveBeenCalled(); + expect(wrapper.vm.replaceFile).not.toHaveBeenCalled(); + }); + + it('makes a POST request', async () => { + await submitRequest(); + + expect(mock.history.put).toHaveLength(0); + expect(mock.history.post).toHaveLength(1); + }); + }); + + describe('replace blob file', () => { + const modalTitle = 'Replace foo.js'; + const replacePath = 'replace-path'; + const primaryBtnText = 'Replace file'; + + beforeEach(() => { + createComponent({ + modalTitle, + replacePath, + primaryBtnText, + }); + }); + + it('displays the passed modal title', () => { + expect(findModal().props('title')).toBe(modalTitle); + }); + + it('display the passed primary button text', () => { + expect(findModal().props('actionPrimary').text).toBe(primaryBtnText); + }); + + it('calls the replaceFile when the form submit', async () => { + await submitForm(); + + expect(wrapper.vm.replaceFile).toHaveBeenCalled(); + expect(wrapper.vm.uploadFile).not.toHaveBeenCalled(); + }); + + it('makes a PUT request', async () => { + await submitRequest(); + + expect(mock.history.put).toHaveLength(1); + expect(mock.history.post).toHaveLength(0); + expect(mock.history.put[0].url).toBe(replacePath); + }); + }); + }); }); diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index a842053caad..8cabf902a4f 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -69,6 +69,21 @@ describe('fetchLogsTree', () => { mock.restore(); }); + it('persists the offset for a given page if offset is larger than maximum offset', async () => { + await fetchLogsTree(client, 'path', '1000', resolver, 900).then(() => {}); + + await fetchLogsTree(client, 'path', '1100', resolver, 1200).then(() => { + expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/-/refs/main/logs_tree/path', { + params: { format: 'json', offset: 975 }, + }); + }); + }); + + it('does not call axios get if offset is larger than the maximum offset', () => + fetchLogsTree(client, '', '1000', resolver, 900).then(() => { + expect(axios.get).not.toHaveBeenCalled(); + })); + it('calls axios get', () => fetchLogsTree(client, '', '0', resolver).then(() => { expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/-/refs/main/logs_tree/', { diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js new file mode 100644 index 00000000000..12651a82a0c --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -0,0 +1,201 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; +import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql'; +import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; +import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; + +const mockId = '1'; + +const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; + +describe('RunnerTypeCell', () => { + let wrapper; + let mutate; + + const findEditBtn = () => wrapper.findByTestId('edit-runner'); + const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); + const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); + + const createComponent = ({ active = true } = {}, options) => { + wrapper = extendedWrapper( + shallowMount(RunnerActionCell, { + propsData: { + runner: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + active, + }, + }, + mocks: { + $apollo: { + mutate, + }, + }, + ...options, + }), + ); + }; + + beforeEach(() => { + mutate = jest.fn(); + }); + + afterEach(() => { + mutate.mockReset(); + wrapper.destroy(); + }); + + it('Displays the runner edit link with the correct href', () => { + createComponent(); + + expect(findEditBtn().attributes('href')).toBe('/admin/runners/1'); + }); + + describe.each` + state | label | icon | isActive | newActiveValue + ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false} + ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true} + `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => { + beforeEach(() => { + mutate.mockResolvedValue({ + data: { + runnerUpdate: { + runner: { + id: `gid://gitlab/Ci::Runner/1`, + __typename: 'CiRunner', + }, + }, + }, + }); + + createComponent({ active: isActive }); + }); + + it(`Displays a ${icon} button`, () => { + expect(findToggleActiveBtn().props('loading')).toBe(false); + expect(findToggleActiveBtn().props('icon')).toBe(icon); + expect(findToggleActiveBtn().attributes('title')).toBe(label); + expect(findToggleActiveBtn().attributes('aria-label')).toBe(label); + }); + + it(`After clicking the ${icon} button, the button has a loading state`, async () => { + await findToggleActiveBtn().vm.$emit('click'); + + expect(findToggleActiveBtn().props('loading')).toBe(true); + }); + + it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => { + await findToggleActiveBtn().vm.$emit('click'); + + expect(findToggleActiveBtn().attributes('title')).toBe(''); + expect(findToggleActiveBtn().attributes('aria-label')).toBe(''); + }); + + describe(`When clicking on the ${icon} button`, () => { + beforeEach(async () => { + await findToggleActiveBtn().vm.$emit('click'); + await waitForPromises(); + }); + + it(`The apollo mutation to set active to ${newActiveValue} is called`, () => { + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + mutation: runnerUpdateMutation, + variables: { + input: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + active: newActiveValue, + }, + }, + }); + }); + + it('The button does not have a loading state', () => { + expect(findToggleActiveBtn().props('loading')).toBe(false); + }); + }); + }); + + describe('When the user clicks a runner', () => { + beforeEach(() => { + createComponent(); + + mutate.mockResolvedValue({ + data: { + runnerDelete: { + runner: { + id: `gid://gitlab/Ci::Runner/1`, + __typename: 'CiRunner', + }, + }, + }, + }); + + jest.spyOn(window, 'confirm'); + }); + + describe('When the user confirms deletion', () => { + beforeEach(async () => { + window.confirm.mockReturnValue(true); + await findDeleteBtn().vm.$emit('click'); + }); + + it('The user sees a confirmation alert', async () => { + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(window.confirm).toHaveBeenCalledWith(expect.any(String)); + }); + + it('The delete mutation is called correctly', () => { + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + mutation: deleteRunnerMutation, + variables: { + input: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + }, + }, + awaitRefetchQueries: true, + refetchQueries: [getRunnersQueryName], + }); + }); + + it('The delete button does not have a loading state', () => { + expect(findDeleteBtn().props('loading')).toBe(false); + expect(findDeleteBtn().attributes('title')).toBe('Remove'); + }); + + it('After the delete button is clicked, loading state is shown', async () => { + await findDeleteBtn().vm.$emit('click'); + + expect(findDeleteBtn().props('loading')).toBe(true); + }); + + it('After the delete button is clicked, stale tooltip is removed', async () => { + await findDeleteBtn().vm.$emit('click'); + + expect(findDeleteBtn().attributes('title')).toBe(''); + }); + }); + + describe('When the user does not confirm deletion', () => { + beforeEach(async () => { + window.confirm.mockReturnValue(false); + await findDeleteBtn().vm.$emit('click'); + }); + + it('The user sees a confirmation alert', () => { + expect(window.confirm).toHaveBeenCalledTimes(1); + }); + + it('The delete mutation is not called', () => { + expect(mutate).toHaveBeenCalledTimes(0); + }); + + it('The delete button does not have a loading state', () => { + expect(findDeleteBtn().props('loading')).toBe(false); + expect(findDeleteBtn().attributes('title')).toBe('Remove'); + }); + }); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_name_cell_spec.js b/spec/frontend/runner/components/cells/runner_name_cell_spec.js new file mode 100644 index 00000000000..26055fc0faf --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_name_cell_spec.js @@ -0,0 +1,42 @@ +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue'; + +const mockId = '1'; +const mockShortSha = '2P6oDVDm'; +const mockDescription = 'runner-1'; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = () => { + wrapper = mount(RunnerNameCell, { + propsData: { + runner: { + id: `gid://gitlab/Ci::Runner/${mockId}`, + shortSha: mockShortSha, + description: mockDescription, + }, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays the runner link with id and short token', () => { + expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`); + expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`); + }); + + it('Displays the runner description', () => { + expect(wrapper.text()).toContain(mockDescription); + }); +}); diff --git a/spec/frontend/runner/components/cells/runner_type_cell_spec.js b/spec/frontend/runner/components/cells/runner_type_cell_spec.js new file mode 100644 index 00000000000..48958a282fc --- /dev/null +++ b/spec/frontend/runner/components/cells/runner_type_cell_spec.js @@ -0,0 +1,48 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue'; +import { INSTANCE_TYPE } from '~/runner/constants'; + +describe('RunnerTypeCell', () => { + let wrapper; + + const findBadges = () => wrapper.findAllComponents(GlBadge); + + const createComponent = ({ runner = {} } = {}) => { + wrapper = mount(RunnerTypeCell, { + propsData: { + runner: { + runnerType: INSTANCE_TYPE, + active: true, + locked: false, + ...runner, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays the runner type', () => { + createComponent(); + + expect(findBadges()).toHaveLength(1); + expect(findBadges().at(0).text()).toBe('shared'); + }); + + it('Displays locked and paused states', () => { + createComponent({ + runner: { + active: false, + locked: true, + }, + }); + + expect(findBadges()).toHaveLength(3); + expect(findBadges().at(0).text()).toBe('shared'); + expect(findBadges().at(1).text()).toBe('locked'); + expect(findBadges().at(2).text()).toBe('paused'); + }); +}); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js new file mode 100644 index 00000000000..61a8f821b30 --- /dev/null +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -0,0 +1,137 @@ +import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE } from '~/runner/constants'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +describe('RunnerList', () => { + let wrapper; + + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); + const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); + + const mockDefaultSort = 'CREATED_DESC'; + const mockOtherSort = 'CONTACTED_DESC'; + const mockFilters = [ + { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }, + { type: 'filtered-search-term', value: { data: '' } }, + ]; + + const createComponent = ({ props = {}, options = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(RunnerFilteredSearchBar, { + propsData: { + value: { + filters: [], + sort: mockDefaultSort, + }, + ...props, + }, + attrs: { namespace: 'runners' }, + stubs: { + FilteredSearch, + GlFilteredSearch, + GlDropdown, + GlDropdownItem, + }, + ...options, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('binds a namespace to the filtered search', () => { + expect(findFilteredSearch().props('namespace')).toBe('runners'); + }); + + it('sets sorting options', () => { + const SORT_OPTIONS_COUNT = 2; + + expect(findSortOptions()).toHaveLength(SORT_OPTIONS_COUNT); + expect(findSortOptions().at(0).text()).toBe('Created date'); + expect(findSortOptions().at(1).text()).toBe('Last contact'); + }); + + it('sets tokens', () => { + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + ]); + }); + + it('fails validation for v-model with the wrong shape', () => { + expect(() => { + createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } }); + }).toThrow('Invalid prop: custom validator check failed'); + + expect(() => { + createComponent({ props: { value: { sort: 'sort' } } }); + }).toThrow('Invalid prop: custom validator check failed'); + }); + + describe('when a search is preselected', () => { + beforeEach(() => { + createComponent({ + props: { + value: { + sort: mockOtherSort, + filters: mockFilters, + }, + }, + }); + }); + + it('filter values are shown', () => { + expect(findGlFilteredSearch().props('value')).toEqual(mockFilters); + }); + + it('sort option is selected', () => { + expect( + findSortOptions() + .filter((w) => w.props('isChecked')) + .at(0) + .text(), + ).toEqual('Last contact'); + }); + }); + + it('when the user sets a filter, the "search" is emitted with filters', () => { + findGlFilteredSearch().vm.$emit('input', mockFilters); + findGlFilteredSearch().vm.$emit('submit'); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + filters: mockFilters, + sort: mockDefaultSort, + pagination: { page: 1 }, + }, + ]); + }); + + it('when the user sets a sorting method, the "search" is emitted with the sort', () => { + findSortOptions().at(1).vm.$emit('click'); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + filters: [], + sort: mockOtherSort, + pagination: { page: 1 }, + }, + ]); + }); +}); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js new file mode 100644 index 00000000000..d88d7b3fbee --- /dev/null +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -0,0 +1,130 @@ +import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerList from '~/runner/components/runner_list.vue'; +import { runnersData } from '../mock_data'; + +const mockRunners = runnersData.data.runners.nodes; +const mockActiveRunnersCount = mockRunners.length; + +describe('RunnerList', () => { + let wrapper; + + const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findTable = () => wrapper.findComponent(GlTable); + const findHeaders = () => wrapper.findAll('th'); + const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]'); + const findCell = ({ row = 0, fieldKey }) => + extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + wrapper = extendedWrapper( + mountFn(RunnerList, { + propsData: { + runners: mockRunners, + activeRunnersCount: mockActiveRunnersCount, + ...props, + }, + }), + ); + }; + + beforeEach(() => { + createComponent({}, mount); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays active runner count', () => { + expect(findActiveRunnersMessage().text()).toBe( + `Runners currently online: ${mockActiveRunnersCount}`, + ); + }); + + it('Displays a large active runner count', () => { + createComponent({ props: { activeRunnersCount: 2000 } }); + + expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000'); + }); + + it('Displays headers', () => { + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + + expect(headerLabels).toEqual([ + 'Type/State', + 'Runner', + 'Version', + 'IP Address', + 'Projects', + 'Jobs', + 'Tags', + 'Last contact', + '', // actions has no label + ]); + }); + + it('Displays a list of runners', () => { + expect(findRows()).toHaveLength(3); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('Displays details of a runner', () => { + const { id, description, version, ipAddress, shortSha } = mockRunners[0]; + + // Badges + expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('specific paused'); + + // Runner identifier + expect(findCell({ fieldKey: 'name' }).text()).toContain( + `#${getIdFromGraphQLId(id)} (${shortSha})`, + ); + expect(findCell({ fieldKey: 'name' }).text()).toContain(description); + + // Other fields: some cells are empty in the first iteration + // See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features + expect(findCell({ fieldKey: 'version' }).text()).toBe(version); + expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); + expect(findCell({ fieldKey: 'projectCount' }).text()).toBe(''); + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe(''); + expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); + expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); + + // Actions + const actions = findCell({ fieldKey: 'actions' }); + + expect(actions.findByTestId('edit-runner').exists()).toBe(true); + expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); + }); + + it('Links to the runner page', () => { + const { id } = mockRunners[0]; + + expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe( + `/admin/runners/${getIdFromGraphQLId(id)}`, + ); + }); + + describe('When data is loading', () => { + it('shows a busy state', () => { + createComponent({ props: { runners: [], loading: true } }); + expect(findTable().attributes('busy')).toBeTruthy(); + }); + + it('when there are no runners, shows an skeleton loader', () => { + createComponent({ props: { runners: [], loading: true } }, mount); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('when there are runners, shows a busy indicator skeleton loader', () => { + createComponent({ props: { loading: true } }, mount); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js new file mode 100644 index 00000000000..ca5c88f6e28 --- /dev/null +++ b/spec/frontend/runner/components/runner_manual_setup_help_spec.js @@ -0,0 +1,84 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; + +const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/'; + +describe('RunnerManualSetupHelp', () => { + let wrapper; + let originalGon; + + const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions); + const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton); + const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title'); + const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url'); + const findRegistrationToken = () => wrapper.findByTestId('registration-token'); + const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(RunnerManualSetupHelp, { + provide: { + runnerInstallHelpPage: mockRunnerInstallHelpPage, + }, + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, + stubs: { + GlSprintf, + }, + }), + ); + }; + + beforeAll(() => { + originalGon = global.gon; + global.gon = { gitlab_url: TEST_HOST }; + }); + + afterAll(() => { + global.gon = originalGon; + }); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Title contains the default runner type', () => { + expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually'); + }); + + it('Title contains the group runner type', () => { + createComponent({ props: { typeName: 'group' } }); + + expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually'); + }); + + it('Runner Install Page link', () => { + expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage); + }); + + it('Displays the coordinator URL token', () => { + expect(findCoordinatorUrl().text()).toBe(TEST_HOST); + expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST); + }); + + it('Displays the registration token', () => { + expect(findRegistrationToken().text()).toBe(mockRegistrationToken); + expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken); + }); + + it('Displays the runner instructions', () => { + expect(findRunnerInstructions().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/runner/components/runner_pagination_spec.js b/spec/frontend/runner/components/runner_pagination_spec.js new file mode 100644 index 00000000000..59feb32dd2a --- /dev/null +++ b/spec/frontend/runner/components/runner_pagination_spec.js @@ -0,0 +1,160 @@ +import { GlPagination } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; + +const mockStartCursor = 'START_CURSOR'; +const mockEndCursor = 'END_CURSOR'; + +describe('RunnerPagination', () => { + let wrapper; + + const findPagination = () => wrapper.findComponent(GlPagination); + + const createComponent = ({ page = 1, hasPreviousPage = false, hasNextPage = true } = {}) => { + wrapper = mount(RunnerPagination, { + propsData: { + value: { + page, + }, + pageInfo: { + hasPreviousPage, + hasNextPage, + startCursor: mockStartCursor, + endCursor: mockEndCursor, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('When on the first page', () => { + beforeEach(() => { + createComponent({ + page: 1, + hasPreviousPage: false, + hasNextPage: true, + }); + }); + + it('Contains the current page information', () => { + expect(findPagination().props('value')).toBe(1); + expect(findPagination().props('prevPage')).toBe(null); + expect(findPagination().props('nextPage')).toBe(2); + }); + + it('Shows prev page disabled', () => { + expect(findPagination().find('[aria-disabled]').text()).toBe('Prev'); + }); + + it('Shows next page link', () => { + expect(findPagination().find('a').text()).toBe('Next'); + }); + + it('Goes to the second page', () => { + findPagination().vm.$emit('input', 2); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + after: mockEndCursor, + page: 2, + }, + ]); + }); + }); + + describe('When in between pages', () => { + beforeEach(() => { + createComponent({ + page: 2, + hasPreviousPage: true, + hasNextPage: true, + }); + }); + + it('Contains the current page information', () => { + expect(findPagination().props('value')).toBe(2); + expect(findPagination().props('prevPage')).toBe(1); + expect(findPagination().props('nextPage')).toBe(3); + }); + + it('Shows the next and previous pages', () => { + const links = findPagination().findAll('a'); + + expect(links).toHaveLength(2); + expect(links.at(0).text()).toBe('Prev'); + expect(links.at(1).text()).toBe('Next'); + }); + + it('Goes to the last page', () => { + findPagination().vm.$emit('input', 3); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + after: mockEndCursor, + page: 3, + }, + ]); + }); + + it('Goes to the first page', () => { + findPagination().vm.$emit('input', 1); + + expect(wrapper.emitted('input')[0]).toEqual([ + { + before: mockStartCursor, + page: 1, + }, + ]); + }); + }); + + describe('When in the last page', () => { + beforeEach(() => { + createComponent({ + page: 3, + hasPreviousPage: true, + hasNextPage: false, + }); + }); + + it('Contains the current page', () => { + expect(findPagination().props('value')).toBe(3); + expect(findPagination().props('prevPage')).toBe(2); + expect(findPagination().props('nextPage')).toBe(null); + }); + + it('Shows next page link', () => { + expect(findPagination().find('a').text()).toBe('Prev'); + }); + + it('Shows next page disabled', () => { + expect(findPagination().find('[aria-disabled]').text()).toBe('Next'); + }); + }); + + describe('When only one page', () => { + beforeEach(() => { + createComponent({ + page: 1, + hasPreviousPage: false, + hasNextPage: false, + }); + }); + + it('does not display pagination', () => { + expect(wrapper.html()).toBe(''); + }); + + it('Contains the current page', () => { + expect(findPagination().props('value')).toBe(1); + }); + + it('Shows no more page buttons', () => { + expect(findPagination().props('prevPage')).toBe(null); + expect(findPagination().props('nextPage')).toBe(null); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_tags_spec.js b/spec/frontend/runner/components/runner_tags_spec.js new file mode 100644 index 00000000000..7bb3f65e4ba --- /dev/null +++ b/spec/frontend/runner/components/runner_tags_spec.js @@ -0,0 +1,64 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTags from '~/runner/components/runner_tags.vue'; + +describe('RunnerTags', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTags, { + propsData: { + tagList: ['tag1', 'tag2'], + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays tags text', () => { + expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2'); + + expect(findBadgesAt(0).text()).toBe('tag1'); + expect(findBadgesAt(1).text()).toBe('tag2'); + }); + + it('Displays tags with correct style', () => { + expect(findBadge().props('size')).toBe('md'); + expect(findBadge().props('variant')).toBe('info'); + }); + + it('Displays tags with small size', () => { + createComponent({ + props: { size: 'sm' }, + }); + + expect(findBadge().props('size')).toBe('sm'); + }); + + it('Displays tags with a variant', () => { + createComponent({ + props: { variant: 'warning' }, + }); + + expect(findBadge().props('variant')).toBe('warning'); + }); + + it('Is empty when there are no tags', () => { + createComponent({ + props: { tagList: null }, + }); + + expect(wrapper.text()).toBe(''); + expect(findBadge().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js new file mode 100644 index 00000000000..5b136a77eeb --- /dev/null +++ b/spec/frontend/runner/components/runner_type_alert_spec.js @@ -0,0 +1,61 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTypeAlert from '~/runner/components/runner_type_alert.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; + +describe('RunnerTypeAlert', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTypeAlert, { + propsData: { + type: INSTANCE_TYPE, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + type | exampleText | anchor | variant + ${INSTANCE_TYPE} | ${'Shared runners are available to every project'} | ${'#shared-runners'} | ${'success'} + ${GROUP_TYPE} | ${'Use Group runners when you want all projects in a group'} | ${'#group-runners'} | ${'success'} + ${PROJECT_TYPE} | ${'You can set up a specific runner to be used by multiple projects'} | ${'#specific-runners'} | ${'info'} + `('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => { + beforeEach(() => { + createComponent({ props: { type } }); + }); + + it('Describes runner type', () => { + expect(wrapper.text()).toMatch(exampleText); + }); + + it(`Shows a ${variant} variant`, () => { + expect(findAlert().props('variant')).toBe(variant); + }); + + it(`Links to anchor "${anchor}"`, () => { + expect(findLink().attributes('href')).toBe(`/help/ci/runners/runners_scope${anchor}`); + }); + }); + + describe('When runner type is not correct', () => { + it('Does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); + + expect(wrapper.html()).toBe(''); + }); + + it('Validation fails for an incorrect type', () => { + expect(() => { + createComponent({ props: { type: 'NOT_A_TYPE' } }); + }).toThrow(); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js index 8e52d3398bd..ab5ccf6390f 100644 --- a/spec/frontend/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/runner/components/runner_type_badge_spec.js @@ -32,8 +32,14 @@ describe('RunnerTypeBadge', () => { expect(findBadge().props('variant')).toBe(variant); }); - it('does not display a badge when type is unknown', () => { - createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + it('validation fails for an incorrect type', () => { + expect(() => { + createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + }).toThrow(); + }); + + it('does not render content when type is missing', () => { + createComponent({ props: { type: undefined } }); expect(findBadge().exists()).toBe(false); }); diff --git a/spec/frontend/runner/components/runner_type_help_spec.js b/spec/frontend/runner/components/runner_type_help_spec.js new file mode 100644 index 00000000000..f0d03282f8e --- /dev/null +++ b/spec/frontend/runner/components/runner_type_help_spec.js @@ -0,0 +1,32 @@ +import { GlBadge } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; + +describe('RunnerTypeHelp', () => { + let wrapper; + + const findBadges = () => wrapper.findAllComponents(GlBadge); + + const createComponent = () => { + wrapper = mount(RunnerTypeHelp); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays each of the runner types', () => { + expect(findBadges().at(0).text()).toBe('shared'); + expect(findBadges().at(1).text()).toBe('group'); + expect(findBadges().at(2).text()).toBe('specific'); + }); + + it('Displays runner states', () => { + expect(findBadges().at(3).text()).toBe('locked'); + expect(findBadges().at(4).text()).toBe('paused'); + }); +}); diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js new file mode 100644 index 00000000000..6333ed7118a --- /dev/null +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -0,0 +1,263 @@ +import { GlForm } from '@gitlab/ui'; +import { createLocalVue, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import RunnerUpdateForm from '~/runner/components/runner_update_form.vue'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + ACCESS_LEVEL_REF_PROTECTED, + ACCESS_LEVEL_NOT_PROTECTED, +} from '~/runner/constants'; +import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; +import { runnerData } from '../mock_data'; + +jest.mock('~/flash'); + +const mockRunner = runnerData.data.runner; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerUpdateForm', () => { + let wrapper; + let runnerUpdateHandler; + + const findForm = () => wrapper.findComponent(GlForm); + const findPausedCheckbox = () => wrapper.findByTestId('runner-field-paused'); + const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected'); + const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged'); + const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked'); + + const findIpInput = () => wrapper.findByTestId('runner-field-ip-address').find('input'); + + const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input'); + const findMaxJobTimeoutInput = () => + wrapper.findByTestId('runner-field-max-timeout').find('input'); + const findTagsInput = () => wrapper.findByTestId('runner-field-tags').find('input'); + + const findSubmit = () => wrapper.find('[type="submit"]'); + const findSubmitDisabledAttr = () => findSubmit().attributes('disabled'); + const submitForm = () => findForm().trigger('submit'); + const submitFormAndWait = () => submitForm().then(waitForPromises); + + const getFieldsModel = () => ({ + active: !findPausedCheckbox().element.checked, + accessLevel: findProtectedCheckbox().element.checked + ? ACCESS_LEVEL_REF_PROTECTED + : ACCESS_LEVEL_NOT_PROTECTED, + runUntagged: findRunUntaggedCheckbox().element.checked, + locked: findLockedCheckbox().element.checked, + ipAddress: findIpInput().element.value, + maximumTimeout: findMaxJobTimeoutInput().element.value || null, + tagList: findTagsInput().element.value.split(',').filter(Boolean), + }); + + const createComponent = ({ props } = {}) => { + wrapper = extendedWrapper( + mount(RunnerUpdateForm, { + localVue, + propsData: { + runner: mockRunner, + ...props, + }, + apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]), + }), + ); + }; + + const expectToHaveSubmittedRunnerContaining = (submittedRunner) => { + expect(runnerUpdateHandler).toHaveBeenCalledTimes(1); + expect(runnerUpdateHandler).toHaveBeenCalledWith({ + input: expect.objectContaining(submittedRunner), + }); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: expect.stringContaining('saved'), + type: FLASH_TYPES.SUCCESS, + }); + + expect(findSubmitDisabledAttr()).toBeUndefined(); + }; + + beforeEach(() => { + runnerUpdateHandler = jest.fn().mockImplementation(({ input }) => { + return Promise.resolve({ + data: { + runnerUpdate: { + runner: { + ...mockRunner, + ...input, + }, + errors: [], + }, + }, + }); + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Form has a submit button', () => { + expect(findSubmit().exists()).toBe(true); + }); + + it('Form fields match data', () => { + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + + it('Form prevent multiple submissions', async () => { + await submitForm(); + + expect(findSubmitDisabledAttr()).toBe('disabled'); + }); + + it('Updates runner with no changes', async () => { + await submitFormAndWait(); + + // Some fields are not submitted + const { ipAddress, runnerType, ...submitted } = mockRunner; + + expectToHaveSubmittedRunnerContaining(submitted); + }); + + describe('When data is being loaded', () => { + beforeEach(() => { + createComponent({ props: { runner: null } }); + }); + + it('Form cannot be submitted', () => { + expect(findSubmit().props('loading')).toBe(true); + }); + + it('Form is updated when data loads', async () => { + wrapper.setProps({ + runner: mockRunner, + }); + + await nextTick(); + + expect(mockRunner).toMatchObject(getFieldsModel()); + }); + }); + + it.each` + runnerType | attrDisabled | outcome + ${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'} + ${GROUP_TYPE} | ${'disabled'} | ${'disabled'} + ${PROJECT_TYPE} | ${undefined} | ${'enabled'} + `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => { + const runner = { ...mockRunner, runnerType }; + createComponent({ props: { runner } }); + + expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled); + }); + + describe('On submit, runner gets updated', () => { + it.each` + test | initialValue | findCheckbox | checked | submitted + ${'pauses'} | ${{ active: true }} | ${findPausedCheckbox} | ${true} | ${{ active: false }} + ${'activates'} | ${{ active: false }} | ${findPausedCheckbox} | ${false} | ${{ active: true }} + ${'unprotects'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} | ${findProtectedCheckbox} | ${true} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} + ${'protects'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} | ${findProtectedCheckbox} | ${false} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} + ${'"runs untagged jobs"'} | ${{ runUntagged: true }} | ${findRunUntaggedCheckbox} | ${false} | ${{ runUntagged: false }} + ${'"runs tagged jobs"'} | ${{ runUntagged: false }} | ${findRunUntaggedCheckbox} | ${true} | ${{ runUntagged: true }} + ${'locks'} | ${{ runnerType: PROJECT_TYPE, locked: true }} | ${findLockedCheckbox} | ${false} | ${{ locked: false }} + ${'unlocks'} | ${{ runnerType: PROJECT_TYPE, locked: false }} | ${findLockedCheckbox} | ${true} | ${{ locked: true }} + `('Checkbox $test runner', async ({ initialValue, findCheckbox, checked, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findCheckbox().setChecked(checked); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + test | initialValue | findInput | value | submitted + ${'description'} | ${{ description: 'Desc. 1' }} | ${findDescriptionInput} | ${'Desc. 2'} | ${{ description: 'Desc. 2' }} + ${'max timeout'} | ${{ maximumTimeout: 36000 }} | ${findMaxJobTimeoutInput} | ${'40000'} | ${{ maximumTimeout: 40000 }} + ${'tags'} | ${{ tagList: ['tag1'] }} | ${findTagsInput} | ${'tag2, tag3'} | ${{ tagList: ['tag2', 'tag3'] }} + `("Field updates runner's $test", async ({ initialValue, findInput, value, submitted }) => { + const runner = { ...mockRunner, ...initialValue }; + createComponent({ props: { runner } }); + + await findInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + + it.each` + value | submitted + ${''} | ${{ tagList: [] }} + ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} + ${'with spaces'} | ${{ tagList: ['with spaces'] }} + ${',,,,, commas'} | ${{ tagList: ['commas'] }} + ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }} + ${' trimmed , trimmed2 '} | ${{ tagList: ['trimmed', 'trimmed2'] }} + `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => { + const runner = { ...mockRunner, tagList: ['tag1'] }; + createComponent({ props: { runner } }); + + await findTagsInput().setValue(value); + await submitFormAndWait(); + + expectToHaveSubmittedRunnerContaining({ + id: runner.id, + ...submitted, + }); + }); + }); + + describe('On error', () => { + beforeEach(() => { + createComponent(); + }); + + it('On network error, error message is shown', async () => { + runnerUpdateHandler.mockRejectedValue(new Error('Something went wrong')); + + await submitFormAndWait(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: 'Network error: Something went wrong', + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + }); + + it('On validation error, error message is shown', async () => { + runnerUpdateHandler.mockResolvedValue({ + data: { + runnerUpdate: { + runner: mockRunner, + errors: ['A value is invalid'], + }, + }, + }); + + await submitFormAndWait(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: 'A value is invalid', + }); + expect(findSubmitDisabledAttr()).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js new file mode 100644 index 00000000000..8f551feca6e --- /dev/null +++ b/spec/frontend/runner/mock_data.js @@ -0,0 +1,6 @@ +// Fixtures generated by: spec/frontend/fixtures/runner.rb +export const runnersData = getJSONFixture('graphql/runner/get_runners.query.graphql.json'); +export const runnersDataPaginated = getJSONFixture( + 'graphql/runner/get_runners.query.graphql.paginated.json', +); +export const runnerData = getJSONFixture('graphql/runner/get_runner.query.graphql.json'); diff --git a/spec/frontend/runner/runner_detail/runner_details_app_spec.js b/spec/frontend/runner/runner_detail/runner_details_app_spec.js index c61cb647ae6..d0bd701458d 100644 --- a/spec/frontend/runner/runner_detail/runner_details_app_spec.js +++ b/spec/frontend/runner/runner_detail/runner_details_app_spec.js @@ -3,12 +3,15 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; -import { INSTANCE_TYPE } from '~/runner/constants'; import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; -const mockRunnerId = '55'; +import { runnerData } from '../mock_data'; + +const mockRunnerGraphqlId = runnerData.data.runner.id; +const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -35,15 +38,7 @@ describe('RunnerDetailsApp', () => { }; beforeEach(async () => { - mockRunnerQuery = jest.fn().mockResolvedValue({ - data: { - runner: { - id: `gid://gitlab/Ci::Runner/${mockRunnerId}`, - runnerType: INSTANCE_TYPE, - __typename: 'CiRunner', - }, - }, - }); + mockRunnerQuery = jest.fn().mockResolvedValue(runnerData); }); afterEach(() => { @@ -54,13 +49,13 @@ describe('RunnerDetailsApp', () => { it('expect GraphQL ID to be requested', async () => { await createComponentWithApollo(); - expect(mockRunnerQuery).toHaveBeenCalledWith({ id: `gid://gitlab/Ci::Runner/${mockRunnerId}` }); + expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); it('displays the runner id', async () => { await createComponentWithApollo(); - expect(wrapper.text()).toContain('Runner #55'); + expect(wrapper.text()).toContain(`Runner #${mockRunnerId}`); }); it('displays the runner type', async () => { diff --git a/spec/frontend/runner/runner_list/runner_list_app_spec.js b/spec/frontend/runner/runner_list/runner_list_app_spec.js new file mode 100644 index 00000000000..dd913df7143 --- /dev/null +++ b/spec/frontend/runner/runner_list/runner_list_app_spec.js @@ -0,0 +1,232 @@ +import * as Sentry from '@sentry/browser'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import { updateHistory } from '~/lib/utils/url_utility'; + +import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; +import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; + +import { + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + INSTANCE_TYPE, + PARAM_KEY_STATUS, + STATUS_ACTIVE, + RUNNER_PAGE_SIZE, +} from '~/runner/constants'; +import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; +import RunnerListApp from '~/runner/runner_list/runner_list_app.vue'; + +import { runnersData, runnersDataPaginated } from '../mock_data'; + +const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +const mockActiveRunnersCount = 2; + +jest.mock('@sentry/browser'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerListApp', () => { + let wrapper; + let mockRunnersQuery; + let originalLocation; + + const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); + const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + + const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getRunnersQuery, mockRunnersQuery]]; + + wrapper = mountFn(RunnerListApp, { + localVue, + apolloProvider: createMockApollo(handlers), + propsData: { + activeRunnersCount: mockActiveRunnersCount, + registrationToken: mockRegistrationToken, + ...props, + }, + }); + }; + + const setQuery = (query) => { + window.location.href = `${TEST_HOST}/admin/runners/${query}`; + window.location.search = query; + }; + + beforeAll(() => { + originalLocation = window.location; + Object.defineProperty(window, 'location', { writable: true, value: { href: '', search: '' } }); + }); + + afterAll(() => { + window.location = originalLocation; + }); + + beforeEach(async () => { + setQuery(''); + + Sentry.withScope.mockImplementation((fn) => { + const scope = { setTag: jest.fn() }; + fn(scope); + }); + + mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); + createComponentWithApollo(); + await waitForPromises(); + }); + + afterEach(() => { + mockRunnersQuery.mockReset(); + wrapper.destroy(); + }); + + it('shows the runners list', () => { + expect(runnersData.data.runners.nodes).toMatchObject(findRunnerList().props('runners')); + }); + + it('requests the runners with no filters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: undefined, + type: undefined, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('shows the runner type help', () => { + expect(findRunnerTypeHelp().exists()).toBe(true); + }); + + it('shows the runner setup instructions', () => { + expect(findRunnerManualSetupHelp().exists()).toBe(true); + expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + window.location.search = `?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`; + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + filters: [ + { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, + ], + sort: 'CREATED_DESC', + pagination: { page: 1 }, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: STATUS_ACTIVE, + type: INSTANCE_TYPE, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(() => { + findRunnerFilteredSearchBar().vm.$emit('input', { + filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }], + sort: CREATED_ASC, + }); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: 'http://test.host/admin/runners/?status[]=ACTIVE&sort=CREATED_ASC', + }); + }); + + it('requests the runners with filters', () => { + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + status: STATUS_ACTIVE, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } }); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('shows a message for no results', async () => { + expect(wrapper.text()).toContain('No runners found'); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponentWithApollo(); + expect(findRunnerList().props('loading')).toBe(true); + }); + + describe('when runners query fails', () => { + beforeEach(async () => { + mockRunnersQuery = jest.fn().mockRejectedValue(new Error()); + createComponentWithApollo(); + + await waitForPromises(); + }); + + it('error is reported to sentry', async () => { + expect(Sentry.withScope).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); + + createComponentWithApollo({ mountFn: mount }); + }); + + it('more pages can be selected', () => { + expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next'); + }); + + it('cannot navigate to the previous page', () => { + expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev'); + }); + + it('navigates to the next page', async () => { + const nextPageBtn = findRunnerPagination().find('a'); + expect(nextPageBtn.text()).toBe('Next'); + + await nextPageBtn.trigger('click'); + + expect(mockRunnersQuery).toHaveBeenLastCalledWith({ + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: runnersDataPaginated.data.runners.pageInfo.endCursor, + }); + }); + }); +}); diff --git a/spec/frontend/runner/runner_list/runner_search_utils_spec.js b/spec/frontend/runner/runner_list/runner_search_utils_spec.js new file mode 100644 index 00000000000..a1f33e9c880 --- /dev/null +++ b/spec/frontend/runner/runner_list/runner_search_utils_spec.js @@ -0,0 +1,239 @@ +import { RUNNER_PAGE_SIZE } from '~/runner/constants'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, +} from '~/runner/runner_list/runner_search_utils'; + +describe('search_params.js', () => { + const examples = [ + { + name: 'a default query', + urlQuery: '', + search: { filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, + graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a single status', + urlQuery: '?status[]=ACTIVE', + search: { + filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a single term text search', + urlQuery: '?search=something', + search: { + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'a two terms text search', + urlQuery: '?search=something+else', + search: { + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: 'else' }, + }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'single instance type', + urlQuery: '?runner_type[]=INSTANCE_TYPE', + search: { + filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'multiple runner status', + urlQuery: '?status[]=ACTIVE&status[]=PAUSED', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'status', value: { data: 'PAUSED', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'multiple status, a single instance type and a non default sort', + urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + sort: 'CREATED_ASC', + first: RUNNER_PAGE_SIZE, + }, + }, + { + name: 'the next page', + urlQuery: '?page=2&after=AFTER_CURSOR', + search: { filters: [], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC' }, + graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, + }, + { + name: 'the previous page', + urlQuery: '?page=2&before=BEFORE_CURSOR', + search: { + filters: [], + pagination: { page: 2, before: 'BEFORE_CURSOR' }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE }, + }, + { + name: + 'the next page filtered by multiple status, a single instance type and a non default sort', + urlQuery: + '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', + search: { + filters: [ + { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, + ], + pagination: { page: 2, after: 'AFTER_CURSOR' }, + sort: 'CREATED_ASC', + }, + graphqlVariables: { + status: 'ACTIVE', + type: 'INSTANCE_TYPE', + sort: 'CREATED_ASC', + after: 'AFTER_CURSOR', + first: RUNNER_PAGE_SIZE, + }, + }, + ]; + + describe('fromUrlQueryToSearch', () => { + examples.forEach(({ name, urlQuery, search }) => { + it(`Converts ${name} to a search object`, () => { + expect(fromUrlQueryToSearch(urlQuery)).toEqual(search); + }); + }); + + it('When search params appear as array, they are concatenated', () => { + expect(fromUrlQueryToSearch('?search[]=my&search[]=text').filters).toEqual([ + { type: 'filtered-search-term', value: { data: 'my' } }, + { type: 'filtered-search-term', value: { data: 'text' } }, + ]); + }); + + it('When a page cannot be parsed as a number, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=NONSENSE&after=AFTER_CURSOR').pagination).toEqual({ + page: 1, + }); + }); + + it('When a page is less than 1, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=0&after=AFTER_CURSOR').pagination).toEqual({ + page: 1, + }); + }); + + it('When a page with no cursor is given, it defaults to `1`', () => { + expect(fromUrlQueryToSearch('?page=2').pagination).toEqual({ + page: 1, + }); + }); + }); + + describe('fromSearchToUrl', () => { + examples.forEach(({ name, urlQuery, search }) => { + it(`Converts ${name} to a url`, () => { + expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`); + }); + }); + + it.each([ + 'http://test.host/?status[]=ACTIVE', + 'http://test.host/?runner_type[]=INSTANCE_TYPE', + 'http://test.host/?search=my_text', + ])('When a filter is removed, it is removed from the URL', (initalUrl) => { + const search = { filters: [], sort: 'CREATED_DESC' }; + const expectedUrl = `http://test.host/`; + + expect(fromSearchToUrl(search, initalUrl)).toEqual(expectedUrl); + }); + + it('When unrelated search parameter is present, it does not get removed', () => { + const initialUrl = `http://test.host/?unrelated=UNRELATED&status[]=ACTIVE`; + const search = { filters: [], sort: 'CREATED_DESC' }; + const expectedUrl = `http://test.host/?unrelated=UNRELATED`; + + expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl); + }); + }); + + describe('fromSearchToVariables', () => { + examples.forEach(({ name, graphqlVariables, search }) => { + it(`Converts ${name} to a GraphQL query variables object`, () => { + expect(fromSearchToVariables(search)).toEqual(graphqlVariables); + }); + }); + + it('When a search param is empty, it gets removed', () => { + expect( + fromSearchToVariables({ + filters: [ + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }), + ).toMatchObject({ + search: '', + }); + + expect( + fromSearchToVariables({ + filters: [ + { + type: 'filtered-search-term', + value: { data: 'something' }, + }, + { + type: 'filtered-search-term', + value: { data: '' }, + }, + ], + }), + ).toMatchObject({ + search: 'something', + }); + }); + }); +}); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index d076997b04a..fbe01f372b0 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -2,47 +2,49 @@ export const MOCK_QUERY = { scope: 'issues', state: 'all', confidential: null, - group_id: 'test_1', + group_id: 1, }; export const MOCK_GROUP = { name: 'test group', - full_name: 'full name test group', - id: 'test_1', + full_name: 'full name / test group', + id: 1, }; export const MOCK_GROUPS = [ { + avatar_url: null, name: 'test group', - full_name: 'full name test group', - id: 'test_1', + full_name: 'full name / test group', + id: 1, }, { + avatar_url: 'https://avatar.com', name: 'test group 2', - full_name: 'full name test group 2', - id: 'test_2', + full_name: 'full name / test group 2', + id: 2, }, ]; export const MOCK_PROJECT = { name: 'test project', namespace: MOCK_GROUP, - nameWithNamespace: 'test group test project', - id: 'test_1', + nameWithNamespace: 'test group / test project', + id: 1, }; export const MOCK_PROJECTS = [ { name: 'test project', namespace: MOCK_GROUP, - name_with_namespace: 'test group test project', - id: 'test_1', + name_with_namespace: 'test group / test project', + id: 1, }, { name: 'test project 2', namespace: MOCK_GROUP, - name_with_namespace: 'test group test project 2', - id: 'test_2', + name_with_namespace: 'test group / test project 2', + id: 2, }, ]; diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index ab622c53387..634661c5843 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -20,9 +20,8 @@ describe('Global Search Store Actions', () => { let mock; let state; - const noCallback = () => {}; - const flashCallback = () => { - expect(createFlash).toHaveBeenCalledTimes(1); + const flashCallback = (callCount) => { + expect(createFlash).toHaveBeenCalledTimes(callCount); createFlash.mockClear(); }; @@ -37,19 +36,21 @@ describe('Global Search Store Actions', () => { }); describe.each` - action | axiosMock | type | expectedMutations | callback - ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback} - ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback} - ${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${noCallback} - ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${flashCallback} - `(`axios calls`, ({ action, axiosMock, type, expectedMutations, callback }) => { + action | axiosMock | type | expectedMutations | flashCallCount + ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${0} + ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${1} + ${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${0} + ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${2} + `(`axios calls`, ({ action, axiosMock, type, expectedMutations, flashCallCount }) => { describe(action.name, () => { describe(`on ${type}`, () => { beforeEach(() => { mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res); }); it(`should dispatch the correct mutations`, () => { - return testAction({ action, state, expectedMutations }).then(() => callback()); + return testAction({ action, state, expectedMutations }).then(() => + flashCallback(flashCallCount), + ); }); }); }); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js new file mode 100644 index 00000000000..e51fe9a4cf9 --- /dev/null +++ b/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js @@ -0,0 +1,97 @@ +import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { MOCK_GROUPS } from 'jest/search/mock_data'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import SearchableDropdownItem from '~/search/topbar/components/searchable_dropdown_item.vue'; +import { GROUP_DATA } from '~/search/topbar/constants'; + +describe('Global Search Searchable Dropdown Item', () => { + let wrapper; + + const defaultProps = { + item: MOCK_GROUPS[0], + selectedItem: MOCK_GROUPS[0], + name: GROUP_DATA.name, + fullName: GROUP_DATA.fullName, + }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(SearchableDropdownItem, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findGlAvatar = () => wrapper.findComponent(GlAvatar); + const findDropdownTitle = () => wrapper.findByTestId('item-title'); + const findDropdownSubtitle = () => wrapper.findByTestId('item-namespace'); + + describe('template', () => { + describe('always', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlDropdownItem', () => { + expect(findGlDropdownItem().exists()).toBe(true); + }); + + it('renders GlAvatar', () => { + expect(findGlAvatar().exists()).toBe(true); + }); + + it('renders Dropdown Title correctly', () => { + const titleEl = findDropdownTitle(); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.text()).toBe(MOCK_GROUPS[0][GROUP_DATA.name]); + }); + + it('renders Dropdown Subtitle correctly', () => { + const subtitleEl = findDropdownSubtitle(); + + expect(subtitleEl.exists()).toBe(true); + expect(subtitleEl.text()).toBe(truncateNamespace(MOCK_GROUPS[0][GROUP_DATA.fullName])); + }); + }); + + describe('when item === selectedItem', () => { + beforeEach(() => { + createComponent({ item: MOCK_GROUPS[0], selectedItem: MOCK_GROUPS[0] }); + }); + + it('marks the dropdown as checked', () => { + expect(findGlDropdownItem().attributes('ischecked')).toBe('true'); + }); + }); + + describe('when item !== selectedItem', () => { + beforeEach(() => { + createComponent({ item: MOCK_GROUPS[0], selectedItem: MOCK_GROUPS[1] }); + }); + + it('marks the dropdown as not checked', () => { + expect(findGlDropdownItem().attributes('ischecked')).toBeUndefined(); + }); + }); + }); + + describe('actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('clicking the dropdown item $emits change with the item', () => { + findGlDropdownItem().vm.$emit('click'); + + expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); + }); + }); +}); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js index 5de948592d4..10d779f0f90 100644 --- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js +++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js @@ -1,20 +1,21 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; -import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; +import SearchableDropdownItem from '~/search/topbar/components/searchable_dropdown_item.vue'; import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); describe('Global Search Searchable Dropdown', () => { let wrapper; const defaultProps = { headerText: GROUP_DATA.headerText, - selectedDisplayValue: GROUP_DATA.selectedDisplayValue, - itemsDisplayValue: GROUP_DATA.itemsDisplayValue, + name: GROUP_DATA.name, + fullName: GROUP_DATA.fullName, loading: false, selectedItem: ANY_OPTION, items: [], @@ -29,7 +30,6 @@ describe('Global Search Searchable Dropdown', () => { }); wrapper = mountFn(SearchableDropdown, { - localVue, store, propsData: { ...defaultProps, @@ -40,17 +40,16 @@ describe('Global Search Searchable Dropdown', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findGlDropdown = () => wrapper.find(GlDropdown); - const findGlDropdownSearch = () => findGlDropdown().find(GlSearchBoxByType); + const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlDropdownSearch = () => findGlDropdown().findComponent(GlSearchBoxByType); const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text'); - const findDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); - const findDropdownItemsText = () => findDropdownItems().wrappers.map((w) => w.text()); - const findAnyDropdownItem = () => findDropdownItems().at(0); - const findFirstGroupDropdownItem = () => findDropdownItems().at(1); - const findLoader = () => wrapper.find(GlSkeletonLoader); + const findSearchableDropdownItems = () => + findGlDropdown().findAllComponents(SearchableDropdownItem); + const findAnyDropdownItem = () => findGlDropdown().findComponent(GlDropdownItem); + const findFirstGroupDropdownItem = () => findSearchableDropdownItems().at(0); + const findLoader = () => wrapper.findComponent(GlSkeletonLoader); describe('template', () => { beforeEach(() => { @@ -93,9 +92,12 @@ describe('Global Search Searchable Dropdown', () => { expect(findLoader().exists()).toBe(false); }); - it('renders an instance for each namespace', () => { - const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map((n) => n.full_name)); - expect(findDropdownItemsText()).toStrictEqual(resultsIncludeAny); + it('renders the Any Dropdown', () => { + expect(findAnyDropdownItem().exists()).toBe(true); + }); + + it('renders SearchableDropdownItem for each item', () => { + expect(findSearchableDropdownItems()).toHaveLength(MOCK_GROUPS.length); }); }); @@ -108,18 +110,12 @@ describe('Global Search Searchable Dropdown', () => { expect(findLoader().exists()).toBe(true); }); - it('renders only Any in dropdown', () => { - expect(findDropdownItemsText()).toStrictEqual(['Any']); - }); - }); - - describe('when item is selected', () => { - beforeEach(() => { - createComponent({}, { items: MOCK_GROUPS, selectedItem: MOCK_GROUPS[0] }); + it('renders the Any Dropdown', () => { + expect(findAnyDropdownItem().exists()).toBe(true); }); - it('marks the dropdown as checked', () => { - expect(findFirstGroupDropdownItem().attributes('ischecked')).toBe('true'); + it('does not render SearchableDropdownItem', () => { + expect(findSearchableDropdownItems()).toHaveLength(0); }); }); }); @@ -140,8 +136,8 @@ describe('Global Search Searchable Dropdown', () => { createComponent({}, { selectedItem: MOCK_GROUP }, mount); }); - it('sets dropdown text to the selectedItem selectedDisplayValue', () => { - expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.selectedDisplayValue]); + it('sets dropdown text to the selectedItem name', () => { + expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.name]); }); }); }); @@ -158,8 +154,8 @@ describe('Global Search Searchable Dropdown', () => { expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]); }); - it('clicking result dropdown item $emits @change with result', () => { - findFirstGroupDropdownItem().vm.$emit('click'); + it('on SearchableDropdownItem @change, the wrapper $emits change with the item', () => { + findFirstGroupDropdownItem().vm.$emit('change', MOCK_GROUPS[0]); expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); }); diff --git a/spec/frontend/security_configuration/components/redesigned_app_spec.js b/spec/frontend/security_configuration/components/redesigned_app_spec.js new file mode 100644 index 00000000000..7e27a3e1108 --- /dev/null +++ b/spec/frontend/security_configuration/components/redesigned_app_spec.js @@ -0,0 +1,232 @@ +import { GlTab } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + SAST_NAME, + SAST_SHORT_NAME, + SAST_DESCRIPTION, + SAST_HELP_PATH, + SAST_CONFIG_HELP_PATH, + LICENSE_COMPLIANCE_NAME, + LICENSE_COMPLIANCE_DESCRIPTION, + LICENSE_COMPLIANCE_HELP_PATH, +} from '~/security_configuration/components/constants'; +import FeatureCard from '~/security_configuration/components/feature_card.vue'; +import RedesignedSecurityConfigurationApp, { + i18n, +} from '~/security_configuration/components/redesigned_app.vue'; +import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; +import { + REPORT_TYPE_LICENSE_COMPLIANCE, + REPORT_TYPE_SAST, +} from '~/vue_shared/security_reports/constants'; + +const upgradePath = '/upgrade'; + +describe('redesigned App component', () => { + let wrapper; + let userCalloutDismissSpy; + + const createComponent = ({ shouldShowCallout = true, ...propsData }) => { + userCalloutDismissSpy = jest.fn(); + + wrapper = extendedWrapper( + mount(RedesignedSecurityConfigurationApp, { + propsData, + provide: { + upgradePath, + }, + stubs: { + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, + }), + ); + }; + + const findMainHeading = () => wrapper.find('h1'); + const findTab = () => wrapper.findComponent(GlTab); + const findTabs = () => wrapper.findAllComponents(GlTab); + const findByTestId = (id) => wrapper.findByTestId(id); + const findFeatureCards = () => wrapper.findAllComponents(FeatureCard); + const findComplianceViewHistoryLink = () => findByTestId('compliance-view-history-link'); + const findSecurityViewHistoryLink = () => findByTestId('security-view-history-link'); + const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner); + + const securityFeaturesMock = [ + { + name: SAST_NAME, + shortName: SAST_SHORT_NAME, + description: SAST_DESCRIPTION, + helpPath: SAST_HELP_PATH, + configurationHelpPath: SAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST, + available: true, + }, + ]; + + const complianceFeaturesMock = [ + { + name: LICENSE_COMPLIANCE_NAME, + description: LICENSE_COMPLIANCE_DESCRIPTION, + helpPath: LICENSE_COMPLIANCE_HELP_PATH, + type: REPORT_TYPE_LICENSE_COMPLIANCE, + configurationHelpPath: LICENSE_COMPLIANCE_HELP_PATH, + }, + ]; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('basic structure', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + }); + }); + + it('renders main-heading with correct text', () => { + const mainHeading = findMainHeading(); + expect(mainHeading).toExist(); + expect(mainHeading.text()).toContain('Security Configuration'); + }); + + it('renders GlTab Component ', () => { + expect(findTab()).toExist(); + }); + + it('renders right amount of tabs with correct title ', () => { + expect(findTabs()).toHaveLength(2); + }); + + it('renders security-testing tab', () => { + expect(findByTestId('security-testing-tab').exists()).toBe(true); + }); + + it('renders compliance-testing tab', () => { + expect(findByTestId('compliance-testing-tab').exists()).toBe(true); + }); + + it('renders right amount of feature cards for given props with correct props', () => { + const cards = findFeatureCards(); + expect(cards).toHaveLength(2); + expect(cards.at(0).props()).toEqual({ feature: securityFeaturesMock[0] }); + expect(cards.at(1).props()).toEqual({ feature: complianceFeaturesMock[0] }); + }); + + it('should not show latest pipeline link when latestPipelinePath is not defined', () => { + expect(findByTestId('latest-pipeline-info').exists()).toBe(false); + }); + + it('should not show configuration History Link when gitlabCiPresent & gitlabCiHistoryPath are not defined', () => { + expect(findComplianceViewHistoryLink().exists()).toBe(false); + expect(findSecurityViewHistoryLink().exists()).toBe(false); + }); + }); + + describe('upgrade banner', () => { + const makeAvailable = (available) => (feature) => ({ ...feature, available }); + + describe('given at least one unavailable feature', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)), + }); + }); + + it('renders the banner', () => { + expect(findUpgradeBanner().exists()).toBe(true); + }); + + it('calls the dismiss callback when closing the banner', () => { + expect(userCalloutDismissSpy).not.toHaveBeenCalled(); + + findUpgradeBanner().vm.$emit('close'); + + expect(userCalloutDismissSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('given at least one unavailable feature, but banner is already dismissed', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)), + shouldShowCallout: false, + }); + }); + + it('does not render the banner', () => { + expect(findUpgradeBanner().exists()).toBe(false); + }); + }); + + describe('given all features are available', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock.map(makeAvailable(true)), + augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(true)), + }); + }); + + it('does not render the banner', () => { + expect(findUpgradeBanner().exists()).toBe(false); + }); + }); + }); + + describe('when given latestPipelinePath props', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + latestPipelinePath: 'test/path', + }); + }); + + it('should show latest pipeline info on the security tab with correct link when latestPipelinePath is defined', () => { + const latestPipelineInfoSecurity = findByTestId('latest-pipeline-info-security'); + + expect(latestPipelineInfoSecurity.exists()).toBe(true); + expect(latestPipelineInfoSecurity.text()).toMatchInterpolatedText( + i18n.securityTestingDescription, + ); + expect(latestPipelineInfoSecurity.find('a').attributes('href')).toBe('test/path'); + }); + + it('should show latest pipeline info on the compliance tab with correct link when latestPipelinePath is defined', () => { + const latestPipelineInfoCompliance = findByTestId('latest-pipeline-info-compliance'); + + expect(latestPipelineInfoCompliance.exists()).toBe(true); + expect(latestPipelineInfoCompliance.text()).toMatchInterpolatedText( + i18n.securityTestingDescription, + ); + expect(latestPipelineInfoCompliance.find('a').attributes('href')).toBe('test/path'); + }); + }); + + describe('given gitlabCiPresent & gitlabCiHistoryPath props', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + gitlabCiPresent: true, + gitlabCiHistoryPath: 'test/historyPath', + }); + }); + + it('should show configuration History Link', () => { + expect(findComplianceViewHistoryLink().exists()).toBe(true); + expect(findSecurityViewHistoryLink().exists()).toBe(true); + + expect(findComplianceViewHistoryLink().attributes('href')).toBe('test/historyPath'); + expect(findSecurityViewHistoryLink().attributes('href')).toBe('test/historyPath'); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/section_layout_spec.js b/spec/frontend/security_configuration/components/section_layout_spec.js new file mode 100644 index 00000000000..75da380bbb8 --- /dev/null +++ b/spec/frontend/security_configuration/components/section_layout_spec.js @@ -0,0 +1,49 @@ +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import SectionLayout from '~/security_configuration/components/section_layout.vue'; + +describe('Section Layout component', () => { + let wrapper; + + const createComponent = (propsData) => { + wrapper = extendedWrapper( + mount(SectionLayout, { + propsData, + scopedSlots: { + description: '<span>foo</span>', + features: '<span>bar</span>', + }, + }), + ); + }; + + const findHeading = () => wrapper.find('h2'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('basic structure', () => { + beforeEach(() => { + createComponent({ heading: 'testheading' }); + }); + + const slots = { + description: 'foo', + features: 'bar', + }; + + it('should render heading when passed in as props', () => { + expect(findHeading().exists()).toBe(true); + expect(findHeading().text()).toBe('testheading'); + }); + + Object.keys(slots).forEach((slot) => { + it('renders the slots', () => { + const slotContent = slots[slot]; + createComponent({ heading: '' }); + expect(wrapper.text()).toContain(slotContent); + }); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/upgrade_banner_spec.js b/spec/frontend/security_configuration/components/upgrade_banner_spec.js new file mode 100644 index 00000000000..cf7945343af --- /dev/null +++ b/spec/frontend/security_configuration/components/upgrade_banner_spec.js @@ -0,0 +1,60 @@ +import { GlBanner } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue'; + +const upgradePath = '/upgrade'; + +describe('UpgradeBanner component', () => { + let wrapper; + let closeSpy; + + const createComponent = (propsData) => { + closeSpy = jest.fn(); + + wrapper = shallowMountExtended(UpgradeBanner, { + provide: { + upgradePath, + }, + propsData, + listeners: { + close: closeSpy, + }, + }); + }; + + const findGlBanner = () => wrapper.findComponent(GlBanner); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('passes the expected props to GlBanner', () => { + expect(findGlBanner().props()).toMatchObject({ + title: UpgradeBanner.i18n.title, + buttonText: UpgradeBanner.i18n.buttonText, + buttonLink: upgradePath, + }); + }); + + it('renders the list of benefits', () => { + const wrapperText = wrapper.text(); + + expect(wrapperText).toContain('GitLab Ultimate checks your application'); + expect(wrapperText).toContain('statistics in the merge request'); + expect(wrapperText).toContain('statistics across projects'); + expect(wrapperText).toContain('Runtime security metrics'); + expect(wrapperText).toContain('risk analysis and remediation'); + }); + + it(`re-emits GlBanner's close event`, () => { + expect(closeSpy).not.toHaveBeenCalled(); + + wrapper.findComponent(GlBanner).vm.$emit('close'); + + expect(closeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js new file mode 100644 index 00000000000..6ad167cadda --- /dev/null +++ b/spec/frontend/security_configuration/utils_spec.js @@ -0,0 +1,81 @@ +import { augmentFeatures } from '~/security_configuration/utils'; + +const mockSecurityFeatures = [ + { + name: 'SAST', + type: 'SAST', + }, +]; + +const mockComplianceFeatures = [ + { + name: 'LICENSE_COMPLIANCE', + type: 'LICENSE_COMPLIANCE', + }, +]; + +const mockFeaturesWithSecondary = [ + { + name: 'DAST', + type: 'DAST', + secondary: { + type: 'DAST PROFILES', + name: 'DAST PROFILES', + }, + }, +]; + +const mockInvalidCustomFeature = [ + { + foo: 'bar', + }, +]; + +const mockValidCustomFeature = [ + { + name: 'SAST', + type: 'SAST', + customfield: 'customvalue', + }, +]; + +const expectedOutputDefault = { + augmentedSecurityFeatures: mockSecurityFeatures, + augmentedComplianceFeatures: mockComplianceFeatures, +}; + +const expectedOutputSecondary = { + augmentedSecurityFeatures: mockSecurityFeatures, + augmentedComplianceFeatures: mockFeaturesWithSecondary, +}; + +const expectedOutputCustomFeature = { + augmentedSecurityFeatures: mockValidCustomFeature, + augmentedComplianceFeatures: mockComplianceFeatures, +}; + +describe('returns an object with augmentedSecurityFeatures and augmentedComplianceFeatures when', () => { + it('given an empty array', () => { + expect(augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, [])).toEqual( + expectedOutputDefault, + ); + }); + + it('given an invalid populated array', () => { + expect( + augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockInvalidCustomFeature), + ).toEqual(expectedOutputDefault); + }); + + it('features have secondary key', () => { + expect(augmentFeatures(mockSecurityFeatures, mockFeaturesWithSecondary, [])).toEqual( + expectedOutputSecondary, + ); + }); + + it('given a valid populated array', () => { + expect( + augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockValidCustomFeature), + ).toEqual(expectedOutputCustomFeature); + }); +}); diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index 226e580a8e8..523f4e88985 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -45,7 +45,9 @@ exports[`self monitor component When the self monitor project has not been creat Enabling this feature creates a project that can be used to monitor the health of your instance. </p> - <gl-form-group-stub> + <gl-form-group-stub + labeldescription="" + > <gl-toggle-stub label="Create Project" labelposition="top" diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap index 33df3a66fcd..36f6746b754 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -11,7 +11,7 @@ exports[`EmptyStateComponent should render content 1`] = ` <p>In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub> </p> <div class=\\"gl-display-flex gl-flex-wrap gl-justify-content-center\\"> - <gl-button-stub category=\\"primary\\" variant=\\"confirm\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" href=\\"/clusters\\" class=\\"gl-mb-3 gl-mx-2\\">Install Knative</gl-button-stub> + <!----> <!----> </div> </div> diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js index d5b187452c6..1b93fd784e1 100644 --- a/spec/frontend/serverless/components/missing_prometheus_spec.js +++ b/spec/frontend/serverless/components/missing_prometheus_spec.js @@ -21,7 +21,7 @@ describe('missingPrometheusComponent', () => { const { vm } = wrapper; expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain( - 'Function invocation metrics require Prometheus to be installed first.', + 'Function invocation metrics require the Prometheus cluster integration.', ); expect(wrapper.find(GlButton).attributes('variant')).toBe('success'); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index 82fc06e1166..3ff6d1f9597 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -2,7 +2,8 @@ import { GlModal, GlFormCheckbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { initEmojiMock } from 'helpers/emoji'; import * as UserApi from '~/api/user_api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import EmojiPicker from '~/emoji/components/picker.vue'; +import createFlash from '~/flash'; import SetStatusModalWrapper, { AVAILABILITY_STATUS, } from '~/set_status_modal/set_status_modal_wrapper.vue'; @@ -25,7 +26,7 @@ describe('SetStatusModalWrapper', () => { defaultEmoji, }; - const createComponent = (props = {}) => { + const createComponent = (props = {}, improvedEmojiPicker = false) => { return shallowMount(SetStatusModalWrapper, { propsData: { ...defaultProps, @@ -34,6 +35,9 @@ describe('SetStatusModalWrapper', () => { mocks: { $toast, }, + provide: { + glFeatures: { improvedEmojiPicker }, + }, }); }; @@ -106,6 +110,20 @@ describe('SetStatusModalWrapper', () => { }); }); + describe('improvedEmojiPicker is true', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({}, true); + return initModal(); + }); + + it('sets emojiTag when clicking in emoji picker', async () => { + await wrapper.findComponent(EmojiPicker).vm.$emit('click', 'thumbsup'); + + expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"'); + }); + }); + describe('with no currentMessage set', () => { beforeEach(async () => { mockEmoji = await initEmojiMock(); @@ -271,9 +289,9 @@ describe('SetStatusModalWrapper', () => { findModal().vm.$emit('ok'); await wrapper.vm.$nextTick(); - expect(createFlash).toHaveBeenCalledWith( - "Sorry, we weren't able to set your status. Please try again later.", - ); + expect(createFlash).toHaveBeenCalledWith({ + message: "Sorry, we weren't able to set your status. Please try again later.", + }); }); }); }); diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js index 74dce499999..be27a800418 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/assignees_spec.js @@ -19,7 +19,7 @@ describe('Assignee component', () => { }); }; - const findComponentTextNoUsers = () => wrapper.find('.assign-yourself'); + const findComponentTextNoUsers = () => wrapper.find('[data-testid="no-value"]'); const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *'); afterEach(() => { @@ -64,7 +64,7 @@ describe('Assignee component', () => { }); jest.spyOn(wrapper.vm, '$emit'); - wrapper.find('.assign-yourself .btn-link').trigger('click'); + wrapper.find('[data-testid="assign-yourself"]').trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted('assign-self')).toBeTruthy(); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js index cfbe7227915..b738d931040 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js @@ -4,11 +4,16 @@ import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_ describe('Sidebar invite members component', () => { let wrapper; + const issuableType = 'issue'; const findDirectInviteLink = () => wrapper.findComponent(InviteMembersTrigger); const createComponent = () => { - wrapper = shallowMount(SidebarInviteMembers); + wrapper = shallowMount(SidebarInviteMembers, { + propsData: { + issuableType, + }, + }); }; afterEach(() => { @@ -23,5 +28,9 @@ describe('Sidebar invite members component', () => { it('renders a direct link to project members path', () => { expect(findDirectInviteLink().exists()).toBe(true); }); + + it('has expected attributes on the trigger', () => { + expect(findDirectInviteLink().props('triggerSource')).toBe('issue-assignee-dropdown'); + }); }); }); diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js index 91cbcc6cc27..619e89beb23 100644 --- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js @@ -22,6 +22,10 @@ describe('Sidebar date Widget', () => { let fakeApollo; const date = '2021-04-15'; + window.gon = { + first_day_of_week: 1, + }; + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findPopoverIcon = () => wrapper.find('[data-testid="inherit-date-popover"]'); const findDatePicker = () => wrapper.find(GlDatepicker); @@ -119,11 +123,12 @@ describe('Sidebar date Widget', () => { expect(wrapper.emitted('dueDateUpdated')).toEqual([[date]]); }); - it('uses a correct prop to set the initial date for GlDatePicker', () => { + it('uses a correct prop to set the initial date and first day of the week for GlDatePicker', () => { expect(findDatePicker().props()).toMatchObject({ value: null, autocomplete: 'off', defaultDate: expect.any(Object), + firstDay: window.gon.first_day_of_week, }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js new file mode 100644 index 00000000000..8d58854b013 --- /dev/null +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -0,0 +1,503 @@ +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLink, + GlSearchBoxByType, + GlFormInput, + GlLoadingIcon, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; +import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { IssuableAttributeType } from '~/sidebar/constants'; +import projectIssueMilestoneMutation from '~/sidebar/queries/project_issue_milestone.mutation.graphql'; +import projectIssueMilestoneQuery from '~/sidebar/queries/project_issue_milestone.query.graphql'; +import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; + +import { + mockIssue, + mockProjectMilestonesResponse, + noCurrentMilestoneResponse, + mockMilestoneMutationResponse, + mockMilestone2, + emptyProjectMilestonesResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('SidebarDropdownWidget', () => { + let wrapper; + let mockApollo; + + const promiseData = { issuableSetAttribute: { issue: { attribute: { id: '123' } } } }; + const firstErrorMsg = 'first error'; + const promiseWithErrors = { + ...promiseData, + issuableSetAttribute: { ...promiseData.issuableSetAttribute, errors: [firstErrorMsg] }, + }; + + const mutationSuccess = () => jest.fn().mockResolvedValue({ data: promiseData }); + const mutationError = () => + jest.fn().mockRejectedValue('Failed to set milestone on this issue. Please try again.'); + const mutationSuccessWithErrors = () => jest.fn().mockResolvedValue({ data: promiseWithErrors }); + + const findGlLink = () => wrapper.findComponent(GlLink); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownText = () => wrapper.findComponent(GlDropdownText); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemWithText = (text) => + findAllDropdownItems().wrappers.find((x) => x.text() === text); + + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findEditButton = () => findSidebarEditableItem().find('[data-testid="edit-button"]'); + const findEditableLoadingIcon = () => findSidebarEditableItem().findComponent(GlLoadingIcon); + const findAttributeItems = () => wrapper.findByTestId('milestone-items'); + const findSelectedAttribute = () => wrapper.findByTestId('select-milestone'); + const findNoAttributeItem = () => wrapper.findByTestId('no-milestone-item'); + const findLoadingIconDropdown = () => wrapper.findByTestId('loading-icon-dropdown'); + + const waitForDropdown = async () => { + // BDropdown first changes its `visible` property + // in a requestAnimationFrame callback. + // It then emits `shown` event in a watcher for `visible` + // Hence we need both of these: + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + + const waitForApollo = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + // Used with createComponentWithApollo which uses 'mount' + const clickEdit = async () => { + await findEditButton().trigger('click'); + + await waitForDropdown(); + + // We should wait for attributes list to be fetched. + await waitForApollo(); + }; + + // Used with createComponent which shallow mounts components + const toggleDropdown = async () => { + wrapper.vm.$refs.editable.expand(); + + await waitForDropdown(); + }; + + const createComponentWithApollo = async ({ + requestHandlers = [], + projectMilestonesSpy = jest.fn().mockResolvedValue(mockProjectMilestonesResponse), + currentMilestoneSpy = jest.fn().mockResolvedValue(noCurrentMilestoneResponse), + } = {}) => { + localVue.use(VueApollo); + mockApollo = createMockApollo([ + [projectMilestonesQuery, projectMilestonesSpy], + [projectIssueMilestoneQuery, currentMilestoneSpy], + ...requestHandlers, + ]); + + wrapper = extendedWrapper( + mount(SidebarDropdownWidget, { + localVue, + provide: { canUpdate: true }, + apolloProvider: mockApollo, + propsData: { + workspacePath: mockIssue.projectPath, + attrWorkspacePath: mockIssue.projectPath, + iid: mockIssue.iid, + issuableType: IssuableType.Issue, + issuableAttribute: IssuableAttributeType.Milestone, + }, + attachTo: document.body, + }), + ); + + await waitForApollo(); + }; + + const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(SidebarDropdownWidget, { + provide: { canUpdate: true }, + data() { + return data; + }, + propsData: { + workspacePath: '', + attrWorkspacePath: '', + iid: '', + issuableType: IssuableType.Issue, + issuableAttribute: IssuableAttributeType.Milestone, + }, + mocks: { + $apollo: { + mutate: mutationPromise(), + queries: { + currentAttribute: { loading: false }, + attributesList: { loading: false }, + ...queries, + }, + }, + }, + stubs: { + SidebarEditableItem, + GlSearchBoxByType, + GlDropdown, + }, + }), + ); + + // We need to mock out `showDropdown` which + // invokes `show` method of BDropdown used inside GlDropdown. + jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when not editing', () => { + beforeEach(() => { + createComponent({ + data: { + currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl' }, + }, + stubs: { + GlDropdown, + SidebarEditableItem, + }, + }); + }); + + it('shows the current attribute', () => { + expect(findSelectedAttribute().text()).toBe('title'); + }); + + it('links to the current attribute', () => { + expect(findGlLink().attributes().href).toBe('webUrl'); + }); + + it('does not show a loading spinner next to the heading', () => { + expect(findEditableLoadingIcon().exists()).toBe(false); + }); + + it('shows a loading spinner while fetching the current attribute', () => { + createComponent({ + queries: { + currentAttribute: { loading: true }, + }, + }); + + expect(findEditableLoadingIcon().exists()).toBe(true); + }); + + it('shows the loading spinner and the title of the selected attribute while updating', () => { + createComponent({ + data: { + updating: true, + selectedTitle: 'Some milestone title', + }, + queries: { + currentAttribute: { loading: false }, + }, + }); + + expect(findEditableLoadingIcon().exists()).toBe(true); + expect(findSelectedAttribute().text()).toBe('Some milestone title'); + }); + + describe('when current attribute does not exist', () => { + it('renders "None" as the selected attribute title', () => { + createComponent(); + + expect(findSelectedAttribute().text()).toBe('None'); + }); + }); + }); + + describe('when a user can edit', () => { + describe('when user is editing', () => { + describe('when rendering the dropdown', () => { + it('shows a loading spinner while fetching a list of attributes', async () => { + createComponent({ + queries: { + attributesList: { loading: true }, + }, + }); + + await toggleDropdown(); + + expect(findLoadingIconDropdown().exists()).toBe(true); + }); + + describe('GlDropdownItem with the right title and id', () => { + const id = 'id'; + const title = 'title'; + + beforeEach(async () => { + createComponent({ + data: { attributesList: [{ id, title }], currentAttribute: { id, title } }, + }); + + await toggleDropdown(); + }); + + it('does not show a loading spinner', () => { + expect(findLoadingIconDropdown().exists()).toBe(false); + }); + + it('renders title $title', () => { + expect(findDropdownItemWithText(title).exists()).toBe(true); + }); + + it('checks the correct dropdown item', () => { + expect( + findAllDropdownItems() + .filter((w) => w.props('isChecked') === true) + .at(0) + .text(), + ).toBe(title); + }); + }); + + describe('when no data is assigned', () => { + beforeEach(async () => { + createComponent(); + + await toggleDropdown(); + }); + + it('finds GlDropdownItem with "No milestone"', () => { + expect(findNoAttributeItem().text()).toBe('No milestone'); + }); + + it('"No milestone" is checked', () => { + expect(findNoAttributeItem().props('isChecked')).toBe(true); + }); + + it('does not render any dropdown item', () => { + expect(findAttributeItems().exists()).toBe(false); + }); + }); + + describe('when clicking on dropdown item', () => { + describe('when currentAttribute is equal to attribute id', () => { + it('does not call setIssueAttribute mutation', async () => { + createComponent({ + data: { + attributesList: [{ id: 'id', title: 'title' }], + currentAttribute: { id: 'id', title: 'title' }, + }, + }); + + await toggleDropdown(); + + findDropdownItemWithText('title').vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(0); + }); + }); + + describe('when currentAttribute is not equal to attribute id', () => { + describe('when error', () => { + const bootstrapComponent = (mutationResp) => { + createComponent({ + data: { + attributesList: [ + { id: '123', title: '123' }, + { id: 'id', title: 'title' }, + ], + currentAttribute: '123', + }, + mutationPromise: mutationResp, + }); + }; + + describe.each` + description | mutationResp | expectedMsg + ${'top-level error'} | ${mutationError} | ${'Failed to set milestone on this issue. Please try again.'} + ${'user-recoverable error'} | ${mutationSuccessWithErrors} | ${firstErrorMsg} + `(`$description`, ({ mutationResp, expectedMsg }) => { + beforeEach(async () => { + bootstrapComponent(mutationResp); + + await toggleDropdown(); + + findDropdownItemWithText('title').vm.$emit('click'); + }); + + it(`calls createFlash with "${expectedMsg}"`, async () => { + await wrapper.vm.$nextTick(); + expect(createFlash).toHaveBeenCalledWith({ + message: expectedMsg, + captureError: true, + error: expectedMsg, + }); + }); + }); + }); + }); + }); + }); + + describe('when a user is searching', () => { + describe('when search result is not found', () => { + it('renders "No milestone found"', async () => { + createComponent(); + + await toggleDropdown(); + + findSearchBox().vm.$emit('input', 'non existing milestones'); + + await wrapper.vm.$nextTick(); + + expect(findDropdownText().text()).toBe('No milestone found'); + }); + }); + }); + }); + }); + + describe('with mock apollo', () => { + let error; + + beforeEach(() => { + jest.spyOn(Sentry, 'captureException'); + error = new Error('mayday'); + }); + + describe("when issuable type is 'issue'", () => { + describe('when dropdown is expanded and user can edit', () => { + let milestoneMutationSpy; + beforeEach(async () => { + milestoneMutationSpy = jest.fn().mockResolvedValue(mockMilestoneMutationResponse); + + await createComponentWithApollo({ + requestHandlers: [[projectIssueMilestoneMutation, milestoneMutationSpy]], + }); + + await clickEdit(); + }); + + it('renders the dropdown on clicking edit', async () => { + expect(findDropdown().isVisible()).toBe(true); + }); + + it('focuses on the input when dropdown is shown', async () => { + expect(document.activeElement).toEqual(wrapper.findComponent(GlFormInput).element); + }); + + describe('when currentAttribute is not equal to attribute id', () => { + describe('when update is successful', () => { + beforeEach(() => { + findDropdownItemWithText(mockMilestone2.title).vm.$emit('click'); + }); + + it('calls setIssueAttribute mutation', () => { + expect(milestoneMutationSpy).toHaveBeenCalledWith({ + iid: mockIssue.iid, + attributeId: getIdFromGraphQLId(mockMilestone2.id), + fullPath: mockIssue.projectPath, + }); + }); + + it('sets the value returned from the mutation to currentAttribute', async () => { + expect(findSelectedAttribute().text()).toBe(mockMilestone2.title); + }); + }); + }); + + describe('milestones', () => { + let projectMilestonesSpy; + + it('should call createFlash if milestones query fails', async () => { + await createComponentWithApollo({ + projectMilestonesSpy: jest.fn().mockRejectedValue(error), + }); + + await clickEdit(); + + expect(createFlash).toHaveBeenCalledWith({ + message: wrapper.vm.i18n.listFetchError, + captureError: true, + error: expect.any(Error), + }); + }); + + it('only fetches attributes when dropdown is opened', async () => { + projectMilestonesSpy = jest.fn().mockResolvedValueOnce(emptyProjectMilestonesResponse); + await createComponentWithApollo({ projectMilestonesSpy }); + + expect(projectMilestonesSpy).not.toHaveBeenCalled(); + + await clickEdit(); + + expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, { + fullPath: mockIssue.projectPath, + title: '', + state: 'active', + }); + }); + + describe('when a user is searching', () => { + const mockSearchTerm = 'foobar'; + + beforeEach(async () => { + projectMilestonesSpy = jest + .fn() + .mockResolvedValueOnce(emptyProjectMilestonesResponse); + await createComponentWithApollo({ projectMilestonesSpy }); + + await clickEdit(); + }); + + it('sends a projectMilestones query with the entered search term "foo"', async () => { + findSearchBox().vm.$emit('input', mockSearchTerm); + await wrapper.vm.$nextTick(); + + // Account for debouncing + jest.runAllTimers(); + + expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, { + fullPath: mockIssue.projectPath, + title: mockSearchTerm, + state: 'active', + }); + }); + }); + }); + }); + + describe('currentAttributes', () => { + it('should call createFlash if currentAttributes query fails', async () => { + await createComponentWithApollo({ + currentMilestoneSpy: jest.fn().mockRejectedValue(error), + }); + + expect(createFlash).toHaveBeenCalledWith({ + message: wrapper.vm.i18n.currentFetchError, + captureError: true, + error: expect.any(Error), + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index 0aa5aa2f691..710fae8ddf7 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -36,7 +36,7 @@ describe('Issuable Time Tracking Report', () => { issuableId: 1, issuableType, }, - propsData: { limitToHours }, + propsData: { limitToHours, issuableId: '1' }, localVue, apolloProvider: fakeApollo, }); diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index f26cdcb8b20..e08bd80b18e 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -1,7 +1,11 @@ import { mount } from '@vue/test-utils'; + import { stubTransition } from 'helpers/stub_transition'; import { createMockDirective } from 'helpers/vue_mock_directive'; import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; +import SidebarEventHub from '~/sidebar/event_hub'; + +import { issuableTimeTrackingResponse } from '../../mock_data'; describe('Issuable Time Tracker', () => { let wrapper; @@ -13,21 +17,39 @@ describe('Issuable Time Tracker', () => { const findReportLink = () => findByTestId('reportLink'); const defaultProps = { - timeEstimate: 10_000, // 2h 46m - timeSpent: 5_000, // 1h 23m - humanTimeEstimate: '2h 46m', - humanTimeSpent: '1h 23m', limitToHours: false, + fullPath: 'gitlab-org/gitlab-test', + issuableIid: '1', + initialTimeTracking: { + ...issuableTimeTrackingResponse.data.workspace.issuable, + }, }; - const mountComponent = ({ props = {} } = {}) => - mount(TimeTracker, { + const issuableTimeTrackingRefetchSpy = jest.fn(); + + const mountComponent = ({ props = {}, issuableType = 'issue', loading = false } = {}) => { + return mount(TimeTracker, { propsData: { ...defaultProps, ...props }, directives: { GlTooltip: createMockDirective() }, stubs: { transition: stubTransition(), }, + provide: { + issuableType, + }, + mocks: { + $apollo: { + queries: { + issuableTimeTracking: { + loading, + refetch: issuableTimeTrackingRefetchSpy, + query: jest.fn().mockResolvedValue(issuableTimeTrackingResponse), + }, + }, + }, + }, }); + }; afterEach(() => { wrapper.destroy(); @@ -44,13 +66,13 @@ describe('Issuable Time Tracker', () => { it('should correctly render timeEstimate', () => { expect(findByTestId('timeTrackingComparisonPane').html()).toContain( - defaultProps.humanTimeEstimate, + defaultProps.initialTimeTracking.humanTimeEstimate, ); }); - it('should correctly render time_spent', () => { + it('should correctly render totalTimeSpent', () => { expect(findByTestId('timeTrackingComparisonPane').html()).toContain( - defaultProps.humanTimeSpent, + defaultProps.initialTimeTracking.humanTotalTimeSpent, ); }); }); @@ -78,10 +100,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 100_000, // 1d 3h - timeSpent: 5_000, // 1h 23m - humanTimeEstimate: '1d 3h', - humanTimeSpent: '1h 23m', + initialTimeTracking: { + timeEstimate: 100_000, // 1d 3h + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '1d 3h', + humanTotalTimeSpent: '1h 23m', + }, }, }); }); @@ -108,8 +132,11 @@ describe('Issuable Time Tracker', () => { it('should display the remaining meter with the correct background color when over estimate', () => { wrapper = mountComponent({ props: { - timeEstimate: 10_000, // 2h 46m - timeSpent: 20_000_000, // 231 days + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 20_000_000, // 231 days + }, }, }); @@ -122,8 +149,11 @@ describe('Issuable Time Tracker', () => { beforeEach(async () => { wrapper = mountComponent({ props: { - timeEstimate: 100_000, // 1d 3h limitToHours: true, + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + timeEstimate: 100_000, // 1d 3h + }, }, }); }); @@ -140,10 +170,12 @@ describe('Issuable Time Tracker', () => { beforeEach(async () => { wrapper = mountComponent({ props: { - timeEstimate: 10_000, // 2h 46m - timeSpent: 0, - timeEstimateHumanReadable: '2h 46m', - timeSpentHumanReadable: '', + initialTimeTracking: { + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 0, + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '', + }, }, }); await wrapper.vm.$nextTick(); @@ -159,10 +191,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 0, - timeSpent: 5_000, // 1h 23m - timeEstimateHumanReadable: '2h 46m', - timeSpentHumanReadable: '1h 23m', + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '1h 23m', + }, }, }); }); @@ -177,10 +211,12 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeEstimate: 0, - timeSpent: 0, - timeEstimateHumanReadable: '', - timeSpentHumanReadable: '', + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, }, }); }); @@ -198,8 +234,11 @@ describe('Issuable Time Tracker', () => { beforeEach(() => { wrapper = mountComponent({ props: { - timeSpent: 0, - timeSpentHumanReadable: '', + initialTimeTracking: { + ...defaultProps.initialTimeTracking, + totalTimeSpent: 0, + humanTotalTimeSpent: '', + }, }, }); }); @@ -210,13 +249,20 @@ describe('Issuable Time Tracker', () => { }); describe('When time spent', () => { - beforeEach(() => { + it('link should appear on issue', () => { wrapper = mountComponent(); + expect(findReportLink().exists()).toBe(true); }); - it('link should appear', () => { + it('link should appear on merge request', () => { + wrapper = mountComponent({ issuableType: 'merge_request' }); expect(findReportLink().exists()).toBe(true); }); + + it('link should not appear on milestone', () => { + wrapper = mountComponent({ issuableType: 'milestone' }); + expect(findReportLink().exists()).toBe(false); + }); }); }); @@ -225,7 +271,16 @@ describe('Issuable Time Tracker', () => { const findCloseHelpButton = () => findByTestId('closeHelpButton'); beforeEach(async () => { - wrapper = mountComponent({ props: { timeEstimate: 0, timeSpent: 0 } }); + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, + }, + }); await wrapper.vm.$nextTick(); }); @@ -254,4 +309,14 @@ describe('Issuable Time Tracker', () => { }); }); }); + + describe('Event listeners', () => { + it('refetches issuableTimeTracking query when eventHub emits `timeTracker:refresh` event', async () => { + SidebarEventHub.$emit('timeTracker:refresh'); + + await wrapper.vm.$nextTick(); + + expect(issuableTimeTrackingRefetchSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index b052038661a..d6287b93fb9 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -513,4 +513,100 @@ export const participantsQueryResponse = { }, }; +export const mockGroupPath = 'gitlab-org'; +export const mockProjectPath = `${mockGroupPath}/some-project`; + +export const mockIssue = { + projectPath: mockProjectPath, + iid: '1', + groupPath: mockGroupPath, +}; + +export const mockIssueId = 'gid://gitlab/Issue/1'; + +export const mockMilestone1 = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/1', + title: 'Foobar Milestone', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1', + state: 'active', +}; + +export const mockMilestone2 = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/2', + title: 'Awesome Milestone', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2', + state: 'active', +}; + +export const mockProjectMilestonesResponse = { + data: { + workspace: { + attributes: { + nodes: [mockMilestone1, mockMilestone2], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, +}; + +export const noCurrentMilestoneResponse = { + data: { + workspace: { + issuable: { id: mockIssueId, attribute: null, __typename: 'Issue' }, + __typename: 'Project', + }, + }, +}; + +export const mockMilestoneMutationResponse = { + data: { + issuableSetAttribute: { + errors: [], + issuable: { + id: 'gid://gitlab/Issue/1', + attribute: { + id: 'gid://gitlab/Milestone/2', + title: 'Awesome Milestone', + state: 'active', + __typename: 'Milestone', + }, + __typename: 'Issue', + }, + __typename: 'UpdateIssuePayload', + }, + }, +}; + +export const emptyProjectMilestonesResponse = { + data: { + workspace: { + attributes: { + nodes: [], + }, + __typename: 'MilestoneConnection', + }, + __typename: 'Project', + }, +}; + +export const issuableTimeTrackingResponse = { + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/1', + title: 'Commodi incidunt eos eos libero dicta dolores sed.', + timeEstimate: 10_000, // 2h 46m + totalTimeSpent: 5_000, // 1h 23m + humanTimeEstimate: '2h 46m', + humanTotalTimeSpent: '1h 23m', + }, + }, + }, +}; + export default mockData; diff --git a/spec/frontend/sidebar/track_invite_members_spec.js b/spec/frontend/sidebar/track_invite_members_spec.js new file mode 100644 index 00000000000..6c96e4cfc76 --- /dev/null +++ b/spec/frontend/sidebar/track_invite_members_spec.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; + +describe('Track user dropdown open', () => { + let trackingSpy; + let dropdownElement; + + beforeEach(() => { + document.body.innerHTML = ` + <div id="dummy-wrapper-element"> + <div class="js-sidebar-assignee-dropdown"> + <div class="js-invite-members-track" data-track-event="_track_event_" data-track-label="_track_label_"> + </div> + </div> + </div> + `; + + dropdownElement = document.querySelector('.js-sidebar-assignee-dropdown'); + trackingSpy = mockTracking('_category_', dropdownElement, jest.spyOn); + document.body.dataset.page = 'some:page'; + + trackShowInviteMemberLink(dropdownElement); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends a tracking event when the dropdown is opened and contains Invite Members link', () => { + $(dropdownElement).trigger('shown.bs.dropdown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, '_track_event_', { + label: '_track_label_', + }); + }); +}); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index 95da67c2bbf..5df69ffb5f8 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -22,6 +22,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = <gl-form-group-stub class="gl-mb-0" id="visibility-level-setting" + labeldescription="" > <gl-form-radio-group-stub checked="private" diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index a7ab205ca7b..4b3b21c5507 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -4,7 +4,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; @@ -125,9 +125,9 @@ describe('Snippet Blob Edit component', () => { it('should call flash', async () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith( - "Can't fetch content for the blob: Error: Request failed with status code 500", - ); + expect(createFlash).toHaveBeenCalledWith({ + message: "Can't fetch content for the blob: Error: Request failed with status code 500", + }); }); }); diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index 17fb3fe788a..1d6245e9dbb 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -7,8 +7,8 @@ import EditDrawer from '~/static_site_editor/components/edit_drawer.vue'; import EditHeader from '~/static_site_editor/components/edit_header.vue'; import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue'; -import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; -import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +import { EDITOR_TYPES } from '~/static_site_editor/rich_content_editor/constants'; +import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue'; import { sourceContentTitle as title, diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js index ce2b0d1ddc1..cd0d09c085f 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js @@ -1,5 +1,5 @@ -import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; -import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; +import buildCustomRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; +import buildHTMLToMarkdownRenderer from '~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'; import { generateToolbarItem, addCustomEventListener, @@ -9,12 +9,12 @@ import { insertVideo, getMarkdown, getEditorOptions, -} from '~/vue_shared/components/rich_content_editor/services/editor_service'; -import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html'; +} from '~/static_site_editor/rich_content_editor/services/editor_service'; +import sanitizeHTML from '~/static_site_editor/rich_content_editor/services/sanitize_html'; -jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); -jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer'); -jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html'); +jest.mock('~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'); +jest.mock('~/static_site_editor/rich_content_editor/services/build_custom_renderer'); +jest.mock('~/static_site_editor/rich_content_editor/services/sanitize_html'); describe('Editor Service', () => { let mockInstance; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js index 97aecda97d2..86ae016987d 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js @@ -1,8 +1,8 @@ import { GlModal, GlTabs } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { IMAGE_TABS } from '~/vue_shared/components/rich_content_editor/constants'; -import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; -import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue'; +import { IMAGE_TABS } from '~/static_site_editor/rich_content_editor/constants'; +import AddImageModal from '~/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue'; +import UploadImageTab from '~/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue'; describe('Add Image Modal', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js index 81fd059ce4f..11b73d58259 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue'; +import UploadImageTab from '~/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue'; describe('Upload Image Tab', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js index 3e9eaf58181..392d31bf039 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js @@ -1,6 +1,6 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; +import InsertVideoModal from '~/static_site_editor/rich_content_editor/modals/insert_video_modal.vue'; describe('Insert Video Modal', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js index 47b1abd2ad2..6c02ec506c6 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js @@ -1,8 +1,8 @@ import Editor from '@toast-ui/editor'; -import buildMarkdownToHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; -import { registerHTMLToMarkdownRenderer } from '~/vue_shared/components/rich_content_editor/services/editor_service'; +import buildMarkdownToHTMLRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; +import { registerHTMLToMarkdownRenderer } from '~/static_site_editor/rich_content_editor/services/editor_service'; -describe('vue_shared/components/rich_content_editor', () => { +describe('static_site_editor/rich_content_editor', () => { let editor; const buildEditor = () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js index 8eb880b3984..3b0d2993a5d 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js @@ -5,10 +5,10 @@ import { EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS, -} from '~/vue_shared/components/rich_content_editor/constants'; -import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; -import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; -import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +} from '~/static_site_editor/rich_content_editor/constants'; +import AddImageModal from '~/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue'; +import InsertVideoModal from '~/static_site_editor/rich_content_editor/modals/insert_video_modal.vue'; +import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue'; import { addCustomEventListener, @@ -18,9 +18,9 @@ import { registerHTMLToMarkdownRenderer, getEditorOptions, getMarkdown, -} from '~/vue_shared/components/rich_content_editor/services/editor_service'; +} from '~/static_site_editor/rich_content_editor/services/editor_service'; -jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', () => ({ +jest.mock('~/static_site_editor/rich_content_editor/services/editor_service', () => ({ addCustomEventListener: jest.fn(), removeCustomEventListener: jest.fn(), addImage: jest.fn(), diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js index a823d04024d..202e13e8bff 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js @@ -1,4 +1,4 @@ -import buildCustomHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; +import buildCustomHTMLRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer'; describe('Build Custom Renderer Service', () => { describe('buildCustomHTMLRenderer', () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js index 3caf03dabba..c9cba3e8689 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -1,4 +1,4 @@ -import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; +import buildHTMLToMarkdownRenderer from '~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer'; import { attributeDefinition } from './renderers/mock_data'; describe('rich_content_editor/services/html_to_markdown_renderer', () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js index 7a7e3055520..ef3ff052cb2 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js @@ -6,7 +6,7 @@ import { buildUneditableBlockTokens, buildUneditableInlineTokens, buildUneditableHtmlAsTextTokens, -} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; +} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; import { originInlineToken, diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js index 407072fb596..407072fb596 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js index 69fd9a67a21..6d96dd3bbca 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js @@ -1,4 +1,4 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition'; import { attributeDefinition } from './mock_data'; describe('rich_content_editor/renderers/render_attribute_definition', () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js index 0c59d9f569b..29e2b5b3b16 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js @@ -1,5 +1,5 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text'; -import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text'; +import { renderUneditableLeaf } from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; import { buildMockTextNode, normalTextNode } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js index c1aaed6f0c3..0fda847b688 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js @@ -1,5 +1,5 @@ -import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline'; +import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline'; import { normalTextNode } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js index 76abc1ec3d8..cf4a90885df 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js @@ -1,5 +1,5 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_heading'; -import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_heading'; +import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; describe('rich_content_editor/renderers/render_heading', () => { it('canRender delegates to renderUtils.willAlwaysRender', () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js index 234f6a4d4ca..9c937ac22f4 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js @@ -1,5 +1,5 @@ -import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block'; +import { buildUneditableHtmlAsTextTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_html_block'; describe('rich_content_editor/services/renderers/render_html_block', () => { const htmlBlockNode = { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js index 425d0f41bcd..15fb2c3a430 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js @@ -1,5 +1,5 @@ -import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text'; +import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text'; import { buildMockTextNode, normalTextNode } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js index 470cf9bddaa..6a2b89a8dcf 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -1,4 +1,4 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph'; import { buildMockTextNode } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js index c1ab700535b..1e8e62b9dd2 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js @@ -1,5 +1,5 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_list_item'; -import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_list_item'; +import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; describe('rich_content_editor/renderers/render_list_item', () => { it('canRender delegates to renderUtils.willAlwaysRender', () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_softbreak_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js index 3c3d2354cb9..d8d1e6ff295 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_softbreak_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js @@ -1,4 +1,4 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_softbreak'; +import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_softbreak'; describe('Render softbreak renderer', () => { describe('canRender', () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js index 7c1809c290c..49b8936a9f7 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js @@ -1,13 +1,13 @@ import { buildUneditableBlockTokens, buildUneditableOpenTokens, -} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; +} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token'; import { renderUneditableLeaf, renderUneditableBranch, renderWithAttributeDefinitions, willAlwaysRender, -} from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; +} from '~/static_site_editor/rich_content_editor/services/renderers/render_utils'; import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js index f2182ef60d7..2f2d3beb53d 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js @@ -1,4 +1,4 @@ -import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html'; +import sanitizeHTML from '~/static_site_editor/rich_content_editor/services/sanitize_html'; describe('rich_content_editor/services/sanitize_html', () => { it.each` diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js index 5a56b499769..c9dcf9cfe2e 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js @@ -1,7 +1,7 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ToolbarItem from '~/vue_shared/components/rich_content_editor/toolbar_item.vue'; +import ToolbarItem from '~/static_site_editor/rich_content_editor/toolbar_item.vue'; describe('Toolbar Item', () => { let wrapper; diff --git a/spec/frontend/tracking/get_standard_context_spec.js b/spec/frontend/tracking/get_standard_context_spec.js new file mode 100644 index 00000000000..b7bdc56b801 --- /dev/null +++ b/spec/frontend/tracking/get_standard_context_spec.js @@ -0,0 +1,53 @@ +import { SNOWPLOW_JS_SOURCE } from '~/tracking/constants'; +import getStandardContext from '~/tracking/get_standard_context'; + +describe('~/tracking/get_standard_context', () => { + beforeEach(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = {}; + }); + + it('returns default object if called without server context', () => { + expect(getStandardContext()).toStrictEqual({ + schema: undefined, + data: { + source: SNOWPLOW_JS_SOURCE, + extra: {}, + }, + }); + }); + + it('returns filled object if called with server context', () => { + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + }, + }; + + expect(getStandardContext()).toStrictEqual({ + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: SNOWPLOW_JS_SOURCE, + extra: {}, + }, + }); + }); + + it('always overrides `source` property', () => { + window.gl.snowplowStandardContext = { + data: { + source: 'custom_source', + }, + }; + + expect(getStandardContext().data.source).toBe(SNOWPLOW_JS_SOURCE); + }); + + it('accepts optional `extra` property', () => { + const extra = { foo: 'bar' }; + + expect(getStandardContext({ extra }).data.extra).toBe(extra); + }); +}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index dd4c8198b72..d8dae2b2dc0 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,14 +1,31 @@ import { setHTMLFixture } from 'helpers/fixtures'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData } from '~/experimentation/utils'; -import Tracking, { initUserTracking, initDefaultTrackers, STANDARD_CONTEXT } from '~/tracking'; +import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import getStandardContext from '~/tracking/get_standard_context'; jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); describe('Tracking', () => { + let standardContext; let snowplowSpy; let bindDocumentSpy; let trackLoadEventsSpy; + let enableFormTracking; + + beforeAll(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: 'unknown', + extra: {}, + }, + }; + + standardContext = getStandardContext(); + }); beforeEach(() => { getExperimentData.mockReturnValue(undefined); @@ -38,6 +55,10 @@ describe('Tracking', () => { formTracking: false, linkClickTracking: false, pageUnloadTimer: 10, + formTrackingConfig: { + fields: { allow: [] }, + forms: { allow: [] }, + }, }); }); }); @@ -46,12 +67,15 @@ describe('Tracking', () => { beforeEach(() => { bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); + enableFormTracking = jest + .spyOn(Tracking, 'enableFormTracking') + .mockImplementation(() => null); }); it('should activate features based on what has been enabled', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [STANDARD_CONTEXT]); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); @@ -59,10 +83,11 @@ describe('Tracking', () => { ...window.snowplowOptions, formTracking: true, linkClickTracking: true, + formTrackingConfig: { forms: { whitelist: ['foo'] }, fields: { whitelist: ['bar'] } }, }; initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking'); + expect(enableFormTracking).toHaveBeenCalledWith(window.snowplowOptions.formTrackingConfig); expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); }); @@ -84,34 +109,6 @@ describe('Tracking', () => { navigator.msDoNotTrack = undefined; }); - describe('builds the standard context', () => { - let standardContext; - - beforeAll(async () => { - window.gl = window.gl || {}; - window.gl.snowplowStandardContext = { - schema: 'iglu:com.gitlab/gitlab_standard', - data: { - environment: 'testing', - source: 'unknown', - }, - }; - - jest.resetModules(); - - ({ STANDARD_CONTEXT: standardContext } = await import('~/tracking')); - }); - - it('uses server data', () => { - expect(standardContext.schema).toBe('iglu:com.gitlab/gitlab_standard'); - expect(standardContext.data.environment).toBe('testing'); - }); - - it('overrides schema source', () => { - expect(standardContext.data.source).toBe('gitlab-javascript'); - }); - }); - it('tracks to snowplow (our current tracking system)', () => { Tracking.event('_category_', '_eventName_', { label: '_label_' }); @@ -122,7 +119,31 @@ describe('Tracking', () => { '_label_', undefined, undefined, - [STANDARD_CONTEXT], + [standardContext], + ); + }); + + it('allows adding extra data to the default context', () => { + const extra = { foo: 'bar' }; + + Tracking.event('_category_', '_eventName_', { extra }); + + expect(snowplowSpy).toHaveBeenCalledWith( + 'trackStructEvent', + '_category_', + '_eventName_', + undefined, + undefined, + undefined, + [ + { + ...standardContext, + data: { + ...standardContext.data, + extra, + }, + }, + ], ); }); @@ -156,26 +177,23 @@ describe('Tracking', () => { }); describe('.enableFormTracking', () => { - it('tells snowplow to enable form tracking', () => { - const config = { forms: { whitelist: [''] }, fields: { whitelist: [''] } }; - Tracking.enableFormTracking(config, ['_passed_context_']); + it('tells snowplow to enable form tracking, with only explicit contexts', () => { + const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } }; + Tracking.enableFormTracking(config, ['_passed_context_', standardContext]); - expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking', config, [ - { data: { source: 'gitlab-javascript' }, schema: undefined }, - '_passed_context_', - ]); + expect(snowplowSpy).toHaveBeenCalledWith( + 'enableFormTracking', + { forms: { whitelist: ['form-class1'] }, fields: { whitelist: ['input-class1'] } }, + ['_passed_context_'], + ); }); - it('throws an error if no whitelist rules are provided', () => { - const expectedError = new Error( - 'Unable to enable form event tracking without whitelist rules.', - ); + it('throws an error if no allow rules are provided', () => { + const expectedError = new Error('Unable to enable form event tracking without allow rules.'); expect(() => Tracking.enableFormTracking()).toThrow(expectedError); - expect(() => Tracking.enableFormTracking({ fields: { whitelist: [] } })).toThrow( - expectedError, - ); - expect(() => Tracking.enableFormTracking({ fields: { whitelist: [1] } })).not.toThrow( + expect(() => Tracking.enableFormTracking({ fields: { allow: true } })).toThrow(expectedError); + expect(() => Tracking.enableFormTracking({ fields: { allow: [] } })).not.toThrow( expectedError, ); }); @@ -197,7 +215,7 @@ describe('Tracking', () => { '_label_', undefined, undefined, - [STANDARD_CONTEXT], + [standardContext], ); }); }); @@ -213,13 +231,15 @@ describe('Tracking', () => { eventSpy = jest.spyOn(Tracking, 'event'); Tracking.bindDocument('_category_'); // only happens once setHTMLFixture(` - <input data-track-${term}="click_input1" data-track-label="_label_" value="_value_"/> - <input data-track-${term}="click_input2" data-track-value="_value_override_" value="_value_"/> - <input type="checkbox" data-track-${term}="toggle_checkbox" value="_value_" checked/> + <input data-track-${term}="click_input1" data-track-label="_label_" value=0 /> + <input data-track-${term}="click_input2" data-track-value=0 value=0/> + <input type="checkbox" data-track-${term}="toggle_checkbox" value=1 checked/> <input class="dropdown" data-track-${term}="toggle_dropdown"/> <div data-track-${term}="nested_event"><span class="nested"></span></div> <input data-track-bogus="click_bogusinput" data-track-label="_label_" value="_value_"/> <input data-track-${term}="click_input3" data-track-experiment="example" value="_value_"/> + <input data-track-${term}="event_with_extra" data-track-extra='{ "foo": "bar" }' /> + <input data-track-${term}="event_with_invalid_extra" data-track-extra="invalid_json" /> `); }); @@ -228,7 +248,7 @@ describe('Tracking', () => { expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', { label: '_label_', - value: '_value_', + value: '0', }); }); @@ -242,7 +262,7 @@ describe('Tracking', () => { document.querySelector(`[data-track-${term}="click_input2"]`).click(); expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', { - value: '_value_override_', + value: '0', }); }); @@ -252,13 +272,13 @@ describe('Tracking', () => { checkbox.click(); // unchecking expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { - value: false, + value: 0, }); checkbox.click(); // checking expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { - value: '_value_', + value: '1', }); }); @@ -295,6 +315,20 @@ describe('Tracking', () => { context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData }, }); }); + + it('supports extra data as JSON', () => { + document.querySelector(`[data-track-${term}="event_with_extra"]`).click(); + + expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_extra', { + extra: { foo: 'bar' }, + }); + }); + + it('ignores extra if provided JSON is invalid', () => { + document.querySelector(`[data-track-${term}="event_with_invalid_extra"]`).click(); + + expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_invalid_extra', {}); + }); }); describe.each` @@ -307,8 +341,8 @@ describe('Tracking', () => { beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); setHTMLFixture(` - <input data-track-${term}="render" data-track-label="label1" value="_value_" data-track-property="_property_"/> - <span data-track-${term}="render" data-track-label="label2" data-track-value="_value_"> + <input data-track-${term}="render" data-track-label="label1" value=1 data-track-property="_property_"/> + <span data-track-${term}="render" data-track-label="label2" data-track-value=1> Something </span> <input data-track-${term}="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/> @@ -323,7 +357,7 @@ describe('Tracking', () => { 'render', { label: 'label1', - value: '_value_', + value: '1', property: '_property_', }, ], @@ -332,7 +366,7 @@ describe('Tracking', () => { 'render', { label: 'label2', - value: '_value_', + value: '1', }, ], ]); diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js new file mode 100644 index 00000000000..7a33c6faac9 --- /dev/null +++ b/spec/frontend/user_lists/components/user_lists_spec.js @@ -0,0 +1,195 @@ +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { within } from '@testing-library/dom'; +import { mount, createWrapper } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import UserListsComponent from '~/user_lists/components/user_lists.vue'; +import UserListsTable from '~/user_lists/components/user_lists_table.vue'; +import createStore from '~/user_lists/store/index'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { userList } from '../../feature_flags/mock_data'; + +jest.mock('~/api'); + +Vue.use(Vuex); + +describe('~/user_lists/components/user_lists.vue', () => { + const mockProvide = { + newUserListPath: '/user-lists/new', + featureFlagsHelpPagePath: '/help/feature-flags', + errorStateSvgPath: '/assets/illustrations/feature_flag.svg', + }; + + const mockState = { + projectId: '1', + }; + + let wrapper; + let store; + + const factory = (provide = mockProvide, fn = mount) => { + store = createStore(mockState); + wrapper = fn(UserListsComponent, { + store, + provide, + }); + }; + + const newButton = () => within(wrapper.element).queryAllByText('New user list'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('without permissions', () => { + const provideData = { + ...mockProvide, + newUserListPath: null, + }; + + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [], headers: {} }); + factory(provideData); + }); + + it('does not render new user list button', () => { + expect(newButton()).toHaveLength(0); + }); + }); + + describe('loading state', () => { + it('renders a loading icon', () => { + Api.fetchFeatureFlagUserLists.mockReturnValue(new Promise(() => {})); + + factory(); + + const loadingElement = wrapper.findComponent(GlLoadingIcon); + + expect(loadingElement.exists()).toBe(true); + expect(loadingElement.props('label')).toEqual('Loading user lists'); + }); + }); + + describe('successful request', () => { + describe('without user lists', () => { + let emptyState; + + beforeEach(async () => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [], headers: {} }); + + factory(); + await waitForPromises(); + await Vue.nextTick(); + + emptyState = wrapper.findComponent(GlEmptyState); + }); + + it('should render the empty state', async () => { + expect(emptyState.exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton()).not.toHaveLength(0); + }); + + it('renders generic title', () => { + const title = createWrapper( + within(emptyState.element).getByText('Get started with user lists'), + ); + expect(title.exists()).toBe(true); + }); + + it('renders generic description', () => { + const description = createWrapper( + within(emptyState.element).getByText( + 'User lists allow you to define a set of users to use with Feature Flags.', + ), + ); + expect(description.exists()).toBe(true); + }); + }); + + describe('with paginated user lists', () => { + let table; + + beforeEach(async () => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ + data: [userList], + headers: { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }, + }); + + factory(); + jest.spyOn(store, 'dispatch'); + await Vue.nextTick(); + table = wrapper.findComponent(UserListsTable); + }); + + it('should render a table with feature flags', () => { + expect(table.exists()).toBe(true); + expect(table.props('userLists')).toEqual([userList]); + }); + + it('renders new feature flag button', () => { + expect(newButton()).not.toHaveLength(0); + }); + + describe('pagination', () => { + let pagination; + + beforeEach(() => { + pagination = wrapper.findComponent(TablePagination); + }); + + it('should render pagination', () => { + expect(pagination.exists()).toBe(true); + }); + + it('should make an API request when page is clicked', () => { + jest.spyOn(store, 'dispatch'); + pagination.vm.change('4'); + + expect(store.dispatch).toHaveBeenCalledWith('setUserListsOptions', { + page: '4', + }); + }); + }); + }); + }); + + describe('unsuccessful request', () => { + beforeEach(async () => { + Api.fetchFeatureFlagUserLists.mockRejectedValue(); + factory(); + + await Vue.nextTick(); + }); + + it('should render error state', () => { + const emptyState = wrapper.findComponent(GlEmptyState); + const title = createWrapper( + within(emptyState.element).getByText('There was an error fetching the user lists.'), + ); + expect(title.exists()).toBe(true); + const description = createWrapper( + within(emptyState.element).getByText( + 'Try again in a few moments or contact your support team.', + ), + ); + expect(description.exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton()).not.toHaveLength(0); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js index 1b04ecee146..7f4d510a39c 100644 --- a/spec/frontend/feature_flags/components/user_lists_table_spec.js +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -1,8 +1,8 @@ import { GlModal } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import * as timeago from 'timeago.js'; -import UserListsTable from '~/feature_flags/components/user_lists_table.vue'; -import { userList } from '../mock_data'; +import UserListsTable from '~/user_lists/components/user_lists_table.vue'; +import { userList } from '../../feature_flags/mock_data'; jest.mock('timeago.js', () => ({ format: jest.fn().mockReturnValue('2 weeks ago'), @@ -35,7 +35,7 @@ describe('User Lists Table', () => { it('should set the title for a tooltip on the created stamp', () => { expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe( - 'Feb 4, 2020 8:13am GMT+0000', + 'Feb 4, 2020 8:13am UTC', ); }); diff --git a/spec/frontend/user_lists/store/index/actions_spec.js b/spec/frontend/user_lists/store/index/actions_spec.js new file mode 100644 index 00000000000..c5d7d557de9 --- /dev/null +++ b/spec/frontend/user_lists/store/index/actions_spec.js @@ -0,0 +1,203 @@ +import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; +import { + setUserListsOptions, + requestUserLists, + receiveUserListsSuccess, + receiveUserListsError, + fetchUserLists, + deleteUserList, + receiveDeleteUserListError, + clearAlert, +} from '~/user_lists/store/index/actions'; +import * as types from '~/user_lists/store/index/mutation_types'; +import createState from '~/user_lists/store/index/state'; +import { userList } from '../../../feature_flags/mock_data'; + +jest.mock('~/api.js'); + +describe('~/user_lists/store/index/actions', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '1' }); + }); + + describe('setUserListsOptions', () => { + it('should commit SET_USER_LISTS_OPTIONS mutation', (done) => { + testAction( + setUserListsOptions, + { page: '1', scope: 'all' }, + state, + [{ type: types.SET_USER_LISTS_OPTIONS, payload: { page: '1', scope: 'all' } }], + [], + done, + ); + }); + }); + + describe('fetchUserLists', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} }); + }); + + describe('success', () => { + it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => { + testAction( + fetchUserLists, + null, + state, + [], + [ + { + type: 'requestUserLists', + }, + { + payload: { data: [userList], headers: {} }, + type: 'receiveUserListsSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestUserLists and receiveUserListsError ', (done) => { + Api.fetchFeatureFlagUserLists.mockRejectedValue(); + + testAction( + fetchUserLists, + null, + state, + [], + [ + { + type: 'requestUserLists', + }, + { + type: 'receiveUserListsError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestUserLists', () => { + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { + testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], [], done); + }); + }); + + describe('receiveUserListsSuccess', () => { + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { + testAction( + receiveUserListsSuccess, + { data: [userList], headers: {} }, + state, + [ + { + type: types.RECEIVE_USER_LISTS_SUCCESS, + payload: { data: [userList], headers: {} }, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveUserListsError', () => { + it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => { + testAction( + receiveUserListsError, + null, + state, + [{ type: types.RECEIVE_USER_LISTS_ERROR }], + [], + done, + ); + }); + }); + + describe('deleteUserList', () => { + beforeEach(() => { + state.userLists = [userList]; + }); + + describe('success', () => { + beforeEach(() => { + Api.deleteFeatureFlagUserList.mockResolvedValue(); + }); + + it('should refresh the user lists', (done) => { + testAction( + deleteUserList, + userList, + state, + [], + [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } }); + }); + + it('should dispatch receiveDeleteUserListError', (done) => { + testAction( + deleteUserList, + userList, + state, + [], + [ + { type: 'requestDeleteUserList', payload: userList }, + { + type: 'receiveDeleteUserListError', + payload: { list: userList, error: 'some error' }, + }, + ], + done, + ); + }); + }); + }); + + describe('receiveDeleteUserListError', () => { + it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => { + testAction( + receiveDeleteUserListError, + { list: userList, error: 'mock error' }, + state, + [ + { + type: 'RECEIVE_DELETE_USER_LIST_ERROR', + payload: { list: userList, error: 'mock error' }, + }, + ], + [], + done, + ); + }); + }); + + describe('clearAlert', () => { + it('should commit RECEIVE_CLEAR_ALERT', (done) => { + const alertIndex = 3; + + testAction( + clearAlert, + alertIndex, + state, + [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/index/mutations_spec.js b/spec/frontend/user_lists/store/index/mutations_spec.js new file mode 100644 index 00000000000..370838ae5fb --- /dev/null +++ b/spec/frontend/user_lists/store/index/mutations_spec.js @@ -0,0 +1,121 @@ +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import * as types from '~/user_lists/store/index/mutation_types'; +import mutations from '~/user_lists/store/index/mutations'; +import createState from '~/user_lists/store/index/state'; +import { userList } from '../../../feature_flags/mock_data'; + +describe('~/user_lists/store/index/mutations', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '1' }); + }); + + describe('SET_USER_LISTS_OPTIONS', () => { + it('should set provided options', () => { + mutations[types.SET_USER_LISTS_OPTIONS](state, { page: '1', scope: 'all' }); + + expect(state.options).toEqual({ page: '1', scope: 'all' }); + }); + }); + + describe('REQUEST_USER_LISTS', () => { + it('sets isLoading to true', () => { + mutations[types.REQUEST_USER_LISTS](state); + expect(state.isLoading).toBe(true); + }); + }); + + describe('RECEIVE_USER_LISTS_SUCCESS', () => { + const headers = { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }; + + beforeEach(() => { + mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, { data: [userList], headers }); + }); + + it('sets isLoading to false', () => { + expect(state.isLoading).toBe(false); + }); + + it('sets userLists to the received userLists', () => { + expect(state.userLists).toEqual([userList]); + }); + + it('sets pagination info for user lits', () => { + expect(state.pageInfo).toEqual(parseIntPagination(normalizeHeaders(headers))); + }); + + it('sets the count for user lists', () => { + expect(state.count).toBe(parseInt(headers['X-TOTAL'], 10)); + }); + }); + + describe('RECEIVE_USER_LISTS_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_USER_LISTS_ERROR](state); + }); + + it('should set isLoading to false', () => { + expect(state.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(state.hasError).toEqual(true); + }); + }); + + describe('REQUEST_DELETE_USER_LIST', () => { + beforeEach(() => { + state.userLists = [userList]; + mutations[types.REQUEST_DELETE_USER_LIST](state, userList); + }); + + it('should remove the deleted list', () => { + expect(state.userLists).not.toContain(userList); + }); + }); + + describe('RECEIVE_DELETE_USER_LIST_ERROR', () => { + beforeEach(() => { + state.userLists = []; + mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](state, { + list: userList, + error: 'some error', + }); + }); + + it('should set isLoading to false and hasError to false', () => { + expect(state.isLoading).toBe(false); + expect(state.hasError).toBe(false); + }); + + it('should add the user list back to the list of user lists', () => { + expect(state.userLists).toContain(userList); + }); + }); + + describe('RECEIVE_CLEAR_ALERT', () => { + it('clears the alert', () => { + state.alerts = ['a server error']; + + mutations[types.RECEIVE_CLEAR_ALERT](state, 0); + + expect(state.alerts).toEqual([]); + }); + + it('clears the alert at the specified index', () => { + state.alerts = ['a server error', 'another error', 'final error']; + + mutations[types.RECEIVE_CLEAR_ALERT](state, 1); + + expect(state.alerts).toEqual(['a server error', 'final error']); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index d6a1c2d3b07..af6624a6c43 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -1,6 +1,6 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; @@ -125,7 +125,7 @@ describe('MRWidget approvals', () => { }); it('flashes error', () => { - expect(createFlash).toHaveBeenCalledWith(FETCH_ERROR); + expect(createFlash).toHaveBeenCalledWith({ message: FETCH_ERROR }); }); }); @@ -264,7 +264,7 @@ describe('MRWidget approvals', () => { }); it('flashes error message', () => { - expect(createFlash).toHaveBeenCalledWith(APPROVE_ERROR); + expect(createFlash).toHaveBeenCalledWith({ message: APPROVE_ERROR }); }); }); }); @@ -315,7 +315,7 @@ describe('MRWidget approvals', () => { }); it('flashes error message', () => { - expect(createFlash).toHaveBeenCalledWith(UNAPPROVE_ERROR); + expect(createFlash).toHaveBeenCalledWith({ message: UNAPPROVE_ERROR }); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js index 07e869a070f..5d923d0383f 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_alert_message_spec.js @@ -1,76 +1,45 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlLink, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import MrWidgetAlertMessage from '~/vue_merge_request_widget/components/mr_widget_alert_message.vue'; -describe('MrWidgetAlertMessage', () => { - let wrapper; - - beforeEach(() => { - const localVue = createLocalVue(); +let wrapper; - wrapper = shallowMount(localVue.extend(MrWidgetAlertMessage), { - propsData: {}, - localVue, - }); +function createComponent(propsData = {}) { + wrapper = shallowMount(MrWidgetAlertMessage, { + propsData, }); +} +describe('MrWidgetAlertMessage', () => { afterEach(() => { wrapper.destroy(); }); - describe('when type is not provided', () => { - it('should render a red message', (done) => { - wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain('danger_message'); - expect(wrapper.classes()).not.toContain('warning_message'); - done(); - }); - }); - }); - - describe('when type === "danger"', () => { - it('should render a red message', (done) => { - wrapper.setProps({ type: 'danger' }); - wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain('danger_message'); - expect(wrapper.classes()).not.toContain('warning_message'); - done(); - }); - }); - }); + it('should render a GlAert', () => { + createComponent({ type: 'danger' }); - describe('when type === "warning"', () => { - it('should render a red message', (done) => { - wrapper.setProps({ type: 'warning' }); - wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain('warning_message'); - expect(wrapper.classes()).not.toContain('danger_message'); - done(); - }); - }); + expect(wrapper.findComponent(GlAlert).exists()).toBe(true); + expect(wrapper.findComponent(GlAlert).props('variant')).toBe('danger'); }); describe('when helpPath is not provided', () => { - it('should not render a help icon/link', (done) => { - wrapper.vm.$nextTick(() => { - const link = wrapper.find(GlLink); + it('should not render a help link', () => { + createComponent({ type: 'info' }); + + const link = wrapper.findComponent(GlLink); - expect(link.exists()).toBe(false); - done(); - }); + expect(link.exists()).toBe(false); }); }); describe('when helpPath is provided', () => { - it('should render a help icon/link', (done) => { - wrapper.setProps({ helpPath: '/path/to/help/docs' }); - wrapper.vm.$nextTick(() => { - const link = wrapper.find(GlLink); + it('should render a help link', () => { + createComponent({ type: 'info', helpPath: 'https://gitlab.com' }); + + const link = wrapper.findComponent(GlLink); - expect(link.exists()).toBe(true); - expect(link.attributes().href).toBe('/path/to/help/docs'); - done(); - }); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('https://gitlab.com'); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index 924dc37aab9..ecaca16a2cd 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -94,7 +94,7 @@ describe('MRWidgetPipeline', () => { it('should render pipeline finished timestamp', () => { expect(findPipelineFinishedAt().attributes()).toMatchObject({ - title: 'Apr 7, 2017 2:00pm GMT+0000', + title: 'Apr 7, 2017 2:00pm UTC', datetime: mockData.pipeline.details.finished_at, }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js index 55d7e2391b2..6ae218ce6f8 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_closed_spec.js @@ -18,8 +18,8 @@ describe('MRWidgetClosed', () => { avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', }, - mergedAt: 'Jan 24, 2018 1:02pm GMT+0000', - closedAt: 'Jan 24, 2018 1:02pm GMT+0000', + mergedAt: 'Jan 24, 2018 1:02pm UTC', + closedAt: 'Jan 24, 2018 1:02pm UTC', readableMergedAt: '', readableClosedAt: 'less than a minute ago', }, diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 6af8ac9e18e..6bb87893c31 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -37,10 +37,10 @@ describe('MRWidgetMerged', () => { avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', }, - mergedAt: 'Jan 24, 2018 1:02pm GMT+0000', + mergedAt: 'Jan 24, 2018 1:02pm UTC', readableMergedAt: '', closedBy: {}, - closedAt: 'Jan 24, 2018 1:02pm GMT+0000', + closedAt: 'Jan 24, 2018 1:02pm UTC', readableClosedAt: '', }, updatedAt: 'mergedUpdatedAt', @@ -236,6 +236,6 @@ describe('MRWidgetMerged', () => { }); it('should use mergedEvent mergedAt as tooltip title', () => { - expect(vm.$el.querySelector('time').getAttribute('title')).toBe('Jan 24, 2018 1:02pm GMT+0000'); + expect(vm.$el.querySelector('time').getAttribute('title')).toBe('Jan 24, 2018 1:02pm UTC'); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js index bd77a1d657e..9b10b078e89 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js @@ -22,7 +22,7 @@ describe('MRWidgetPipelineBlocked', () => { createWrapper(); expect(wrapper.text()).toBe( - 'Pipeline blocked. The pipeline for this merge request requires a manual action to proceed', + "Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.", ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 85a42946325..2d00cd8e8d4 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -59,12 +59,17 @@ const createTestService = () => ({ }); let wrapper; -const createComponent = (customConfig = {}) => { +const createComponent = (customConfig = {}, mergeRequestWidgetGraphql = false) => { wrapper = shallowMount(ReadyToMerge, { propsData: { mr: createTestMr(customConfig), service: createTestService(), }, + provide: { + glFeatures: { + mergeRequestWidgetGraphql, + }, + }, }); }; @@ -123,26 +128,26 @@ describe('ReadyToMerge', () => { }); describe('mergeButtonVariant', () => { - it('defaults to success class', () => { + it('defaults to confirm class', () => { createComponent({ mr: { availableAutoMergeStrategies: [] }, }); - expect(wrapper.vm.mergeButtonVariant).toEqual('success'); + expect(wrapper.vm.mergeButtonVariant).toEqual('confirm'); }); - it('returns success class for success status', () => { + it('returns confirm class for success status', () => { createComponent({ mr: { availableAutoMergeStrategies: [], pipeline: true }, }); - expect(wrapper.vm.mergeButtonVariant).toEqual('success'); + expect(wrapper.vm.mergeButtonVariant).toEqual('confirm'); }); - it('returns info class for pending status', () => { + it('returns confirm class for pending status', () => { createComponent(); - expect(wrapper.vm.mergeButtonVariant).toEqual('info'); + expect(wrapper.vm.mergeButtonVariant).toEqual('confirm'); }); it('returns danger class for failed status', () => { @@ -673,6 +678,34 @@ describe('ReadyToMerge', () => { expect(findCommitEditElements().length).toBe(2); }); + it('should have two edit components when squash is enabled and there is more than 1 commit and mergeRequestWidgetGraphql is enabled', async () => { + createComponent( + { + mr: { + commitsCount: 2, + squashIsSelected: true, + enableSquashBeforeMerge: true, + }, + }, + true, + ); + + wrapper.setData({ + loading: false, + state: { + ...createTestMr({}), + userPermissions: {}, + squash: true, + mergeable: true, + commitCount: 2, + commitsWithoutMergeCommits: {}, + }, + }); + await wrapper.vm.$nextTick(); + + expect(findCommitEditElements().length).toBe(2); + }); + it('should have one edit components when squash is enabled and there is 1 commit only', () => { createComponent({ mr: { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index e0077a008a2..0609086997b 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import WorkInProgress from '~/vue_merge_request_widget/components/states/work_in_progress.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; @@ -63,10 +63,10 @@ describe('Wip', () => { setImmediate(() => { expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); - expect(createFlash).toHaveBeenCalledWith( - 'The merge request can now be merged.', - 'notice', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'The merge request can now be merged.', + type: 'notice', + }); done(); }); }); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js index 22e58ac6abf..49783560bf2 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { CREATED, @@ -203,9 +203,9 @@ describe('DeploymentAction component', () => { it('should call createFlash with error message', () => { expect(createFlash).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith( - actionButtonMocks[configConst].errorMessage, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: actionButtonMocks[configConst].errorMessage, + }); }); }); }); diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 446cd2a1e2f..9da370747fc 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -26,7 +26,7 @@ describe('MrWidgetOptions', () => { let wrapper; let mock; - const COLLABORATION_MESSAGE = 'Allows commits from members who can merge to the target branch'; + const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; beforeEach(() => { gl.mrWidgetData = { ...mockData }; @@ -532,7 +532,7 @@ describe('MrWidgetOptions', () => { nextTick(() => { const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - expect(wrapper.text()).toContain('Deletes source branch'); + expect(wrapper.text()).toContain('The source branch will be deleted'); expect(tooltip.attributes('title')).toBe( 'A user with write access to the source branch selected this option', ); @@ -548,7 +548,7 @@ describe('MrWidgetOptions', () => { nextTick(() => { expect(wrapper.text()).toContain('The source branch has been deleted'); - expect(wrapper.text()).not.toContain('Deletes source branch'); + expect(wrapper.text()).not.toContain('The source branch will be deleted'); done(); }); diff --git a/spec/frontend/vue_shared/alert_details/alert_status_spec.js b/spec/frontend/vue_shared/alert_details/alert_status_spec.js index c532f688cbd..3fc13243bce 100644 --- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js @@ -1,5 +1,5 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import updateAlertStatusMutation from '~/graphql_shared//mutations/alert_status_update.mutation.graphql'; import Tracking from '~/tracking'; @@ -10,9 +10,10 @@ const mockAlert = mockAlerts[0]; describe('AlertManagementStatus', () => { let wrapper; - const findStatusDropdown = () => wrapper.find(GlDropdown); - const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + const findStatusDropdown = () => wrapper.findComponent(GlDropdown); + const findFirstStatusOption = () => findStatusDropdown().findComponent(GlDropdownItem); const findAllStatusOptions = () => findStatusDropdown().findAll(GlDropdownItem); + const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header'); const selectFirstStatusOption = () => { findFirstStatusOption().vm.$emit('click'); @@ -21,7 +22,7 @@ describe('AlertManagementStatus', () => { }; function mountComponent({ props = {}, provide = {}, loading = false, stubs = {} } = {}) { - wrapper = shallowMount(AlertManagementStatus, { + wrapper = shallowMountExtended(AlertManagementStatus, { propsData: { alert: { ...mockAlert }, projectPath: 'gitlab-org/gitlab', @@ -43,17 +44,29 @@ describe('AlertManagementStatus', () => { }); } - beforeEach(() => { - mountComponent(); - }); - afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); + describe('sidebar', () => { + it('displays the dropdown status header', () => { + mountComponent({ props: { isSidebar: true } }); + expect(findStatusDropdownHeader().exists()).toBe(true); + }); + + it('hides the dropdown by default', () => { + mountComponent({ props: { isSidebar: true } }); + expect(wrapper.classes()).toContain('gl-display-none'); + }); + + it('shows the dropdown', () => { + mountComponent({ props: { isSidebar: true, isDropdownShowing: true } }); + expect(wrapper.classes()).toContain('show'); + }); + }); + describe('updating the alert status', () => { const iid = '1527542'; const mockUpdatedMutationResult = { @@ -99,6 +112,13 @@ describe('AlertManagementStatus', () => { ]); }); + it('emits an update event at the start and ending of the updating', async () => { + await selectFirstStatusOption(); + expect(wrapper.emitted('handle-updating').length > 1).toBe(true); + expect(wrapper.emitted('handle-updating')[0]).toEqual([true]); + expect(wrapper.emitted('handle-updating')[1]).toEqual([false]); + }); + it('emits an error when triggered a second time', async () => { await selectFirstStatusOption(); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js index db9b0930c06..9ae45071f45 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js @@ -21,6 +21,7 @@ describe('Alert Details Sidebar Assignees', () => { id: 1, name: 'User 1', username: 'root', + webUrl: 'https://gitlab:3443/root', }, { avatar_url: @@ -28,6 +29,7 @@ describe('Alert Details Sidebar Assignees', () => { id: 2, name: 'User 2', username: 'not-root', + webUrl: 'https://gitlab:3443/non-root', }, ]; @@ -128,7 +130,7 @@ describe('Alert Details Sidebar Assignees', () => { variables: { iid: '1527542', assigneeUsernames: ['root'], - projectPath: 'projectPath', + fullPath: 'projectPath', }, }); }); @@ -137,7 +139,7 @@ describe('Alert Details Sidebar Assignees', () => { wrapper.setData({ isDropdownSearching: false }); const errorMutationResult = { data: { - alertSetAssignees: { + issuableSetAssignees: { errors: ['There was a problem for sure.'], alert: {}, }, diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js index d5be5b623b8..b00a20dab1a 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js @@ -1,6 +1,5 @@ -import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; +import { GlDropdown, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; import AlertSidebarStatus from '~/vue_shared/alert_details/components/sidebar/sidebar_status.vue'; import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; @@ -11,9 +10,7 @@ const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar Status', () => { let wrapper; const findStatusDropdown = () => wrapper.findComponent(GlDropdown); - const findStatusDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findStatusLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header'); const findAlertStatus = () => wrapper.findComponent(AlertStatus); const findStatus = () => wrapper.findByTestId('status'); const findSidebarIcon = () => wrapper.findByTestId('status-icon'); @@ -25,7 +22,7 @@ describe('Alert Details Sidebar Status', () => { stubs = {}, provide = {}, } = {}) { - wrapper = mountExtended(AlertSidebarStatus, { + wrapper = shallowMountExtended(AlertSidebarStatus, { propsData: { alert: { ...mockAlert }, ...data, @@ -63,11 +60,7 @@ describe('Alert Details Sidebar Status', () => { }); it('displays status dropdown', () => { - expect(findStatusDropdown().exists()).toBe(true); - }); - - it('displays the dropdown status header', () => { - expect(findStatusDropdownHeader().exists()).toBe(true); + expect(findAlertStatus().exists()).toBe(true); }); it('does not display the collapsed sidebar icon', () => { @@ -75,42 +68,24 @@ describe('Alert Details Sidebar Status', () => { }); describe('updating the alert status', () => { - const mockUpdatedMutationResult = { - data: { - updateAlertStatus: { - errors: [], - alert: { - status: 'acknowledged', - }, - }, - }, - }; - - beforeEach(() => { + it('ensures dropdown is hidden when loading', async () => { mountComponent({ data: { alert: mockAlert }, sidebarCollapsed: false, loading: false, }); - }); - - it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - findStatusDropdownItem().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateAlertStatusMutation, - variables: { - iid: '1527542', - status: 'TRIGGERED', - projectPath: 'projectPath', - }, - }); + findAlertStatus().vm.$emit('handle-updating', true); + await wrapper.vm.$nextTick(); + expect(findStatusLoadingIcon().exists()).toBe(true); }); it('stops updating when the request fails', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - findStatusDropdownItem().vm.$emit('click'); + mountComponent({ + data: { alert: mockAlert }, + sidebarCollapsed: false, + loading: false, + }); + findAlertStatus().vm.$emit('handle-updating', false); expect(findStatusLoadingIcon().exists()).toBe(false); expect(findStatus().text()).toBe('Triggered'); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 3be609f0dad..3f91591f5cd 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -5,7 +5,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` class="awards js-awards-block" > <button - class="btn gl-mr-3 btn-default btn-md gl-button" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" title="Ada, Leonardo, and Marie" type="button" @@ -35,7 +35,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button selected" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" title="You, Ada, and Marie" type="button" @@ -65,7 +65,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" title="Ada and Jane" type="button" @@ -95,7 +95,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button selected" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" title="You, Ada, Jane, and Leonardo" type="button" @@ -125,7 +125,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button selected" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" title="You" type="button" @@ -155,7 +155,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" title="Marie" type="button" @@ -185,7 +185,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button selected" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" title="You" type="button" @@ -216,7 +216,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <div - class="award-menu-holder" + class="award-menu-holder gl-my-2" > <button aria-label="Add reaction" @@ -238,6 +238,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` aria-hidden="true" class="gl-icon s16" data-testid="slight-smile-icon" + role="img" > <use href="#slight-smile" @@ -252,6 +253,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` aria-hidden="true" class="gl-icon s16" data-testid="smiley-icon" + role="img" > <use href="#smiley" @@ -266,6 +268,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` aria-hidden="true" class="gl-icon s16" data-testid="smile-icon" + role="img" > <use href="#smile" diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap index adb6c935f96..45d34bcdd3f 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap @@ -14,6 +14,7 @@ exports[`Expand button on click when short text is provided renders button after aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" + role="img" > <use href="#ellipsis_h" @@ -43,6 +44,7 @@ exports[`Expand button on click when short text is provided renders button after aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" + role="img" > <use href="#ellipsis_h" @@ -67,6 +69,7 @@ exports[`Expand button when short text is provided renders button before text 1` aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" + role="img" > <use href="#ellipsis_h" @@ -96,6 +99,7 @@ exports[`Expand button when short text is provided renders button before text 1` aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" + role="img" > <use href="#ellipsis_h" diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js index 03b04a92bdf..b9a8a5bee97 100644 --- a/spec/frontend/vue_shared/components/alert_details_table_spec.js +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; @@ -7,6 +7,9 @@ const mockAlert = { title: 'SyntaxError: Invalid or unexpected token', severity: 'CRITICAL', eventCount: 7, + service: 'https://gitlab.com', + // eslint-disable-next-line no-script-url + description: 'javascript:alert("XSS")', createdAt: '2020-04-17T23:18:14.996Z', startedAt: '2020-04-17T23:18:14.996Z', endedAt: '2020-04-17T23:18:14.996Z', @@ -43,7 +46,7 @@ describe('AlertDetails', () => { wrapper = null; }); - const findTableComponent = () => wrapper.find(GlTable); + const findTableComponent = () => wrapper.findComponent(GlTable); const findTableKeys = () => findTableComponent().findAll('tbody td:first-child'); const findTableFieldValueByKey = (fieldKey) => findTableComponent() @@ -52,6 +55,7 @@ describe('AlertDetails', () => { .at(0) .find('td:nth-child(2)'); const findTableField = (fields, fieldName) => fields.filter((row) => row.text() === fieldName); + const findTableLinks = () => wrapper.findAllComponents(GlLink); describe('Alert details', () => { describe('empty state', () => { @@ -88,7 +92,16 @@ describe('AlertDetails', () => { it('should show allowed alert fields', () => { const fields = findTableKeys(); - ['Iid', 'Title', 'Severity', 'Status', 'Hosts', 'Environment'].forEach((field) => { + [ + 'Iid', + 'Title', + 'Severity', + 'Status', + 'Hosts', + 'Environment', + 'Service', + 'Description', + ].forEach((field) => { expect(findTableField(fields, field).exists()).toBe(true); }); }); @@ -99,6 +112,12 @@ describe('AlertDetails', () => { expect(findTableField(fields, field).exists()).toBe(false); }); }); + + it('should render a clickable URL if safe', () => { + expect(findTableLinks().wrappers).toHaveLength(1); + expect(findTableLinks().at(0).props('isUnsafeLink')).toBe(false); + expect(findTableLinks().at(0).attributes('href')).toBe(mockAlert.service); + }); }); describe('environment', () => { diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 550ac4a9d38..55f9eedc169 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -41,7 +41,14 @@ const TEST_AWARDS = [ ]; const TEST_ADD_BUTTON_CLASS = 'js-test-add-button-class'; -const REACTION_CONTROL_CLASSES = ['btn', 'gl-mr-3', 'btn-default', 'btn-md', 'gl-button']; +const REACTION_CONTROL_CLASSES = [ + 'btn', + 'gl-mr-3', + 'gl-my-2', + 'btn-default', + 'btn-md', + 'gl-button', +]; describe('vue_shared/components/awards_list', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index b2ed79cd75a..93cddff8421 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -1,6 +1,9 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import AccessorUtilities from '~/lib/utils/accessor'; + +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; + import { stripQuotes, uniqueTokens, @@ -210,6 +213,19 @@ describe('filterToQueryObject', () => { const res = filterToQueryObject({ [token]: value }); expect(res).toEqual(result); }); + + it.each([ + [FILTERED_SEARCH_TERM, [{ value: '' }], { search: '' }], + [FILTERED_SEARCH_TERM, [{ value: 'bar' }], { search: 'bar' }], + [FILTERED_SEARCH_TERM, [{ value: 'bar' }, { value: '' }], { search: 'bar' }], + [FILTERED_SEARCH_TERM, [{ value: 'bar' }, { value: 'baz' }], { search: 'bar baz' }], + ])( + 'when filteredSearchTermKey=search gathers filter values %s=%j into query object=%j', + (token, value, result) => { + const res = filterToQueryObject({ [token]: value }, { filteredSearchTermKey: 'search' }); + expect(res).toEqual(result); + }, + ); }); describe('urlQueryToFilter', () => { @@ -255,10 +271,61 @@ describe('urlQueryToFilter', () => { }, ], ['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }], - ])('gathers filter values %s into query object=%j', (query, result) => { - const res = urlQueryToFilter(query); - expect(res).toEqual(result); - }); + ['nop=1¬[nop]=2', {}, { filterNamesAllowList: ['foo'] }], + [ + 'foo[]=bar¬[foo][]=baz&nop=xxx¬[nop]=yyy', + { + foo: [ + { value: 'bar', operator: '=' }, + { value: 'baz', operator: '!=' }, + ], + }, + { filterNamesAllowList: ['foo'] }, + ], + [ + 'search=term&foo=bar', + { + [FILTERED_SEARCH_TERM]: [{ value: 'term' }], + foo: { value: 'bar', operator: '=' }, + }, + { filteredSearchTermKey: 'search' }, + ], + [ + 'search=my terms', + { + [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + }, + { filteredSearchTermKey: 'search' }, + ], + [ + 'search[]=my&search[]=terms', + { + [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + }, + { filteredSearchTermKey: 'search' }, + ], + [ + 'search=my+terms', + { + [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + }, + { filteredSearchTermKey: 'search', legacySpacesDecode: false }, + ], + [ + 'search=my terms&foo=bar&nop=xxx', + { + [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + foo: { value: 'bar', operator: '=' }, + }, + { filteredSearchTermKey: 'search', filterNamesAllowList: ['foo'] }, + ], + ])( + 'gathers filter values %s into query object=%j when options %j', + (query, result, options = undefined) => { + const res = urlQueryToFilter(query, options); + expect(res).toEqual(result); + }, + ); }); describe('getRecentlyUsedTokenValues', () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index 23e4deab9c1..134c6c8b929 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -65,8 +65,8 @@ export const mockMilestones = [ ]; export const mockEpics = [ - { iid: 1, id: 1, title: 'Foo' }, - { iid: 2, id: 2, title: 'Bar' }, + { iid: 1, id: 1, title: 'Foo', group_full_path: 'gitlab-org' }, + { iid: 2, id: 2, title: 'Bar', group_full_path: 'gitlab-org/design' }, ]; export const mockEmoji1 = { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js index 05bad572472..4140ec09b4e 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions'; import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 3b50927dcc6..f50eafdbc52 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -1,13 +1,13 @@ import { - GlFilteredSearchToken, GlFilteredSearchTokenSegment, GlFilteredSearchSuggestion, GlDropdownDivider, + GlAvatar, } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { @@ -15,6 +15,7 @@ import { DEFAULT_NONE_ANY, } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { mockAuthorToken, mockAuthors } from '../mock_data'; @@ -29,12 +30,22 @@ const defaultStubs = { }, }; +const mockPreloadedAuthors = [ + { + id: 13, + name: 'Administrator', + username: 'root', + avatar_url: 'avatar/url', + }, +]; + function createComponent(options = {}) { const { config = mockAuthorToken, value = { data: '' }, active = false, stubs = defaultStubs, + data = {}, } = options; return mount(AuthorToken, { propsData: { @@ -47,132 +58,172 @@ function createComponent(options = {}) { alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: 'custom-class', }, + data() { + return { ...data }; + }, stubs, }); } describe('AuthorToken', () => { + const originalGon = window.gon; + const currentUserLength = 1; let mock; let wrapper; + const getBaseToken = () => wrapper.findComponent(BaseToken); + beforeEach(() => { mock = new MockAdapter(axios); - wrapper = createComponent(); }); afterEach(() => { + window.gon = originalGon; mock.restore(); wrapper.destroy(); }); - describe('computed', () => { - describe('currentValue', () => { - it('returns lowercase string for `value.data`', () => { - wrapper = createComponent({ value: { data: 'FOO' } }); - - expect(wrapper.vm.currentValue).toBe('foo'); + describe('methods', () => { + describe('fetchAuthorBySearchTerm', () => { + beforeEach(() => { + wrapper = createComponent(); }); - }); - describe('activeAuthor', () => { - it('returns object for currently present `value.data`', async () => { - wrapper = createComponent({ value: { data: mockAuthors[0].username } }); - - wrapper.setData({ - authors: mockAuthors, - }); + it('calls `config.fetchAuthors` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors'); - await wrapper.vm.$nextTick(); + getBaseToken().vm.$emit('fetch-token-values', mockAuthors[0].username); - expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); + expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( + mockAuthorToken.fetchPath, + mockAuthors[0].username, + ); }); - }); - }); - - describe('fetchAuthorBySearchTerm', () => { - it('calls `config.fetchAuthors` with provided searchTerm param', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors'); - - wrapper.vm.fetchAuthorBySearchTerm(mockAuthors[0].username); - expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( - mockAuthorToken.fetchPath, - mockAuthors[0].username, - ); - }); - - it('sets response to `authors` when request is succesful', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); + it('sets response to `authors` when request is succesful', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); - wrapper.vm.fetchAuthorBySearchTerm('root'); + getBaseToken().vm.$emit('fetch-token-values', 'root'); - return waitForPromises().then(() => { - expect(wrapper.vm.authors).toEqual(mockAuthors); + return waitForPromises().then(() => { + expect(getBaseToken().props('tokenValues')).toEqual(mockAuthors); + }); }); - }); - it('calls `createFlash` with flash error message when request fails', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); - wrapper.vm.fetchAuthorBySearchTerm('root'); + getBaseToken().vm.$emit('fetch-token-values', 'root'); - return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching users.'); + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching users.', + }); + }); }); - }); - it('sets `loading` to false when request completes', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + it('sets `loading` to false when request completes', async () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); - wrapper.vm.fetchAuthorBySearchTerm('root'); + getBaseToken().vm.$emit('fetch-token-values', 'root'); - return waitForPromises().then(() => { - expect(wrapper.vm.loading).toBe(false); + await waitForPromises(); + + expect(getBaseToken().props('tokensListLoading')).toBe(false); }); }); }); describe('template', () => { - beforeEach(() => { - wrapper.setData({ - authors: mockAuthors, + const activateTokenValuesList = async () => { + const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + }; + + it('renders base-token component', () => { + wrapper = createComponent({ + value: { data: mockAuthors[0].username }, + data: { authors: mockAuthors }, }); - return wrapper.vm.$nextTick(); - }); + const baseTokenEl = getBaseToken(); - it('renders gl-filtered-search-token component', () => { - expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + tokenValues: mockAuthors, + fnActiveTokenValue: wrapper.vm.getActiveAuthor, + }); }); it('renders token item when value is selected', () => { - wrapper.setProps({ + wrapper = createComponent({ value: { data: mockAuthors[0].username }, + data: { authors: mockAuthors }, + stubs: { Portal: true }, }); return wrapper.vm.$nextTick(() => { const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); expect(tokenSegments).toHaveLength(3); // Author, =, "Administrator" - expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator" + + const tokenValue = tokenSegments.at(2); + + expect(tokenValue.findComponent(GlAvatar).props('src')).toBe(mockAuthors[0].avatar_url); + expect(tokenValue.text()).toBe(mockAuthors[0].name); // "Administrator" }); }); + it('renders token value with correct avatarUrl from author object', async () => { + const getAvatarEl = () => + wrapper.findAll(GlFilteredSearchTokenSegment).at(2).findComponent(GlAvatar); + + wrapper = createComponent({ + value: { data: mockAuthors[0].username }, + data: { + authors: [ + { + ...mockAuthors[0], + }, + ], + }, + stubs: { Portal: true }, + }); + + await wrapper.vm.$nextTick(); + + expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + + wrapper.setData({ + authors: [ + { + ...mockAuthors[0], + avatarUrl: mockAuthors[0].avatar_url, + avatar_url: undefined, + }, + ], + }); + + await wrapper.vm.$nextTick(); + + expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + }); + it('renders provided defaultAuthors as suggestions', async () => { const defaultAuthors = DEFAULT_NONE_ANY; wrapper = createComponent({ active: true, - config: { ...mockAuthorToken, defaultAuthors }, + config: { ...mockAuthorToken, defaultAuthors, preloadedAuthors: mockPreloadedAuthors }, stubs: { Portal: true }, }); - const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); - const suggestionsSegment = tokenSegments.at(2); - suggestionsSegment.vm.$emit('activate'); - await wrapper.vm.$nextTick(); + + await activateTokenValuesList(); const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(defaultAuthors.length); + expect(suggestions).toHaveLength(defaultAuthors.length + currentUserLength); defaultAuthors.forEach((label, index) => { expect(suggestions.at(index).text()).toBe(label.text); }); @@ -189,25 +240,42 @@ describe('AuthorToken', () => { suggestionsSegment.vm.$emit('activate'); await wrapper.vm.$nextTick(); - expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); }); it('renders `DEFAULT_LABEL_ANY` as default suggestions', async () => { wrapper = createComponent({ active: true, - config: { ...mockAuthorToken }, + config: { ...mockAuthorToken, preloadedAuthors: mockPreloadedAuthors }, stubs: { Portal: true }, }); - const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); - const suggestionsSegment = tokenSegments.at(2); - suggestionsSegment.vm.$emit('activate'); - await wrapper.vm.$nextTick(); + + await activateTokenValuesList(); const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(1); + expect(suggestions).toHaveLength(1 + currentUserLength); expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text); }); + + describe('when loading', () => { + beforeEach(() => { + wrapper = createComponent({ + active: true, + config: { + ...mockAuthorToken, + preloadedAuthors: mockPreloadedAuthors, + defaultAuthors: [], + }, + stubs: { Portal: true }, + }); + }); + + it('shows current user', () => { + const firstSuggestion = wrapper.findComponent(GlFilteredSearchSuggestion).text(); + expect(firstSuggestion).toContain('Administrator'); + expect(firstSuggestion).toContain('@root'); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index 0db47f1f189..602864f4fa5 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -175,6 +175,23 @@ describe('BaseToken', () => { expect(setTokenValueToRecentlyUsed).toHaveBeenCalledWith(mockStorageKey, mockTokenValue); }); + + it('does not add token from preloadedTokenValues', async () => { + const mockTokenValue = { + id: 1, + title: 'Foo', + }; + + wrapper.setProps({ + preloadedTokenValues: [mockTokenValue], + }); + + await wrapper.vm.$nextTick(); + + wrapper.vm.handleTokenValueSelected(mockTokenValue); + + expect(setTokenValueToRecentlyUsed).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index fb48aea8e4f..778a214f97e 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -7,7 +7,7 @@ import { import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { @@ -121,7 +121,9 @@ describe('EmojiToken', () => { wrapper.vm.fetchEmojiBySearchTerm('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching emojis.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching emojis.', + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js index addc058f658..68ed46fc3a2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js @@ -67,18 +67,6 @@ describe('EpicToken', () => { await wrapper.vm.$nextTick(); }); - - describe('activeEpic', () => { - it('returns object for currently present `value.data`', async () => { - wrapper.setProps({ - value: { data: `${mockEpics[0].iid}` }, - }); - - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.activeEpic).toEqual(mockEpics[0]); - }); - }); }); describe('methods', () => { @@ -86,9 +74,12 @@ describe('EpicToken', () => { it('calls `config.fetchEpics` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchEpics'); - wrapper.vm.fetchEpicsBySearchTerm('foo'); + wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); - expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith('foo'); + expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith({ + epicPath: '', + search: 'foo', + }); }); it('sets response to `epics` when request is successful', async () => { @@ -96,7 +87,7 @@ describe('EpicToken', () => { data: mockEpics, }); - wrapper.vm.fetchEpicsBySearchTerm(); + wrapper.vm.fetchEpicsBySearchTerm({}); await waitForPromises(); @@ -106,7 +97,7 @@ describe('EpicToken', () => { it('calls `createFlash` with flash error message when request fails', async () => { jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); - wrapper.vm.fetchEpicsBySearchTerm('foo'); + wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); await waitForPromises(); @@ -118,7 +109,7 @@ describe('EpicToken', () => { it('sets `loading` to false when request completes', async () => { jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); - wrapper.vm.fetchEpicsBySearchTerm('foo'); + wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); await waitForPromises(); @@ -128,9 +119,11 @@ describe('EpicToken', () => { }); describe('template', () => { + const getTokenValueEl = () => wrapper.findAllComponents(GlFilteredSearchTokenSegment).at(2); + beforeEach(async () => { wrapper = createComponent({ - value: { data: `${mockEpics[0].iid}` }, + value: { data: `${mockEpics[0].group_full_path}::&${mockEpics[0].iid}` }, data: { epics: mockEpics }, }); @@ -147,5 +140,19 @@ describe('EpicToken', () => { expect(tokenSegments).toHaveLength(3); expect(tokenSegments.at(2).text()).toBe(`${mockEpics[0].title}::&${mockEpics[0].iid}`); }); + + it.each` + value | valueType | tokenValueString + ${`${mockEpics[0].group_full_path}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} + ${`${mockEpics[1].group_full_path}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} + `('renders token item when selection is a $valueType', async ({ value, tokenValueString }) => { + wrapper.setProps({ + value: { data: value }, + }); + + await wrapper.vm.$nextTick(); + + expect(getTokenValueEl().text()).toBe(tokenValueString); + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 57514a0c499..dd1c61b92b8 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -1,5 +1,4 @@ import { - GlFilteredSearchToken, GlFilteredSearchSuggestion, GlFilteredSearchTokenSegment, GlDropdownDivider, @@ -11,13 +10,14 @@ import { mockRegularLabel, mockLabels, } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_LABELS, DEFAULT_NONE_ANY, } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import { mockLabelToken } from '../mock_data'; @@ -25,6 +25,7 @@ import { mockLabelToken } from '../mock_data'; jest.mock('~/flash'); const defaultStubs = { Portal: true, + BaseToken, GlFilteredSearchSuggestionList: { template: '<div></div>', methods: { @@ -68,55 +69,17 @@ describe('LabelToken', () => { wrapper.destroy(); }); - describe('computed', () => { - beforeEach(async () => { - // Label title with spaces is always enclosed in quotations by component. - wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); - - wrapper.setData({ - labels: mockLabels, - }); - - await wrapper.vm.$nextTick(); - }); - - describe('currentValue', () => { - it('returns lowercase string for `value.data`', () => { - expect(wrapper.vm.currentValue).toBe('"foo label"'); - }); - }); - - describe('activeLabel', () => { - it('returns object for currently present `value.data`', () => { - expect(wrapper.vm.activeLabel).toEqual(mockRegularLabel); - }); - }); - - describe('containerStyle', () => { - it('returns object containing `backgroundColor` and `color` properties based on `activeLabel` value', () => { - expect(wrapper.vm.containerStyle).toEqual({ - backgroundColor: mockRegularLabel.color, - color: mockRegularLabel.textColor, - }); - }); - - it('returns empty object when `activeLabel` is not set', async () => { - wrapper.setData({ - labels: [], - }); - - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.containerStyle).toEqual({}); - }); - }); - }); - describe('methods', () => { beforeEach(() => { wrapper = createComponent(); }); + describe('getActiveLabel', () => { + it('returns label object from labels array based on provided `currentValue` param', () => { + expect(wrapper.vm.getActiveLabel(mockLabels, 'foo label')).toEqual(mockRegularLabel); + }); + }); + describe('getLabelName', () => { it('returns value of `name` or `title` property present in provided label param', () => { let mockLabel = { @@ -158,7 +121,9 @@ describe('LabelToken', () => { wrapper.vm.fetchLabelBySearchTerm('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching labels.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching labels.', + }); }); }); @@ -187,8 +152,14 @@ describe('LabelToken', () => { await wrapper.vm.$nextTick(); }); - it('renders gl-filtered-search-token component', () => { - expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + it('renders base-token component', () => { + const baseTokenEl = wrapper.find(BaseToken); + + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + tokenValues: mockLabels, + fnActiveTokenValue: wrapper.vm.getActiveLabel, + }); }); it('renders token item when value is selected', () => { diff --git a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap index e5035614196..ff1dad2de68 100644 --- a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap +++ b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap @@ -4,6 +4,7 @@ exports[`Title edit field matches the snapshot 1`] = ` <gl-form-group-stub label="Title" label-for="title-field-edit" + labeldescription="" > <gl-form-input-stub /> </gl-form-group-stub> diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index 5c29c267c99..2658fa4a706 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -91,7 +91,7 @@ describe('IssueAssigneesComponent', () => { }); it('computes alt text for assignee avatar', () => { - expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham'); + expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Assigned to Terrell Graham'); }); it('renders component root element with class `issue-assignees`', () => { @@ -106,7 +106,7 @@ describe('IssueAssigneesComponent', () => { const expected = mockAssigneesList.slice(0, TEST_MAX_VISIBLE - 1).map((x) => expect.objectContaining({ linkHref: x.web_url, - imgAlt: `Avatar for ${x.name}`, + imgAlt: `Assigned to ${x.name}`, imgCssClasses: TEST_CSS_CLASSES, imgSrc: x.avatar_url, imgSize: TEST_ICON_SIZE, diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index 30b7f0c2d28..23cf6ef9785 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -59,6 +59,7 @@ exports[`Package code instruction single line to match the default snapshot 1`] aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="copy-to-clipboard-icon" + role="img" > <use href="#copy-to-clipboard" diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap new file mode 100644 index 00000000000..b2906973dbd --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap @@ -0,0 +1,110 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` +<gl-modal-stub + actionsecondary="[object Object]" + dismisslabel="Close" + modalclass="" + modalid="runner-aws-deployments-modal" + size="sm" + title="Deploy GitLab Runner in AWS" + titletag="h4" +> + <p> + For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet. + </p> + + <ul + class="gl-list-style-none gl-p-0 gl-mb-0" + > + <li> + <gl-link-stub + class="gl-display-flex gl-font-weight-bold" + href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml&stackName=linux-docker-nonspot¶m_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host" + target="_blank" + > + <img + alt="linux-docker-nonspot" + class="gl-mt-2 gl-mr-5 gl-mb-6" + height="46" + src="/assets/aws-cloud-formation.png" + title="linux-docker-nonspot" + width="46" + /> + + Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor. + + </gl-link-stub> + </li> + <li> + <gl-link-stub + class="gl-display-flex gl-font-weight-bold" + href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml&stackName=linux-docker-spotonly¶m_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host" + target="_blank" + > + <img + alt="linux-docker-spotonly" + class="gl-mt-2 gl-mr-5 gl-mb-6" + height="46" + src="/assets/aws-cloud-formation.png" + title="linux-docker-spotonly" + width="46" + /> + + Amazon Linux 2 Docker HA with manual scaling and optional scheduling. 100% spot. + + </gl-link-stub> + </li> + <li> + <gl-link-stub + class="gl-display-flex gl-font-weight-bold" + href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml&stackName=win2019-shell-non-spot¶m_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host" + target="_blank" + > + <img + alt="win2019-shell-non-spot" + class="gl-mt-2 gl-mr-5 gl-mb-6" + height="46" + src="/assets/aws-cloud-formation.png" + title="win2019-shell-non-spot" + width="46" + /> + + Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor. + + </gl-link-stub> + </li> + <li> + <gl-link-stub + class="gl-display-flex gl-font-weight-bold" + href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml&stackName=win2019-shell-spot¶m_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host" + target="_blank" + > + <img + alt="win2019-shell-spot" + class="gl-mt-2 gl-mr-5 gl-mb-6" + height="46" + src="/assets/aws-cloud-formation.png" + title="win2019-shell-spot" + width="46" + /> + + Windows 2019 Shell with manual scaling and optional scheduling. 100% spot. + + </gl-link-stub> + </li> + </ul> + + <p> + <gl-sprintf-stub + message="Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}." + /> + </p> + + <p + class="gl-font-sm gl-mb-0" + > + If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account. + </p> +</gl-modal-stub> +`; diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js new file mode 100644 index 00000000000..69db3ec7132 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js @@ -0,0 +1,75 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { getBaseURL } from '~/lib/utils/url_utility'; +import { + EXPERIMENT_NAME, + CF_BASE_URL, + TEMPLATES_BASE_URL, + EASY_BUTTONS, +} from '~/vue_shared/components/runner_aws_deployments/constants'; +import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue'; + +jest.mock('~/experimentation/experiment_tracking'); + +describe('RunnerAwsDeploymentsModal', () => { + let wrapper; + + const findEasyButtons = () => wrapper.findAllComponents(GlLink); + + const createComponent = () => { + wrapper = shallowMount(RunnerAwsDeploymentsModal, { + propsData: { + modalId: 'runner-aws-deployments-modal', + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the modal', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should contain all easy buttons', () => { + expect(findEasyButtons()).toHaveLength(EASY_BUTTONS.length); + }); + + describe('first easy button', () => { + const findFirstButton = () => findEasyButtons().at(0); + + it('should contain the correct description', () => { + expect(findFirstButton().text()).toBe(EASY_BUTTONS[0].description); + }); + + it('should contain the correct link', () => { + const link = findFirstButton().attributes('href'); + + expect(link.startsWith(CF_BASE_URL)).toBe(true); + expect( + link.includes( + `templateURL=${encodeURIComponent(TEMPLATES_BASE_URL + EASY_BUTTONS[0].templateName)}`, + ), + ).toBe(true); + expect(link.includes(`stackName=${EASY_BUTTONS[0].stackName}`)).toBe(true); + expect( + link.includes(`param_3GITLABRunnerInstanceURL=${encodeURIComponent(getBaseURL())}`), + ).toBe(true); + }); + + it('should track an event when clicked', () => { + findFirstButton().vm.$emit('click'); + + expect(ExperimentTracking).toHaveBeenCalledWith(EXPERIMENT_NAME); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + `template_clicked_${EASY_BUTTONS[0].stackName}`, + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js new file mode 100644 index 00000000000..639668761ea --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RunnerAwsDeployments from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue'; +import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue'; + +describe('RunnerAwsDeployments component', () => { + let wrapper; + + const findModalButton = () => wrapper.findByTestId('show-modal-button'); + const findModal = () => wrapper.findComponent(RunnerAwsDeploymentsModal); + + const createComponent = () => { + wrapper = extendedWrapper(shallowMount(RunnerAwsDeployments)); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should show the "Deploy GitLab Runner in AWS" button', () => { + expect(findModalButton().exists()).toBe(true); + expect(findModalButton().text()).toBe('Deploy GitLab Runner in AWS'); + }); + + it('should not render the modal once mounted', () => { + expect(findModal().exists()).toBe(false); + }); + + it('should render the modal once clicked', async () => { + findModalButton().vm.$emit('click'); + + await nextTick(); + + expect(findModal().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js new file mode 100644 index 00000000000..d58c87d66cb --- /dev/null +++ b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js @@ -0,0 +1,108 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { + expectedDownloadDropdownProps, + securityReportMergeRequestDownloadPathsQueryResponse, +} from 'jest/vue_shared/security_reports/mock_data'; +import createFlash from '~/flash'; +import Component from '~/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue'; +import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, +} from '~/vue_shared/security_reports/constants'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; + +jest.mock('~/flash'); + +describe('Merge request artifact Download', () => { + let wrapper; + + const defaultProps = { + reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION], + targetProjectFullPath: '/path', + mrIid: 123, + }; + + const createWrapper = ({ propsData, options }) => { + wrapper = shallowMount(Component, { + stubs: { + SecurityReportDownloadDropdown, + }, + propsData: { + ...defaultProps, + ...propsData, + }, + ...options, + }); + }; + + const pendingHandler = () => new Promise(() => {}); + const successHandler = () => + Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryResponse }); + const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] }); + const createMockApolloProvider = (handler) => { + Vue.use(VueApollo); + const requestHandlers = [[securityReportMergeRequestDownloadPathsQuery, handler]]; + + return createMockApollo(requestHandlers); + }; + + const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('given the query is loading', () => { + beforeEach(() => { + createWrapper({ + options: { + apolloProvider: createMockApolloProvider(pendingHandler), + }, + }); + }); + + it('loading is true', () => { + expect(findDownloadDropdown().props('loading')).toBe(true); + }); + }); + + describe('given the query loads successfully', () => { + beforeEach(() => { + createWrapper({ + options: { + apolloProvider: createMockApolloProvider(successHandler), + }, + }); + }); + + it('renders the download dropdown', () => { + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); + }); + }); + + describe('given the query fails', () => { + beforeEach(() => { + createWrapper({ + options: { + apolloProvider: createMockApolloProvider(failureHandler), + }, + }); + }); + + it('calls createFlash correctly', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: Component.i18n.apiError, + captureError: true, + error: expect.any(Error), + }); + }); + + it('renders nothing', () => { + expect(findDownloadDropdown().props('artifacts')).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js deleted file mode 100644 index 68ea94e72ce..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js +++ /dev/null @@ -1,127 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; - -import LabelsSelect from '~/labels_select'; -import BaseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; - -import { mockConfig, mockLabels } from './mock_data'; - -const createComponent = (config = mockConfig) => - shallowMount(BaseComponent, { - propsData: config, - }); - -describe('BaseComponent', () => { - let wrapper; - let vm; - - beforeEach((done) => { - wrapper = createComponent(); - - ({ vm } = wrapper); - - Vue.nextTick(done); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('computed', () => { - describe('hiddenInputName', () => { - it('returns correct string when showCreate prop is `true`', () => { - expect(vm.hiddenInputName).toBe('issue[label_names][]'); - }); - - it('returns correct string when showCreate prop is `false`', async () => { - await wrapper.setProps({ showCreate: false }); - - expect(vm.hiddenInputName).toBe('label_id[]'); - }); - }); - - describe('createLabelTitle', () => { - it('returns `Create project label` when `isProject` prop is true', () => { - expect(vm.createLabelTitle).toBe('Create project label'); - }); - - it('return `Create group label` when `isProject` prop is false', async () => { - await wrapper.setProps({ isProject: false }); - - expect(vm.createLabelTitle).toBe('Create group label'); - }); - }); - - describe('manageLabelsTitle', () => { - it('returns `Manage project labels` when `isProject` prop is true', () => { - expect(vm.manageLabelsTitle).toBe('Manage project labels'); - }); - - it('return `Manage group labels` when `isProject` prop is false', async () => { - await wrapper.setProps({ isProject: false }); - - expect(vm.manageLabelsTitle).toBe('Manage group labels'); - }); - }); - }); - - describe('methods', () => { - describe('handleClick', () => { - it('emits onLabelClick event with label and list of labels as params', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.handleClick(mockLabels[0]); - - expect(vm.$emit).toHaveBeenCalledWith('onLabelClick', mockLabels[0]); - }); - }); - - describe('handleCollapsedValueClick', () => { - it('emits toggleCollapse event on component', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.handleCollapsedValueClick(); - - expect(vm.$emit).toHaveBeenCalledWith('toggleCollapse'); - }); - }); - - describe('handleDropdownHidden', () => { - it('emits onDropdownClose event on component', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.handleDropdownHidden(); - - expect(vm.$emit).toHaveBeenCalledWith('onDropdownClose'); - }); - }); - }); - - describe('mounted', () => { - it('creates LabelsSelect object and assigns it to `labelsDropdon` as prop', () => { - expect(vm.labelsDropdown instanceof LabelsSelect).toBe(true); - }); - }); - - describe('template', () => { - it('renders component container element with classes `block labels`', () => { - expect(vm.$el.classList.contains('block')).toBe(true); - expect(vm.$el.classList.contains('labels')).toBe(true); - }); - - it('renders `.selectbox` element', () => { - expect(vm.$el.querySelector('.selectbox')).not.toBeNull(); - expect(vm.$el.querySelector('.selectbox').getAttribute('style')).toBe('display: none;'); - }); - - it('renders `.dropdown` element', () => { - expect(vm.$el.querySelector('.dropdown')).not.toBeNull(); - }); - - it('renders `.dropdown-menu` element', () => { - const dropdownMenuEl = vm.$el.querySelector('.dropdown-menu'); - - expect(dropdownMenuEl).not.toBeNull(); - expect(dropdownMenuEl.querySelector('.dropdown-page-one')).not.toBeNull(); - expect(dropdownMenuEl.querySelector('.dropdown-content')).not.toBeNull(); - expect(dropdownMenuEl.querySelector('.dropdown-loading')).not.toBeNull(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js deleted file mode 100644 index 79851e5db05..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ /dev/null @@ -1,90 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; - -import { mockConfig, mockLabels } from './mock_data'; - -const componentConfig = { - ...mockConfig, - fieldName: 'label_id[]', - labels: mockLabels, - showExtraOptions: false, -}; - -const createComponent = (config = componentConfig) => { - const Component = Vue.extend(dropdownButtonComponent); - - return mountComponent(Component, config); -}; - -describe('DropdownButtonComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('dropdownToggleText', () => { - it('returns text as `Label` when `labels` prop is empty array', () => { - const mockEmptyLabels = { ...componentConfig, labels: [] }; - const vmEmptyLabels = createComponent(mockEmptyLabels); - - expect(vmEmptyLabels.dropdownToggleText).toBe('Label'); - vmEmptyLabels.$destroy(); - }); - - it('returns first label name with remaining label count when `labels` prop has more than one item', () => { - const mockMoreLabels = { ...componentConfig, labels: mockLabels.concat(mockLabels) }; - const vmMoreLabels = createComponent(mockMoreLabels); - - expect(vmMoreLabels.dropdownToggleText).toBe( - `Foo Label +${mockMoreLabels.labels.length - 1} more`, - ); - vmMoreLabels.$destroy(); - }); - - it('returns first label name when `labels` prop has only one item present', () => { - const singleLabel = { ...componentConfig, labels: [mockLabels[0]] }; - const vmSingleLabel = createComponent(singleLabel); - - expect(vmSingleLabel.dropdownToggleText).toBe(mockLabels[0].title); - - vmSingleLabel.$destroy(); - }); - }); - }); - - describe('template', () => { - it('renders component container element of type `button`', () => { - expect(vm.$el.nodeName).toBe('BUTTON'); - }); - - it('renders component container element with required data attributes', () => { - expect(vm.$el.dataset.abilityName).toBe(vm.abilityName); - expect(vm.$el.dataset.fieldName).toBe(vm.fieldName); - expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath); - expect(vm.$el.dataset.labels).toBe(vm.labelsPath); - expect(vm.$el.dataset.namespacePath).toBe(vm.namespace); - expect(vm.$el.dataset.showAny).not.toBeDefined(); - }); - - it('renders dropdown toggle text element', () => { - const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text'); - - expect(dropdownToggleTextEl).not.toBeNull(); - expect(dropdownToggleTextEl.innerText.trim()).toBe('Foo Label +1 more'); - }); - - it('renders dropdown button icon', () => { - const dropdownIconEl = vm.$el.querySelector('.dropdown-menu-toggle .gl-icon'); - - expect(dropdownIconEl).not.toBeNull(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js deleted file mode 100644 index 322e632da02..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; - -import { mockSuggestedColors } from './mock_data'; - -const createComponent = (headerTitle) => { - const Component = Vue.extend(dropdownCreateLabelComponent); - - return mountComponent(Component, { - headerTitle, - }); -}; - -describe('DropdownCreateLabelComponent', () => { - const colorsCount = Object.keys(mockSuggestedColors).length; - let vm; - - beforeEach(() => { - gon.suggested_label_colors = mockSuggestedColors; - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('created', () => { - it('initializes `suggestedColors` prop on component from `gon.suggested_color_labels` object', () => { - expect(vm.suggestedColors.length).toBe(colorsCount); - }); - }); - - describe('template', () => { - it('renders component container element with classes `dropdown-page-two dropdown-new-label`', () => { - expect(vm.$el.classList.contains('dropdown-page-two', 'dropdown-new-label')).toBe(true); - }); - - it('renders `Go back` button on component header', () => { - const backButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-back'); - - expect(backButtonEl).not.toBe(null); - expect(backButtonEl.querySelector('[data-testid="arrow-left-icon"]')).not.toBe(null); - }); - - it('renders component header element as `Create new label` when `headerTitle` prop is not provided', () => { - const headerEl = vm.$el.querySelector('.dropdown-title'); - - expect(headerEl.innerText.trim()).toContain('Create new label'); - }); - - it('renders component header element with value of `headerTitle` prop', () => { - const headerTitle = 'Create project label'; - const vmWithHeaderTitle = createComponent(headerTitle); - const headerEl = vmWithHeaderTitle.$el.querySelector('.dropdown-title'); - - expect(headerEl.innerText.trim()).toContain(headerTitle); - vmWithHeaderTitle.$destroy(); - }); - - it('renders `Close` button on component header', () => { - const closeButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-close'); - - expect(closeButtonEl).not.toBe(null); - }); - - it('renders `Name new label` input element', () => { - expect(vm.$el.querySelector('.dropdown-labels-error.js-label-error')).not.toBe(null); - expect(vm.$el.querySelector('input#new_label_name.default-dropdown-input')).not.toBe(null); - }); - - it('renders suggested colors list elements', () => { - const colorsListContainerEl = vm.$el.querySelector('.suggest-colors.suggest-colors-dropdown'); - - expect(colorsListContainerEl).not.toBe(null); - expect(colorsListContainerEl.querySelectorAll('a').length).toBe(colorsCount); - - const colorItemEl = colorsListContainerEl.querySelectorAll('a')[0]; - - expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0].colorCode); - expect(colorItemEl.getAttribute('style')).toBe('background-color: rgb(0, 153, 102);'); - }); - - it('renders color input element', () => { - expect(vm.$el.querySelector('.dropdown-label-color-input')).not.toBe(null); - expect( - vm.$el.querySelector('.dropdown-label-color-preview.js-dropdown-label-color-preview'), - ).not.toBe(null); - - expect(vm.$el.querySelector('input#new_label_color.default-dropdown-input')).not.toBe(null); - }); - - it('renders component action buttons', () => { - const createBtnEl = vm.$el.querySelector('button.js-new-label-btn'); - const cancelBtnEl = vm.$el.querySelector('button.js-cancel-label-btn'); - - expect(createBtnEl).not.toBe(null); - expect(createBtnEl.innerText.trim()).toBe('Create'); - expect(cancelBtnEl.innerText.trim()).toBe('Cancel'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js deleted file mode 100644 index 7e9e242a4f5..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js +++ /dev/null @@ -1,75 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; - -import { mockConfig } from './mock_data'; - -const createComponent = ( - labelsWebUrl = mockConfig.labelsWebUrl, - createLabelTitle, - manageLabelsTitle, -) => { - const Component = Vue.extend(dropdownFooterComponent); - - return mountComponent(Component, { - labelsWebUrl, - createLabelTitle, - manageLabelsTitle, - }); -}; - -describe('DropdownFooterComponent', () => { - const createLabelTitle = 'Create project label'; - const manageLabelsTitle = 'Manage project labels'; - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('template', () => { - it('renders link element with `Create new label` when `createLabelTitle` prop is not provided', () => { - const createLabelEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-toggle-page'); - - expect(createLabelEl).not.toBeNull(); - expect(createLabelEl.innerText.trim()).toBe('Create new label'); - }); - - it('renders link element with value of `createLabelTitle` prop', () => { - const vmWithCreateLabelTitle = createComponent(mockConfig.labelsWebUrl, createLabelTitle); - const createLabelEl = vmWithCreateLabelTitle.$el.querySelector( - '.dropdown-footer-list .dropdown-toggle-page', - ); - - expect(createLabelEl.innerText.trim()).toBe(createLabelTitle); - vmWithCreateLabelTitle.$destroy(); - }); - - it('renders link element with `Manage labels` when `manageLabelsTitle` prop is not provided', () => { - const manageLabelsEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-external-link'); - - expect(manageLabelsEl).not.toBeNull(); - expect(manageLabelsEl.getAttribute('href')).toBe(vm.labelsWebUrl); - expect(manageLabelsEl.innerText.trim()).toBe('Manage labels'); - }); - - it('renders link element with value of `manageLabelsTitle` prop', () => { - const vmWithManageLabelsTitle = createComponent( - mockConfig.labelsWebUrl, - createLabelTitle, - manageLabelsTitle, - ); - const manageLabelsEl = vmWithManageLabelsTitle.$el.querySelector( - '.dropdown-footer-list .dropdown-external-link', - ); - - expect(manageLabelsEl.innerText.trim()).toBe(manageLabelsTitle); - vmWithManageLabelsTitle.$destroy(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js deleted file mode 100644 index 0b9a7262e41..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue'; - -const createComponent = () => { - const Component = Vue.extend(dropdownHeaderComponent); - - return mountComponent(Component); -}; - -describe('DropdownHeaderComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('template', () => { - it('renders header text element', () => { - const headerEl = vm.$el.querySelector('.dropdown-title span'); - - expect(headerEl.innerText.trim()).toBe('Assign labels'); - }); - - it('renders `Close` button element', () => { - const closeBtnEl = vm.$el.querySelector( - '.dropdown-title button.dropdown-title-button.dropdown-menu-close', - ); - - expect(closeBtnEl).not.toBeNull(); - expect(closeBtnEl.querySelector('.dropdown-menu-close-icon')).not.toBeNull(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js deleted file mode 100644 index 510e537b1cd..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue'; - -const createComponent = () => { - const Component = Vue.extend(dropdownSearchInputComponent); - - return mountComponent(Component); -}; - -describe('DropdownSearchInputComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('template', () => { - it('renders input element with type `search`', () => { - const inputEl = vm.$el.querySelector('input.dropdown-input-field'); - - expect(inputEl).not.toBeNull(); - expect(inputEl.getAttribute('type')).toBe('search'); - }); - - it('renders search icon element', () => { - expect(vm.$el.querySelector('.dropdown-input-search')).not.toBeNull(); - }); - - it('renders clear search icon element', () => { - expect(vm.$el.querySelector('.dropdown-input-clear.js-dropdown-input-clear')).not.toBeNull(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js deleted file mode 100644 index 30dd92b72a4..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue'; - -const createComponent = (canEdit = true) => - shallowMount(dropdownTitleComponent, { - propsData: { - canEdit, - }, - }); - -describe('DropdownTitleComponent', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('template', () => { - it('renders title text', () => { - expect(wrapper.vm.$el.classList.contains('title', 'hide-collapsed')).toBe(true); - expect(wrapper.vm.$el.innerText.trim()).toContain('Labels'); - }); - - it('renders spinner icon element', () => { - expect(wrapper.find(GlLoadingIcon)).not.toBeNull(); - }); - - it('renders `Edit` button element', () => { - const editBtnEl = wrapper.vm.$el.querySelector('button.edit-link.js-sidebar-dropdown-toggle'); - - expect(editBtnEl).not.toBeNull(); - expect(editBtnEl.innerText.trim()).toBe('Edit'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js deleted file mode 100644 index 37f59c108df..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import { GlLabel } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; - -import { mockConfig, mockLabels } from './mock_data'; - -const createComponent = ( - labels = mockLabels, - labelFilterBasePath = mockConfig.labelFilterBasePath, -) => - mount(DropdownValueComponent, { - propsData: { - labels, - labelFilterBasePath, - enableScopedLabels: true, - }, - stubs: { - GlLabel: true, - }, - }); - -describe('DropdownValueComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.destroy(); - }); - - describe('computed', () => { - describe('isEmpty', () => { - it('returns true if `labels` prop is empty', () => { - const vmEmptyLabels = createComponent([]); - - expect(vmEmptyLabels.classes()).not.toContain('has-labels'); - vmEmptyLabels.destroy(); - }); - - it('returns false if `labels` prop is empty', () => { - expect(vm.classes()).toContain('has-labels'); - }); - }); - }); - - describe('methods', () => { - describe('labelFilterUrl', () => { - it('returns URL string starting with labelFilterBasePath and encoded label.title', () => { - expect(vm.find(GlLabel).props('target')).toBe( - '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', - ); - }); - }); - - describe('showScopedLabels', () => { - it('returns true if the label is scoped label', () => { - const labels = vm.findAll(GlLabel); - expect(labels.length).toEqual(2); - expect(labels.at(1).props('scoped')).toBe(true); - }); - }); - }); - - describe('template', () => { - it('renders component container element with classes `hide-collapsed value issuable-show-labels`', () => { - expect(vm.classes()).toContain('hide-collapsed', 'value', 'issuable-show-labels'); - }); - - it('render slot content inside component when `labels` prop is empty', () => { - const vmEmptyLabels = createComponent([]); - - expect(vmEmptyLabels.find('.text-secondary').text().trim()).toBe(mockConfig.emptyValueText); - vmEmptyLabels.destroy(); - }); - - it('renders DropdownValueComponent element', () => { - const labelEl = vm.find(GlLabel); - - expect(labelEl.exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js deleted file mode 100644 index 73716d4edf3..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js +++ /dev/null @@ -1,57 +0,0 @@ -export const mockLabels = [ - { - id: 26, - title: 'Foo Label', - description: 'Foobar', - color: '#BADA55', - text_color: '#FFFFFF', - }, - { - id: 27, - title: 'Foo::Bar', - description: 'Foobar', - color: '#0033CC', - text_color: '#FFFFFF', - }, -]; - -export const mockSuggestedColors = { - '#009966': 'Green-cyan', - '#8fbc8f': 'Dark sea green', - '#3cb371': 'Medium sea green', - '#00b140': 'Green screen', - '#013220': 'Dark green', - '#6699cc': 'Blue-gray', - '#0000ff': 'Blue', - '#e6e6fa': 'Lavendar', - '#9400d3': 'Dark violet', - '#330066': 'Deep violet', - '#808080': 'Gray', - '#36454f': 'Charcoal grey', - '#f7e7ce': 'Champagne', - '#c21e56': 'Rose red', - '#cc338b': 'Magenta-pink', - '#dc143c': 'Crimson', - '#ff0000': 'Red', - '#cd5b45': 'Dark coral', - '#eee600': 'Titanium yellow', - '#ed9121': 'Carrot orange', - '#c39953': 'Aztec Gold', -}; - -export const mockConfig = { - showCreate: true, - isProject: true, - abilityName: 'issue', - context: { - labels: mockLabels, - }, - namespace: 'gitlab-org', - updatePath: '/gitlab-org/my-project/issue/1', - labelsPath: '/gitlab-org/my-project/-/labels.json', - labelsWebUrl: '/gitlab-org/my-project/-/labels', - labelFilterBasePath: '/gitlab-org/my-project/issues', - canEdit: true, - suggestedColors: mockSuggestedColors, - emptyValueText: 'None', -}; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js index 003f3d2b4e6..8c1693e8dcc 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; +import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue'; -import { mockLabels } from './mock_data'; +import { mockCollapsedLabels as mockLabels } from './mock_data'; const createComponent = (labels = mockLabels) => { const Component = Vue.extend(dropdownValueCollapsedComponent); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index 3f00eab17b7..be849789667 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -2,12 +2,12 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; -import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue'; import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue'; +import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index f293b8422e7..730afcbecab 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -33,6 +33,23 @@ export const mockLabels = [ }, ]; +export const mockCollapsedLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, + { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + text_color: '#FFFFFF', + }, +]; + export const mockConfig = { allowLabelEdit: true, allowLabelCreate: true, diff --git a/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js b/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js new file mode 100644 index 00000000000..7ca8c619ffc --- /dev/null +++ b/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js @@ -0,0 +1,30 @@ +export const userCalloutsResponse = (callouts = []) => ({ + data: { + currentUser: { + id: 'gid://gitlab/User/46', + __typename: 'UserCore', + callouts: { + __typename: 'UserCalloutConnection', + nodes: callouts.map((callout) => ({ + __typename: 'UserCallout', + featureName: callout.toUpperCase(), + dismissedAt: '2021-02-12T11:10:01Z', + })), + }, + }, + }, +}); + +export const anonUserCalloutsResponse = () => ({ data: { currentUser: null } }); + +export const userCalloutMutationResponse = (variables, errors = []) => ({ + data: { + userCalloutCreate: { + errors, + userCallout: { + featureName: variables.input.featureName.toUpperCase(), + dismissedAt: '2021-02-12T11:10:01Z', + }, + }, + }, +}); diff --git a/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js new file mode 100644 index 00000000000..70dec42ab32 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js @@ -0,0 +1,306 @@ +import { mount } from '@vue/test-utils'; +import { merge } from 'lodash'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; +import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.query.graphql'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { + anonUserCalloutsResponse, + userCalloutMutationResponse, + userCalloutsResponse, +} from './user_callout_dismisser_mock_data'; + +Vue.use(VueApollo); + +const initialSlotProps = (changes = {}) => ({ + dismiss: expect.any(Function), + isAnonUser: false, + isDismissed: false, + isLoadingQuery: true, + isLoadingMutation: false, + mutationError: null, + queryError: null, + shouldShowCallout: false, + ...changes, +}); + +describe('UserCalloutDismisser', () => { + let wrapper; + + const MOCK_FEATURE_NAME = 'mock_feature_name'; + + // Query handlers + const successHandlerFactory = (dismissedCallouts = []) => async () => + userCalloutsResponse(dismissedCallouts); + const anonUserHandler = async () => anonUserCalloutsResponse(); + const errorHandler = () => Promise.reject(new Error('query error')); + const pendingHandler = () => new Promise(() => {}); + + // Mutation handlers + const mutationSuccessHandlerSpy = jest.fn(async (variables) => + userCalloutMutationResponse(variables), + ); + const mutationErrorHandlerSpy = jest.fn(async (variables) => + userCalloutMutationResponse(variables, ['mutation error']), + ); + + const defaultScopedSlotSpy = jest.fn(); + + const callDismissSlotProp = () => defaultScopedSlotSpy.mock.calls[0][0].dismiss(); + + const createComponent = ({ queryHandler, mutationHandler, ...options }) => { + wrapper = mount( + UserCalloutDismisser, + merge( + { + propsData: { + featureName: MOCK_FEATURE_NAME, + }, + scopedSlots: { + default: defaultScopedSlotSpy, + }, + apolloProvider: createMockApollo([ + [getUserCalloutsQuery, queryHandler], + [dismissUserCalloutMutation, mutationHandler], + ]), + }, + options, + ), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent({ + queryHandler: pendingHandler, + }); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith(initialSlotProps()); + }); + }); + + describe('when loaded and dismissed', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory([MOCK_FEATURE_NAME]), + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('when loaded and not dismissed', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + }); + }); + + describe('when loaded with errors', () => { + beforeEach(() => { + createComponent({ + queryHandler: errorHandler, + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + queryError: expect.any(Error), + }), + ); + }); + }); + + describe('when loaded and the user is anonymous', () => { + beforeEach(() => { + createComponent({ + queryHandler: anonUserHandler, + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isAnonUser: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('when skipQuery is true', () => { + let queryHandler; + beforeEach(() => { + queryHandler = jest.fn(); + + createComponent({ + queryHandler, + propsData: { + skipQuery: true, + }, + }); + }); + + it('does not run the query', async () => { + expect(queryHandler).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + }); + }); + + describe('dismissing', () => { + describe('given it succeeds', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + mutationHandler: mutationSuccessHandlerSpy, + }); + + return waitForPromises(); + }); + + it('dismissing calls mutation', () => { + expect(mutationSuccessHandlerSpy).not.toHaveBeenCalled(); + + callDismissSlotProp(); + + expect(mutationSuccessHandlerSpy).toHaveBeenCalledWith({ + input: { featureName: MOCK_FEATURE_NAME }, + }); + }); + + it('passes expected slot props to child', async () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + + callDismissSlotProp(); + + // Wait for Vue re-render due to prop change + await nextTick(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingMutation: true, + isLoadingQuery: false, + }), + ); + + // Wait for mutation to resolve + await waitForPromises(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('given it fails', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + mutationHandler: mutationErrorHandlerSpy, + }); + + return waitForPromises(); + }); + + it('calls mutation', () => { + expect(mutationErrorHandlerSpy).not.toHaveBeenCalled(); + + callDismissSlotProp(); + + expect(mutationErrorHandlerSpy).toHaveBeenCalledWith({ + input: { featureName: MOCK_FEATURE_NAME }, + }); + }); + + it('passes expected slot props to child', async () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + + callDismissSlotProp(); + + // Wait for Vue re-render due to prop change + await nextTick(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingMutation: true, + isLoadingQuery: false, + }), + ); + + // Wait for mutation to resolve + await waitForPromises(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + mutationError: ['mutation error'], + }), + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index 5a609568220..0fabc6525ea 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -49,10 +49,13 @@ describe('User select dropdown', () => { const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); + const searchQueryHandlerSuccess = jest.fn().mockResolvedValue(projectMembersResponse); + const participantsQueryHandlerSuccess = jest.fn().mockResolvedValue(participantsQueryResponse); + const createComponent = ({ props = {}, - searchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse), - participantsQueryHandler = jest.fn().mockResolvedValue(participantsQueryResponse), + searchQueryHandler = searchQueryHandlerSuccess, + participantsQueryHandler = participantsQueryHandlerSuccess, } = {}) => { fakeApollo = createMockApollo([ [searchUsersQuery, searchQueryHandler], @@ -91,6 +94,14 @@ describe('User select dropdown', () => { expect(findParticipantsLoading().exists()).toBe(true); }); + it('skips the queries if `isEditing` prop is false', () => { + createComponent({ props: { isEditing: false } }); + + expect(findParticipantsLoading().exists()).toBe(false); + expect(searchQueryHandlerSuccess).not.toHaveBeenCalled(); + expect(participantsQueryHandlerSuccess).not.toHaveBeenCalled(); + }); + it('emits an `error` event if participants query was rejected', async () => { createComponent({ participantsQueryHandler: mockError }); await waitForPromises(); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index eb23a8ef457..5a6c91bda9f 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -3,8 +3,8 @@ import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; -const TEST_EDIT_URL = '/gitlab-test/test/-/edit/master/'; -const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/master/-/'; +const TEST_EDIT_URL = '/gitlab-test/test/-/edit/main/'; +const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/main/-/'; const TEST_GITPOD_URL = 'https://gitpod.test/'; const ACTION_EDIT = { diff --git a/spec/frontend/whats_new/components/feature_spec.js b/spec/frontend/whats_new/components/feature_spec.js index 9e9cb59c0d6..8f4b4b08f50 100644 --- a/spec/frontend/whats_new/components/feature_spec.js +++ b/spec/frontend/whats_new/components/feature_spec.js @@ -8,7 +8,7 @@ describe("What's new single feature", () => { const exampleFeature = { title: 'Compliance pipeline configurations', body: - '<p>We are thrilled to announce that it is now possible to define enforceable pipelines that will run for any project assigned a corresponding compliance framework.</p>', + '<p data-testid="body-content">We are thrilled to announce that it is now possible to define enforceable pipelines that will run for any project assigned a corresponding <a href="https://en.wikipedia.org/wiki/Compliance_(psychology)" target="_blank" rel="noopener noreferrer" onload="alert(xss)">compliance</a> framework.</p>', stage: 'Manage', 'self-managed': true, 'gitlab-com': true, @@ -20,6 +20,7 @@ describe("What's new single feature", () => { }; const findReleaseDate = () => wrapper.find('[data-testid="release-date"]'); + const findBodyAnchor = () => wrapper.find('[data-testid="body-content"] a'); const createWrapper = ({ feature } = {}) => { wrapper = shallowMount(Feature, { @@ -43,4 +44,13 @@ describe("What's new single feature", () => { expect(findReleaseDate().exists()).toBe(false); }); }); + + it('safe-html config allows target attribute on elements', () => { + createWrapper({ feature: exampleFeature }); + expect(findBodyAnchor().attributes()).toEqual({ + href: expect.any(String), + rel: 'noopener noreferrer', + target: '_blank', + }); + }); }); |