diff options
Diffstat (limited to 'spec/frontend/vue_shared')
46 files changed, 2938 insertions, 135 deletions
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 82503e5a025..04ae2a0f34d 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 @@ -6,10 +6,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` > <button class="btn award-control" - data-boundary="viewport" - data-original-title="Ada, Leonardo, and Marie" data-testid="award-button" - title="" + title="Ada, Leonardo, and Marie" type="button" > <span @@ -32,10 +30,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control active" - data-boundary="viewport" - data-original-title="You, Ada, and Marie" data-testid="award-button" - title="" + title="You, Ada, and Marie" type="button" > <span @@ -58,10 +54,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control" - data-boundary="viewport" - data-original-title="Ada and Jane" data-testid="award-button" - title="" + title="Ada and Jane" type="button" > <span @@ -84,10 +78,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control active" - data-boundary="viewport" - data-original-title="You, Ada, Jane, and Leonardo" data-testid="award-button" - title="" + title="You, Ada, Jane, and Leonardo" type="button" > <span @@ -110,10 +102,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control active" - data-boundary="viewport" - data-original-title="You" data-testid="award-button" - title="" + title="You" type="button" > <span @@ -136,10 +126,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control" - data-boundary="viewport" - data-original-title="Marie" data-testid="award-button" - title="" + title="Marie" type="button" > <span @@ -162,10 +150,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control active" - data-boundary="viewport" - data-original-title="You" data-testid="award-button" - title="" + title="You" type="button" > <span @@ -193,9 +179,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button aria-label="Add reaction" class="award-control btn js-add-award js-test-add-button-class" - data-boundary="viewport" - data-original-title="Add reaction" - title="" + title="Add reaction" type="button" > <span diff --git a/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap index 5ab159a5a84..ca9d4488870 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap @@ -5,11 +5,11 @@ exports[`File row header component adds multiple ellipsises after 40 characters class="file-row-header bg-white sticky-top p-2 js-file-row-header" title="app/assets/javascripts/merge_requests/widget/diffs/notes" > - <span + <gl-truncate-stub class="bold" - > - app/assets/javascripts/…/…/diffs/notes - </span> + position="middle" + text="app/assets/javascripts/merge_requests/widget/diffs/notes" + /> </div> `; @@ -18,11 +18,11 @@ exports[`File row header component renders file path 1`] = ` class="file-row-header bg-white sticky-top p-2 js-file-row-header" title="app/assets" > - <span + <gl-truncate-stub class="bold" - > - app/assets - </span> + position="middle" + text="app/assets" + /> </div> `; @@ -31,10 +31,10 @@ exports[`File row header component trucates path after 40 characters 1`] = ` class="file-row-header bg-white sticky-top p-2 js-file-row-header" title="app/assets/javascripts/merge_requests" > - <span + <gl-truncate-stub class="bold" - > - app/assets/javascripts/merge_requests - </span> + position="middle" + text="app/assets/javascripts/merge_requests" + /> </div> `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap new file mode 100644 index 00000000000..df0fcf5da1c --- /dev/null +++ b/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IntegrationHelpText component should not render the link when start and end is not provided 1`] = ` +<span> + Click nowhere! +</span> +`; + +exports[`IntegrationHelpText component should render the help text 1`] = ` +<span> + Click + <gl-link-stub + href="http://bar.com" + target="_blank" + > + + Bar + + <gl-icon-stub + class="gl-vertical-align-middle" + name="external-link" + size="12" + /> + </gl-link-stub> + ! +</span> +`; 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 dff307e92c2..ef7815f9e9e 100644 --- a/spec/frontend/vue_shared/components/alert_details_table_spec.js +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -23,14 +23,10 @@ const environmentPath = '/fake/path'; describe('AlertDetails', () => { let environmentData = { name: environmentName, path: environmentPath }; - let glFeatures = { exposeEnvironmentPathInAlertDetails: false }; let wrapper; function mountComponent(propsData = {}) { wrapper = mount(AlertDetailsTable, { - provide: { - glFeatures, - }, propsData: { alert: { ...mockAlert, @@ -97,34 +93,19 @@ describe('AlertDetails', () => { expect(findTableField(fields, 'Severity').exists()).toBe(true); expect(findTableField(fields, 'Status').exists()).toBe(true); expect(findTableField(fields, 'Hosts').exists()).toBe(true); - expect(findTableField(fields, 'Environment').exists()).toBe(false); + expect(findTableField(fields, 'Environment').exists()).toBe(true); }); - it('should not show disallowed and flaggedAllowed alert fields', () => { + it('should not show disallowed alert fields', () => { const fields = findTableKeys(); expect(findTableField(fields, 'Typename').exists()).toBe(false); expect(findTableField(fields, 'Todos').exists()).toBe(false); expect(findTableField(fields, 'Notes').exists()).toBe(false); expect(findTableField(fields, 'Assignees').exists()).toBe(false); - expect(findTableField(fields, 'Environment').exists()).toBe(false); - }); - }); - - describe('when exposeEnvironmentPathInAlertDetails is enabled', () => { - beforeEach(() => { - glFeatures = { exposeEnvironmentPathInAlertDetails: true }; - mountComponent(); - }); - - it('should show flaggedAllowed alert fields', () => { - const fields = findTableKeys(); - - expect(findTableField(fields, 'Environment').exists()).toBe(true); }); it('should display only the name for the environment', () => { - expect(findTableFieldValueByKey('Iid').text()).toBe('1527542'); expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName); }); diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 0abb72ace2e..63fc8a5749d 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -62,7 +62,7 @@ describe('vue_shared/components/awards_list', () => { findAwardButtons().wrappers.map(x => { return { classes: x.classes(), - title: x.attributes('data-original-title'), + title: x.attributes('title'), html: x.find('[data-testid="award-html"]').element.innerHTML, count: Number(x.find('.js-counter').text()), }; diff --git a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap index 4909d2d4226..023895099b1 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap +++ b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap @@ -59,7 +59,7 @@ exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = ` class="code highlight" > <code - id="blob-code-content" + data-blob-hash="foo-bar" > <span id="LC1" diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index 79195aa1350..8434fdaccde 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -5,9 +5,13 @@ import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/const describe('Blob Simple Viewer component', () => { let wrapper; const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`; + const blobHash = 'foo-bar'; function createComponent(content = contentMock) { wrapper = shallowMount(SimpleViewer, { + provide: { + blobHash, + }, propsData: { content, type: 'text', diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js index 8456ca9d125..96ccf56cbc6 100644 --- a/spec/frontend/vue_shared/components/confirm_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js @@ -62,7 +62,7 @@ describe('vue_shared/components/confirm_modal', () => { wrapper.vm.modalAttributes = MOCK_MODAL_DATA.modalAttributes; }); - it('renders GlModal wtih data', () => { + it('renders GlModal with data', () => { expect(findModal().exists()).toBeTruthy(); expect(findModal().attributes()).toEqual( expect.objectContaining({ @@ -72,6 +72,24 @@ describe('vue_shared/components/confirm_modal', () => { ); }); }); + + describe.each` + desc | attrs | expectation + ${'when message is simple text'} | ${{}} | ${`<div>${MOCK_MODAL_DATA.modalAttributes.message}</div>`} + ${'when message has html'} | ${{ messageHtml: '<p>Header</p><ul onhover="alert(1)"><li>First</li></ul>' }} | ${'<p>Header</p><ul><li>First</li></ul>'} + `('$desc', ({ attrs, expectation }) => { + beforeEach(() => { + createComponent(); + wrapper.vm.modalAttributes = { + ...MOCK_MODAL_DATA.modalAttributes, + ...attrs, + }; + }); + + it('renders message', () => { + expect(findForm().element.innerHTML).toContain(expectation); + }); + }); }); describe('methods', () => { diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js index 892a96b76fd..08e5d828b8f 100644 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js @@ -60,10 +60,9 @@ describe('DropdownButtonComponent', () => { }); it('renders dropdown button icon', () => { - const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa'); + const dropdownIconEl = vm.$el.querySelector('[data-testid="chevron-down-icon"]'); expect(dropdownIconEl).not.toBeNull(); - expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); }); it('renders slot, if default slot exists', () => { diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index d28c35d26bf..bd6a18bf704 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import FileRow from '~/vue_shared/components/file_row.vue'; import FileHeader from '~/vue_shared/components/file_row_header.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import { escapeFileUrl } from '~/lib/utils/url_utility'; describe('File row component', () => { @@ -151,4 +152,18 @@ describe('File row component', () => { expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold'); }); + + it('renders submodule icon', () => { + const submodule = true; + + createComponent({ + file: { + ...file(), + submodule, + }, + level: 0, + }); + + expect(wrapper.find(FileIcon).props('submodule')).toBe(submodule); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index c79880d4766..64bfff3dfa1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -1,5 +1,12 @@ import { shallowMount, mount } from '@vue/test-utils'; -import { GlFilteredSearch, GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { + GlFilteredSearch, + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownItem, + GlFormCheckbox, +} from '@gitlab/ui'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -30,6 +37,8 @@ const createComponent = ({ recentSearchesStorageKey = 'requirements', tokens = mockAvailableTokens, sortOptions, + showCheckbox = false, + checkboxChecked = false, searchInputPlaceholder = 'Filter requirements', } = {}) => { const mountMethod = shallow ? shallowMount : mount; @@ -40,6 +49,8 @@ const createComponent = ({ recentSearchesStorageKey, tokens, sortOptions, + showCheckbox, + checkboxChecked, searchInputPlaceholder, }, }); @@ -364,6 +375,26 @@ describe('FilteredSearchBarRoot', () => { expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems); }); + it('renders checkbox when `showCheckbox` prop is true', async () => { + let wrapperWithCheckbox = createComponent({ + showCheckbox: true, + }); + + expect(wrapperWithCheckbox.find(GlFormCheckbox).exists()).toBe(true); + expect(wrapperWithCheckbox.find(GlFormCheckbox).attributes('checked')).not.toBeDefined(); + + wrapperWithCheckbox.destroy(); + + wrapperWithCheckbox = createComponent({ + showCheckbox: true, + checkboxChecked: true, + }); + + expect(wrapperWithCheckbox.find(GlFormCheckbox).attributes('checked')).toBe('true'); + + wrapperWithCheckbox.destroy(); + }); + it('renders search history items dropdown with formatting done using token symbols', async () => { const wrapperFullMount = createComponent({ sortOptions: mockSortOptions, shallow: false }); wrapperFullMount.vm.recentSearchesStore.addRecentSearch(mockHistoryItems[0]); 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 72840ce381f..3fd1d8b7f42 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 @@ -45,6 +45,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index 12b7fd58670..5b7f7d242e9 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -45,6 +45,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', }, stubs, }); 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 3feb05bab35..74172db81c2 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 @@ -50,6 +50,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index 0ec814e3f15..67f9a9c70cc 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -48,6 +48,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', }, stubs, }); @@ -120,7 +121,9 @@ describe('MilestoneToken', () => { wrapper.vm.fetchMilestoneBySearchTerm('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching milestones.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching milestones.', + }); }); }); diff --git a/spec/frontend/vue_shared/components/integration_help_text_spec.js b/spec/frontend/vue_shared/components/integration_help_text_spec.js new file mode 100644 index 00000000000..4269d36d0e2 --- /dev/null +++ b/spec/frontend/vue_shared/components/integration_help_text_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; + +describe('IntegrationHelpText component', () => { + let wrapper; + const defaultProps = { + message: 'Click %{linkStart}Bar%{linkEnd}!', + messageUrl: 'http://bar.com', + }; + + function createComponent(props = {}) { + return shallowMount(IntegrationHelpText, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should use the gl components', () => { + wrapper = createComponent(); + + expect(wrapper.find(GlSprintf).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(true); + expect(wrapper.find(GlLink).exists()).toBe(true); + }); + + it('should render the help text', () => { + wrapper = createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should not use the gl-link and gl-icon components', () => { + wrapper = createComponent({ message: 'Click nowhere!' }); + + expect(wrapper.find(GlSprintf).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(false); + expect(wrapper.find(GlLink).exists()).toBe(false); + }); + + it('should not render the link when start and end is not provided', () => { + wrapper = createComponent({ message: 'Click nowhere!' }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js index efa9b5796fb..464fe3411dd 100644 --- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js +++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js @@ -239,4 +239,30 @@ describe('Local Storage Sync', () => { }); }); }); + + it('clears localStorage when clear property is true', async () => { + const storageKey = 'key'; + const value = 'initial'; + + createComponent({ + props: { + storageKey, + }, + }); + wrapper.setProps({ + value, + }); + + await wrapper.vm.$nextTick(); + + expect(localStorage.getItem(storageKey)).toBe(value); + + wrapper.setProps({ + clear: true, + }); + + await wrapper.vm.$nextTick(); + + expect(localStorage.getItem(storageKey)).toBe(null); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index b19e74b5b11..c0a000690f8 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -29,6 +29,10 @@ describe('Suggestion Diff component', () => { }); }; + beforeEach(() => { + window.gon.current_user_id = 1; + }); + afterEach(() => { wrapper.destroy(); }); @@ -71,6 +75,14 @@ describe('Suggestion Diff component', () => { expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true); }); + it('does not render apply suggestion button with anonymous user', () => { + window.gon.current_user_id = null; + + createComponent(); + + expect(findApplyButton().exists()).toBe(false); + }); + describe('when apply suggestion is clicked', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js index d7bb8c0d142..5674929716d 100644 --- a/spec/frontend/vue_shared/components/members/mock_data.js +++ b/spec/frontend/vue_shared/components/members/mock_data.js @@ -3,6 +3,7 @@ export const member = { canUpdate: false, canRemove: false, canOverride: false, + isOverridden: false, accessLevel: { integerValue: 50, stringValue: 'Owner' }, source: { id: 178, diff --git a/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js b/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js new file mode 100644 index 00000000000..a1afdbc2b49 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js @@ -0,0 +1,166 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { nextTick } from 'vue'; +import { GlDatepicker } from '@gitlab/ui'; +import { useFakeDate } from 'helpers/fake_date'; +import waitForPromises from 'helpers/wait_for_promises'; +import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue'; +import { member } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ExpirationDatepicker', () => { + // March 15th, 2020 3:00 + useFakeDate(2020, 2, 15, 3); + + let wrapper; + let actions; + let resolveUpdateMemberExpiration; + const $toast = { + show: jest.fn(), + }; + + const createStore = () => { + actions = { + updateMemberExpiration: jest.fn( + () => + new Promise(resolve => { + resolveUpdateMemberExpiration = resolve; + }), + ), + }; + + return new Vuex.Store({ actions }); + }; + + const createComponent = (propsData = {}) => { + wrapper = mount(ExpirationDatepicker, { + propsData: { + member, + permissions: { canUpdate: true }, + ...propsData, + }, + localVue, + store: createStore(), + mocks: { + $toast, + }, + }); + }; + + const findInput = () => wrapper.find('input'); + const findDatepicker = () => wrapper.find(GlDatepicker); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('datepicker input', () => { + it('sets `member.expiresAt` as initial date', async () => { + createComponent({ member: { ...member, expiresAt: '2020-03-17T00:00:00Z' } }); + + await nextTick(); + + expect(findInput().element.value).toBe('2020-03-17'); + }); + }); + + describe('props', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets `minDate` prop as tomorrow', () => { + expect( + findDatepicker() + .props('minDate') + .toISOString(), + ).toBe(new Date('2020-3-16').toISOString()); + }); + + it('sets `target` prop as `null` so datepicker opens on focus', () => { + expect(findDatepicker().props('target')).toBe(null); + }); + + it("sets `container` prop as `null` so table styles don't affect the datepicker styles", () => { + expect(findDatepicker().props('container')).toBe(null); + }); + + it('shows clear button', () => { + expect(findDatepicker().props('showClearButton')).toBe(true); + }); + }); + + describe('when datepicker is changed', () => { + beforeEach(async () => { + createComponent(); + + findDatepicker().vm.$emit('input', new Date('2020-03-17')); + }); + + it('calls `updateMemberExpiration` Vuex action', () => { + expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + expiresAt: new Date('2020-03-17'), + }); + }); + + it('displays toast when successful', async () => { + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Expiration date updated successfully.'); + }); + + it('disables dropdown while waiting for `updateMemberExpiration` to resolve', async () => { + expect(findDatepicker().props('disabled')).toBe(true); + + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect(findDatepicker().props('disabled')).toBe(false); + }); + }); + + describe('when datepicker is cleared', () => { + beforeEach(async () => { + createComponent(); + + findInput().setValue('2020-03-17'); + await nextTick(); + wrapper.find('[data-testid="clear-button"]').trigger('click'); + }); + + it('calls `updateMemberExpiration` Vuex action', () => { + expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + expiresAt: null, + }); + }); + + it('displays toast when successful', async () => { + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Expiration date removed successfully.'); + }); + + it('disables datepicker while waiting for `updateMemberExpiration` to resolve', async () => { + expect(findDatepicker().props('disabled')).toBe(true); + + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect(findDatepicker().props('disabled')).toBe(false); + }); + }); + + describe('when user does not have `canUpdate` permissions', () => { + it('disables datepicker', () => { + createComponent({ permissions: { canUpdate: false } }); + + expect(findDatepicker().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js index 20c1c26d2ee..e593e88438c 100644 --- a/spec/frontend/vue_shared/components/members/table/members_table_spec.js +++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js @@ -3,14 +3,16 @@ import Vuex from 'vuex'; import { getByText as getByTextHelper, getByTestId as getByTestIdHelper, + within, } from '@testing-library/dom'; -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlTable } from '@gitlab/ui'; import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; +import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue'; import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; import * as initUserPopovers from '~/user_popovers'; import { member as memberMock, invite, accessRequest } from '../mock_data'; @@ -26,7 +28,12 @@ describe('MemberList', () => { state: { members: [], tableFields: [], + tableAttrs: { + table: { 'data-qa-selector': 'members_list' }, + tr: { 'data-qa-selector': 'member_row' }, + }, sourceId: 1, + currentUserId: 1, ...state, }, }); @@ -44,6 +51,7 @@ describe('MemberList', () => { 'member-action-buttons', 'role-dropdown', 'remove-group-link-modal', + 'expiration-datepicker', ], }); }; @@ -54,18 +62,24 @@ describe('MemberList', () => { const getByTestId = (id, options) => createWrapper(getByTestIdHelper(wrapper.element, id, options)); + const findTable = () => wrapper.find(GlTable); + afterEach(() => { wrapper.destroy(); wrapper = null; }); describe('fields', () => { - const memberCanUpdate = { + const directMember = { ...memberMock, - canUpdate: true, source: { ...memberMock.source, id: 1 }, }; + const memberCanUpdate = { + ...directMember, + canUpdate: true, + }; + it.each` field | label | member | expectedComponent ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} @@ -75,7 +89,7 @@ describe('MemberList', () => { ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} - ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} `('renders the $label field', ({ field, label, member, expectedComponent }) => { createComponent({ members: [member], @@ -94,19 +108,60 @@ describe('MemberList', () => { } }); - it('renders "Actions" field for screen readers', () => { - createComponent({ members: [memberMock], tableFields: ['actions'] }); + describe('"Actions" field', () => { + it('renders "Actions" field for screen readers', () => { + createComponent({ members: [memberCanUpdate], tableFields: ['actions'] }); - const actionField = getByTestId('col-actions'); + const actionField = getByTestId('col-actions'); - expect(actionField.exists()).toBe(true); - expect(actionField.classes('gl-sr-only')).toBe(true); - expect( - wrapper - .find(`[data-label="Actions"][role="cell"]`) - .find(MemberActionButtons) - .exists(), - ).toBe(true); + expect(actionField.exists()).toBe(true); + expect(actionField.classes('gl-sr-only')).toBe(true); + expect( + wrapper + .find(`[data-label="Actions"][role="cell"]`) + .find(MemberActionButtons) + .exists(), + ).toBe(true); + }); + + describe('when user is not logged in', () => { + it('does not render the "Actions" field', () => { + createComponent({ currentUserId: null, tableFields: ['actions'] }); + + expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + }); + }); + + const memberCanRemove = { + ...directMember, + canRemove: true, + }; + + describe.each` + permission | members + ${'canUpdate'} | ${[memberCanUpdate]} + ${'canRemove'} | ${[memberCanRemove]} + ${'canResend'} | ${[invite]} + `('when one of the members has $permission permissions', ({ members }) => { + it('renders the "Actions" field', () => { + createComponent({ members, tableFields: ['actions'] }); + + expect(getByTestId('col-actions').exists()).toBe(true); + }); + }); + + describe.each` + permission | members + ${'canUpdate'} | ${[memberMock]} + ${'canRemove'} | ${[memberMock]} + ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]} + `('when none of the members have $permission permissions', ({ members }) => { + it('does not render the "Actions" field', () => { + createComponent({ members, tableFields: ['actions'] }); + + expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + }); + }); }); }); @@ -138,4 +193,20 @@ describe('MemberList', () => { expect(initUserPopoversMock).toHaveBeenCalled(); }); + + it('adds QA selector to table', () => { + createComponent(); + + expect(findTable().attributes('data-qa-selector')).toBe('members_list'); + }); + + it('adds QA selector to table row', () => { + createComponent(); + + expect( + findTable() + .find('tbody tr') + .attributes('data-qa-selector'), + ).toBe('member_row'); + }); }); diff --git a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js index 1e47953a510..55ec7000693 100644 --- a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js @@ -30,6 +30,7 @@ describe('RoleDropdown', () => { wrapper = mount(RoleDropdown, { propsData: { member, + permissions: {}, ...propsData, }, localVue, @@ -115,11 +116,11 @@ describe('RoleDropdown', () => { await nextTick(); - expect(findDropdown().attributes('disabled')).toBe('disabled'); + expect(findDropdown().props('disabled')).toBe(true); await waitForPromises(); - expect(findDropdown().attributes('disabled')).toBeUndefined(); + expect(findDropdown().props('disabled')).toBe(false); }); }); }); diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js index f183abc08d6..3f2b2097133 100644 --- a/spec/frontend/vue_shared/components/members/utils_spec.js +++ b/spec/frontend/vue_shared/components/members/utils_spec.js @@ -1,5 +1,19 @@ -import { generateBadges } from '~/vue_shared/components/members/utils'; -import { member as memberMock } from './mock_data'; +import { + generateBadges, + isGroup, + isDirectMember, + isCurrentUser, + canRemove, + canResend, + canUpdate, + canOverride, +} from '~/vue_shared/components/members/utils'; +import { member as memberMock, group, invite } from './mock_data'; + +const DIRECT_MEMBER_ID = 178; +const INHERITED_MEMBER_ID = 179; +const IS_CURRENT_USER_ID = 123; +const IS_NOT_CURRENT_USER_ID = 124; describe('Members Utils', () => { describe('generateBadges', () => { @@ -26,4 +40,83 @@ describe('Members Utils', () => { expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected)); }); }); + + describe('isGroup', () => { + test.each` + member | expected + ${group} | ${true} + ${memberMock} | ${false} + `('returns $expected', ({ member, expected }) => { + expect(isGroup(member)).toBe(expected); + }); + }); + + describe('isDirectMember', () => { + test.each` + sourceId | expected + ${DIRECT_MEMBER_ID} | ${true} + ${INHERITED_MEMBER_ID} | ${false} + `('returns $expected', ({ sourceId, expected }) => { + expect(isDirectMember(memberMock, sourceId)).toBe(expected); + }); + }); + + describe('isCurrentUser', () => { + test.each` + currentUserId | expected + ${IS_CURRENT_USER_ID} | ${true} + ${IS_NOT_CURRENT_USER_ID} | ${false} + `('returns $expected', ({ currentUserId, expected }) => { + expect(isCurrentUser(memberMock, currentUserId)).toBe(expected); + }); + }); + + describe('canRemove', () => { + const memberCanRemove = { + ...memberMock, + canRemove: true, + }; + + test.each` + member | sourceId | expected + ${memberCanRemove} | ${DIRECT_MEMBER_ID} | ${true} + ${memberCanRemove} | ${INHERITED_MEMBER_ID} | ${false} + ${memberMock} | ${INHERITED_MEMBER_ID} | ${false} + `('returns $expected', ({ member, sourceId, expected }) => { + expect(canRemove(member, sourceId)).toBe(expected); + }); + }); + + describe('canResend', () => { + test.each` + member | expected + ${invite} | ${true} + ${{ ...invite, invite: { ...invite.invite, canResend: false } }} | ${false} + `('returns $expected', ({ member, sourceId, expected }) => { + expect(canResend(member, sourceId)).toBe(expected); + }); + }); + + describe('canUpdate', () => { + const memberCanUpdate = { + ...memberMock, + canUpdate: true, + }; + + test.each` + member | currentUserId | sourceId | expected + ${memberCanUpdate} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${true} + ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false} + ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${INHERITED_MEMBER_ID} | ${false} + ${memberMock} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false} + `('returns $expected', ({ member, currentUserId, sourceId, expected }) => { + expect(canUpdate(member, currentUserId, sourceId)).toBe(expected); + }); + }); + + describe('canOverride', () => { + it('returns `false`', () => { + expect(canOverride(memberMock)).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/modal_copy_button_spec.js b/spec/frontend/vue_shared/components/modal_copy_button_spec.js index e5a8860f42e..ca9f8ff54d4 100644 --- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js +++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js @@ -1,9 +1,7 @@ -import Vue from 'vue'; -import { shallowMount } from '@vue/test-utils'; -import modalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { shallowMount, createWrapper } from '@vue/test-utils'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; describe('modal copy button', () => { - const Component = Vue.extend(modalCopyButton); let wrapper; afterEach(() => { @@ -11,16 +9,18 @@ describe('modal copy button', () => { }); beforeEach(() => { - wrapper = shallowMount(Component, { + wrapper = shallowMount(ModalCopyButton, { propsData: { text: 'copy me', title: 'Copy this value', + id: 'test-id', }, }); }); describe('clipboard', () => { it('should fire a `success` event on click', () => { + const root = createWrapper(wrapper.vm.$root); document.execCommand = jest.fn(() => true); window.getSelection = jest.fn(() => ({ toString: jest.fn(() => 'test'), @@ -31,6 +31,7 @@ describe('modal copy button', () => { return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted().success).not.toBeEmpty(); expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(root.emitted('bv::hide::tooltip')).toEqual([['test-id']]); }); }); it("should propagate the clipboard error event if execCommand doesn't work", () => { diff --git a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js new file mode 100644 index 00000000000..233c488b60b --- /dev/null +++ b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import { getByText } from '@testing-library/dom'; +import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; + +describe('MultiSelectDropdown Component', () => { + it('renders items slot', () => { + const wrapper = shallowMount(MultiSelectDropdown, { + propsData: { + text: '', + headerText: '', + }, + slots: { + items: '<p>Test</p>', + }, + }); + expect(getByText(wrapper.element, 'Test')).toBeDefined(); + }); + + it('renders search slot', () => { + const wrapper = shallowMount(MultiSelectDropdown, { + propsData: { + text: '', + headerText: '', + }, + slots: { + search: '<p>Search</p>', + }, + }); + expect(getByText(wrapper.element, 'Search')).toBeDefined(); + }); +}); diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index d943aaf3e5f..0f7c8e97635 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -70,7 +70,7 @@ describe('AlertManagementEmptyState', () => { ...props, }, slots: { - 'emtpy-state': EmptyStateSlot, + 'empty-state': EmptyStateSlot, 'header-actions': HeaderActionsSlot, title: TitleSlot, table: TableSlot, diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index 5cb606b58d9..b743a663f06 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -5,12 +5,16 @@ import component from '~/vue_shared/components/registry/title_area.vue'; describe('title area', () => { let wrapper; + const DYNAMIC_SLOT = 'metadata-dynamic-slot'; + const findSubHeaderSlot = () => wrapper.find('[data-testid="sub-header"]'); const findRightActionsSlot = () => wrapper.find('[data-testid="right-actions"]'); const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`); const findTitle = () => wrapper.find('[data-testid="title"]'); const findAvatar = () => wrapper.find(GlAvatar); const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]'); + const findDynamicSlot = () => wrapper.find(`[data-testid="${DYNAMIC_SLOT}`); + const findSlotOrderElements = () => wrapper.findAll('[slot-test]'); const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => { wrapper = shallowMount(component, { @@ -98,6 +102,59 @@ describe('title area', () => { }); }); + describe('dynamic slots', () => { + const createDynamicSlot = () => { + return wrapper.vm.$createElement('div', { + attrs: { + 'data-testid': DYNAMIC_SLOT, + 'slot-test': true, + }, + }); + }; + it('shows dynamic slots', async () => { + mountComponent(); + // we manually add a new slot to simulate dynamic slots being evaluated after the initial mount + wrapper.vm.$slots[DYNAMIC_SLOT] = createDynamicSlot(); + + await wrapper.vm.$nextTick(); + expect(findDynamicSlot().exists()).toBe(false); + + await wrapper.vm.$nextTick(); + expect(findDynamicSlot().exists()).toBe(true); + }); + + it('preserve the order of the slots', async () => { + mountComponent({ + slots: { + 'metadata-foo': '<div slot-test data-testid="metadata-foo"></div>', + }, + }); + + // rewrite slot putting dynamic slot as first + wrapper.vm.$slots = { + 'metadata-dynamic-slot': createDynamicSlot(), + 'metadata-foo': wrapper.vm.$slots['metadata-foo'], + }; + + await wrapper.vm.$nextTick(); + expect(findDynamicSlot().exists()).toBe(false); + expect(findMetadataSlot('metadata-foo').exists()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect( + findSlotOrderElements() + .at(0) + .attributes('data-testid'), + ).toBe(DYNAMIC_SLOT); + expect( + findSlotOrderElements() + .at(1) + .attributes('data-testid'), + ).toBe('metadata-foo'); + }); + }); + describe('info-messages', () => { it('shows a message when the props contains one', () => { mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index 0f2f263a776..d79df4d0557 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -91,12 +91,25 @@ describe('Editor Service', () => { }); describe('addImage', () => { - it('calls the exec method on the instance', () => { - const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; + const file = new File([], 'some-file.jpg'); + const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' }; - addImage(mockInstance, mockImage); + it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => { + jest.spyOn(URL, 'createObjectURL'); + mockInstance.editor.isWysiwygMode.mockReturnValue(true); + mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() }); - expect(mockInstance.editor.exec).toHaveBeenCalledWith('AddImage', mockImage); + addImage(mockInstance, mockImage, file); + + expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled(); + expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file); + }); + + it('calls the insertText method on the instance when in Markdown mode', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(false); + addImage(mockInstance, mockImage, file); + + expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)'); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js index 0c2ac53aa52..16370a7aaad 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js @@ -15,10 +15,7 @@ describe('Add Image Modal', () => { const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); beforeEach(() => { - wrapper = shallowMount(AddImageModal, { - provide: { glFeatures: { sseImageUploads: true } }, - propsData, - }); + wrapper = shallowMount(AddImageModal, { propsData }); }); describe('when content is loaded', () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index 8c2c0413819..d50cf2915e8 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -180,7 +180,7 @@ describe('Rich Content Editor', () => { wrapper.vm.$refs.editor = mockInstance; findAddImageModal().vm.$emit('addImage', mockImage); - expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage); + expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js index fd745c21bb6..85516eae4cf 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -189,4 +189,30 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => { expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); }); }); + + describe('IMG', () => { + const originalSrc = 'path/to/image.png'; + const alt = 'alt text'; + let node; + + beforeEach(() => { + node = document.createElement('img'); + node.alt = alt; + node.src = originalSrc; + }); + + it('returns an image with its original src of the `original-src` attribute is preset', () => { + node.dataset.originalSrc = originalSrc; + node.src = 'modified/path/to/image.png'; + + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + + expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); + }); + + it('fallback to `src` if no `original-src` is specified on the image', () => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js new file mode 100644 index 00000000000..01f7f3d49c7 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js @@ -0,0 +1,107 @@ +export const mockGraphqlRunnerPlatforms = { + data: { + runnerPlatforms: { + nodes: [ + { + name: 'linux', + humanReadableName: 'Linux', + architectures: { + nodes: [ + { + name: 'amd64', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64', + __typename: 'RunnerArchitecture', + }, + { + name: '386', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386', + __typename: 'RunnerArchitecture', + }, + { + name: 'arm', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm', + __typename: 'RunnerArchitecture', + }, + { + name: 'arm64', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64', + __typename: 'RunnerArchitecture', + }, + ], + __typename: 'RunnerArchitectureConnection', + }, + __typename: 'RunnerPlatform', + }, + { + name: 'osx', + humanReadableName: 'macOS', + architectures: { + nodes: [ + { + name: 'amd64', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64', + __typename: 'RunnerArchitecture', + }, + ], + __typename: 'RunnerArchitectureConnection', + }, + __typename: 'RunnerPlatform', + }, + { + name: 'windows', + humanReadableName: 'Windows', + architectures: { + nodes: [ + { + name: 'amd64', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe', + __typename: 'RunnerArchitecture', + }, + { + name: '386', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe', + __typename: 'RunnerArchitecture', + }, + ], + __typename: 'RunnerArchitectureConnection', + }, + __typename: 'RunnerPlatform', + }, + { + name: 'docker', + humanReadableName: 'Docker', + architectures: null, + __typename: 'RunnerPlatform', + }, + { + name: 'kubernetes', + humanReadableName: 'Kubernetes', + architectures: null, + __typename: 'RunnerPlatform', + }, + ], + __typename: 'RunnerPlatformConnection', + }, + project: { id: 'gid://gitlab/Project/1', __typename: 'Project' }, + group: null, + }, +}; + +export const mockGraphqlInstructions = { + data: { + runnerSetup: { + installInstructions: + "# Download the binary for your system\nsudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64\n\n# Give it permissions to execute\nsudo chmod +x /usr/local/bin/gitlab-runner\n\n# Create a GitLab CI user\nsudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash\n\n# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start\n", + registerInstructions: + 'sudo gitlab-runner register --url http://192.168.1.81:3000/ --registration-token GE5gsjeep_HAtBf9s3Yz', + __typename: 'RunnerSetup', + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js new file mode 100644 index 00000000000..afbcee506c7 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js @@ -0,0 +1,119 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; +import getRunnerPlatforms from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructions from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; + +import { mockGraphqlRunnerPlatforms, mockGraphqlInstructions } from './mock_data'; + +const projectPath = 'gitlab-org/gitlab'; +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerInstructions component', () => { + let wrapper; + let fakeApollo; + + const findModalButton = () => wrapper.find('[data-testid="show-modal-button"]'); + const findPlatformButtons = () => wrapper.findAll('[data-testid="platform-button"]'); + const findArchitectureDropdownItems = () => + wrapper.findAll('[data-testid="architecture-dropdown-item"]'); + const findBinaryInstructionsSection = () => wrapper.find('[data-testid="binary-instructions"]'); + const findRunnerInstructionsSection = () => wrapper.find('[data-testid="runner-instructions"]'); + + beforeEach(() => { + const requestHandlers = [ + [getRunnerPlatforms, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], + [getRunnerSetupInstructions, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + wrapper = shallowMount(RunnerInstructions, { + provide: { + projectPath, + }, + localVue, + apolloProvider: fakeApollo, + }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should show the "Show Runner installation instructions" button', () => { + const button = findModalButton(); + + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Show Runner installation instructions'); + }); + + it('should contain a number of platforms buttons', () => { + const buttons = findPlatformButtons(); + + expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); + }); + + it('should contain a number of dropdown items for the architecture options', () => { + const platformButton = findPlatformButtons().at(0); + platformButton.vm.$emit('click'); + + return wrapper.vm.$nextTick(() => { + const dropdownItems = findArchitectureDropdownItems(); + + expect(dropdownItems).toHaveLength( + mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, + ); + }); + }); + + it('should display the binary installation instructions for a selected architecture', async () => { + const platformButton = findPlatformButtons().at(0); + platformButton.vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const dropdownItem = findArchitectureDropdownItems().at(0); + dropdownItem.vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const runner = findBinaryInstructionsSection(); + + expect(runner.text()).toEqual( + expect.stringContaining('sudo chmod +x /usr/local/bin/gitlab-runner'), + ); + expect(runner.text()).toEqual( + expect.stringContaining( + `sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash`, + ), + ); + expect(runner.text()).toEqual( + expect.stringContaining( + 'sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner', + ), + ); + expect(runner.text()).toEqual(expect.stringContaining('sudo gitlab-runner start')); + }); + + it('should display the runner register instructions for a selected architecture', async () => { + const platformButton = findPlatformButtons().at(0); + platformButton.vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const dropdownItem = findArchitectureDropdownItems().at(0); + dropdownItem.vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const runner = findRunnerInstructionsSection(); + + expect(runner.text()).toEqual( + expect.stringContaining(mockGraphqlInstructions.data.runnerSetup.registerInstructions), + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js new file mode 100644 index 00000000000..a97e26caf53 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js @@ -0,0 +1,375 @@ +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { + GlIcon, + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlSearchBoxByType, + GlButton, +} from '@gitlab/ui'; + +import axios from '~/lib/utils/axios_utils'; +import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue'; + +const mockProjects = [ + { + id: 2, + name_with_namespace: 'Gitlab Org / Gitlab Shell', + full_path: 'gitlab-org/gitlab-shell', + }, + { + id: 3, + name_with_namespace: 'Gnuwget / Wget2', + full_path: 'gnuwget/wget2', + }, + { + id: 4, + name_with_namespace: 'Commit451 / Lab Coat', + full_path: 'Commit451/lab-coat', + }, +]; + +const mockProps = { + projectsFetchPath: '/-/autocomplete/projects?project_id=1', + dropdownButtonTitle: 'Move issuable', + dropdownHeaderTitle: 'Move issuable', + moveInProgress: false, +}; + +const mockEvent = { + stopPropagation: jest.fn(), + preventDefault: jest.fn(), +}; + +const createComponent = (propsData = mockProps) => + shallowMount(IssuableMoveDropdown, { + propsData, + }); + +describe('IssuableMoveDropdown', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + wrapper.vm.$refs.dropdown.hide = jest.fn(); + wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('watch', () => { + describe('searchKey', () => { + it('calls `fetchProjects` with value of the prop', async () => { + jest.spyOn(wrapper.vm, 'fetchProjects'); + wrapper.setData({ + searchKey: 'foo', + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.fetchProjects).toHaveBeenCalledWith('foo'); + }); + }); + }); + + describe('methods', () => { + describe('fetchProjects', () => { + it('sets projectsListLoading to true and projectsListLoadFailed to false', () => { + wrapper.vm.fetchProjects(); + + expect(wrapper.vm.projectsListLoading).toBe(true); + expect(wrapper.vm.projectsListLoadFailed).toBe(false); + }); + + it('calls `axios.get` with `projectsFetchPath` and query param `search`', () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + wrapper.vm.fetchProjects('foo'); + + expect(axios.get).toHaveBeenCalledWith( + mockProps.projectsFetchPath, + expect.objectContaining({ + params: { + search: 'foo', + }, + }), + ); + }); + + it('sets response to `projects` and focuses on searchInput when request is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projects).toBe(mockProjects); + expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); + }); + + it('sets projectsListLoadFailed to true when request fails', async () => { + jest.spyOn(axios, 'get').mockRejectedValue({}); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projectsListLoadFailed).toBe(true); + }); + + it('sets projectsListLoading to false when request completes', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projectsListLoading).toBe(false); + }); + }); + + describe('isSelectedProject', () => { + it.each` + project | selectedProject | title | returnValue + ${mockProjects[0]} | ${mockProjects[0]} | ${'are same projects'} | ${true} + ${mockProjects[0]} | ${mockProjects[1]} | ${'are different projects'} | ${false} + `( + 'returns $returnValue when selectedProject and provided project param $title', + async ({ project, selectedProject, returnValue }) => { + wrapper.setData({ + selectedProject, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isSelectedProject(project)).toBe(returnValue); + }, + ); + + it('returns false when selectedProject is null', async () => { + wrapper.setData({ + selectedProject: null, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isSelectedProject(mockProjects[0])).toBe(false); + }); + }); + }); + + describe('template', () => { + const findDropdownEl = () => wrapper.find(GlDropdown); + + it('renders collapsed state element with icon', () => { + const collapsedEl = wrapper.find('[data-testid="move-collapsed"]'); + + expect(collapsedEl.exists()).toBe(true); + expect(collapsedEl.attributes('title')).toBe(mockProps.dropdownButtonTitle); + expect(collapsedEl.find(GlIcon).exists()).toBe(true); + expect(collapsedEl.find(GlIcon).props('name')).toBe('arrow-right'); + }); + + describe('gl-dropdown component', () => { + it('renders component container element', () => { + expect(findDropdownEl().exists()).toBe(true); + expect(findDropdownEl().props('block')).toBe(true); + }); + + it('renders gl-dropdown-form component', () => { + expect( + findDropdownEl() + .find(GlDropdownForm) + .exists(), + ).toBe(true); + }); + + it('renders header element', () => { + const headerEl = findDropdownEl().find('[data-testid="header"]'); + + expect(headerEl.exists()).toBe(true); + expect(headerEl.find('span').text()).toBe(mockProps.dropdownHeaderTitle); + expect(headerEl.find(GlButton).props('icon')).toBe('close'); + }); + + it('renders gl-search-box-by-type component', () => { + const searchEl = findDropdownEl().find(GlSearchBoxByType); + + expect(searchEl.exists()).toBe(true); + expect(searchEl.attributes()).toMatchObject({ + placeholder: 'Search project', + debounce: '300', + }); + }); + + it('renders gl-loading-icon component when projectsListLoading prop is true', async () => { + wrapper.setData({ + projectsListLoading: true, + }); + + await wrapper.vm.$nextTick(); + + expect( + findDropdownEl() + .find(GlLoadingIcon) + .exists(), + ).toBe(true); + }); + + it('renders gl-dropdown-item components for available projects', async () => { + wrapper.setData({ + projects: mockProjects, + selectedProject: mockProjects[0], + }); + + await wrapper.vm.$nextTick(); + + const dropdownItems = wrapper.findAll(GlDropdownItem); + + expect(dropdownItems).toHaveLength(mockProjects.length); + expect(dropdownItems.at(0).props()).toMatchObject({ + isCheckItem: true, + isChecked: true, + }); + expect(dropdownItems.at(0).text()).toBe(mockProjects[0].name_with_namespace); + }); + + it('renders string "No matching results" when search does not yield any matches', async () => { + wrapper.setData({ + searchKey: 'foo', + }); + + // Wait for `searchKey` watcher to run. + await wrapper.vm.$nextTick(); + + wrapper.setData({ + projects: [], + projectsListLoading: false, + }); + + await wrapper.vm.$nextTick(); + + const dropdownContentEl = wrapper.find('[data-testid="content"]'); + + expect(dropdownContentEl.text()).toContain('No matching results'); + }); + + it('renders string "Failed to load projects" when loading projects list fails', async () => { + wrapper.setData({ + projects: [], + projectsListLoading: false, + projectsListLoadFailed: true, + }); + + await wrapper.vm.$nextTick(); + + const dropdownContentEl = wrapper.find('[data-testid="content"]'); + + expect(dropdownContentEl.text()).toContain('Failed to load projects'); + }); + + it('renders gl-button within footer', async () => { + const moveButtonEl = wrapper.find('[data-testid="footer"]').find(GlButton); + + expect(moveButtonEl.text()).toBe('Move'); + expect(moveButtonEl.attributes('disabled')).toBe('true'); + + wrapper.setData({ + selectedProject: mockProjects[0], + }); + + await wrapper.vm.$nextTick(); + + expect( + wrapper + .find('[data-testid="footer"]') + .find(GlButton) + .attributes('disabled'), + ).not.toBeDefined(); + }); + }); + + describe('events', () => { + it('collapsed state element emits `toggle-collapse` event on component when clicked', () => { + wrapper.find('[data-testid="move-collapsed"]').trigger('click'); + + expect(wrapper.emitted('toggle-collapse')).toBeTruthy(); + }); + + it('gl-dropdown component calls `fetchProjects` on `shown` event', () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + findDropdownEl().vm.$emit('shown'); + + expect(axios.get).toHaveBeenCalled(); + }); + + it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => { + wrapper.setData({ + projectItemClick: true, + }); + + findDropdownEl().vm.$emit('hide', mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(wrapper.vm.projectItemClick).toBe(false); + }); + + it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', async () => { + findDropdownEl().vm.$emit('hide'); + + expect(wrapper.emitted('dropdown-close')).toBeTruthy(); + }); + + it('close icon in dropdown header closes the dropdown when clicked', () => { + wrapper + .find('[data-testid="header"]') + .find(GlButton) + .vm.$emit('click', mockEvent); + + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled(); + }); + + it('sets project for clicked gl-dropdown-item to selectedProject', async () => { + wrapper.setData({ + projects: mockProjects, + }); + + await wrapper.vm.$nextTick(); + + wrapper + .findAll(GlDropdownItem) + .at(0) + .vm.$emit('click', mockEvent); + + expect(wrapper.vm.selectedProject).toBe(mockProjects[0]); + }); + + it('hides dropdown and emits `move-issuable` event when move button is clicked', async () => { + wrapper.setData({ + selectedProject: mockProjects[0], + }); + + await wrapper.vm.$nextTick(); + + wrapper + .find('[data-testid="footer"]') + .find(GlButton) + .vm.$emit('click'); + + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled(); + expect(wrapper.emitted('move-issuable')).toBeTruthy(); + expect(wrapper.emitted('move-issuable')[0]).toEqual([mockProjects[0]]); + }); + }); + }); +}); 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/dropdown_value_collapsed_spec.js index 7847e0ee71d..71c040c6633 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -81,9 +81,7 @@ describe('DropdownValueCollapsedComponent', () => { describe('template', () => { it('renders component container element with tooltip`', () => { - expect(vm.$el.dataset.placement).toBe('left'); - expect(vm.$el.dataset.container).toBe('body'); - expect(vm.$el.dataset.originalTitle).toBe(vm.labelsList); + expect(vm.$el.title).toBe(vm.labelsList); }); it('renders tags icon element', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index e8a126d8774..78367b3a5b4 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -128,6 +128,16 @@ describe('DropdownContentsLabelsView', () => { }); }); + describe('handleComponentAppear', () => { + it('calls `focusInput` on searchInput field', async () => { + wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + + await wrapper.vm.handleComponentAppear(); + + expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); + }); + }); + describe('handleComponentDisappear', () => { it('calls action `receiveLabelsSuccess` with empty array', () => { jest.spyOn(wrapper.vm, 'receiveLabelsSuccess'); @@ -301,7 +311,6 @@ describe('DropdownContentsLabelsView', () => { const searchInputEl = wrapper.find(GlSearchBoxByType); expect(searchInputEl.exists()).toBe(true); - expect(searchInputEl.attributes('autofocus')).toBe('true'); }); it('renders label elements for all labels', () => { diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js index bc86ee5a0c6..0786882f527 100644 --- a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js +++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js @@ -29,6 +29,13 @@ describe('StackedProgressBarComponent', () => { vm.$destroy(); }); + const findSuccessBarText = wrapper => wrapper.$el.querySelector('.status-green').innerText.trim(); + const findNeutralBarText = wrapper => + wrapper.$el.querySelector('.status-neutral').innerText.trim(); + const findFailureBarText = wrapper => wrapper.$el.querySelector('.status-red').innerText.trim(); + const findUnavailableBarText = wrapper => + wrapper.$el.querySelector('.status-unavailable').innerText.trim(); + describe('computed', () => { describe('neutralCount', () => { it('returns neutralCount based on totalCount, successCount and failureCount', () => { @@ -37,24 +44,54 @@ describe('StackedProgressBarComponent', () => { }); }); - describe('methods', () => { + describe('template', () => { + it('renders container element', () => { + expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); + }); + + it('renders empty state when count is unavailable', () => { + const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); + + expect(findUnavailableBarText(vmX)).not.toBeUndefined(); + }); + + it('renders bar elements when count is available', () => { + expect(findSuccessBarText(vm)).not.toBeUndefined(); + expect(findNeutralBarText(vm)).not.toBeUndefined(); + expect(findFailureBarText(vm)).not.toBeUndefined(); + }); + describe('getPercent', () => { - it('returns percentage from provided count based on `totalCount`', () => { - expect(vm.getPercent(500)).toBe(10); + it('returns correct percentages from provided count based on `totalCount`', () => { + vm = createComponent({ totalCount: 100, successCount: 25, failureCount: 10 }); + + expect(findSuccessBarText(vm)).toBe('25%'); + expect(findNeutralBarText(vm)).toBe('65%'); + expect(findFailureBarText(vm)).toBe('10%'); }); - it('returns percentage with decimal place from provided count based on `totalCount`', () => { - expect(vm.getPercent(67)).toBe(1.3); + it('returns percentage with decimal place when decimal is greater than 1', () => { + vm = createComponent({ successCount: 67 }); + + expect(findSuccessBarText(vm)).toBe('1.3%'); }); - it('returns percentage as `< 1` from provided count based on `totalCount` when evaluated value is less than 1', () => { - expect(vm.getPercent(10)).toBe('< 1'); + it('returns percentage as `< 1%` from provided count based on `totalCount` when evaluated value is less than 1', () => { + vm = createComponent({ successCount: 10 }); + + expect(findSuccessBarText(vm)).toBe('< 1%'); }); - it('returns 0 if totalCount is falsy', () => { + it('returns not available if totalCount is falsy', () => { vm = createComponent({ totalCount: 0 }); - expect(vm.getPercent(100)).toBe(0); + expect(findUnavailableBarText(vm)).toBe('Not available'); + }); + + it('returns 99.9% when numbers are extreme decimals', () => { + vm = createComponent({ totalCount: 1000000 }); + + expect(findNeutralBarText(vm)).toBe('99.9%'); }); }); @@ -82,23 +119,4 @@ describe('StackedProgressBarComponent', () => { }); }); }); - - describe('template', () => { - it('renders container element', () => { - expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); - }); - - it('renders empty state when count is unavailable', () => { - const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); - - expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0); - vmX.$destroy(); - }); - - it('renders bar elements when count is available', () => { - expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0); - expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0); - expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap new file mode 100644 index 00000000000..d2fe3cd76cb --- /dev/null +++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap @@ -0,0 +1,608 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Upload dropzone component correctly overrides description and drop messages 1`] = ` +<div + class="gl-w-full gl-relative" +> + <button + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-mb-0" + > + <span> + Test %{linkStart}description%{linkEnd} message. + </span> + </p> + </div> + </button> + + <input + accept="image/jpg,image/jpeg" + class="hide" + multiple="multiple" + name="upload_file" + type="file" + /> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="display: none;" + > + <div + class="mw-50 gl-text-center" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Test drop-to-start message. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Upload dropzone component when dragging renders correct template when drag event contains files 1`] = ` +<div + class="gl-w-full gl-relative" +> + <button + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} files to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="upload_file" + type="file" + /> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="" + > + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Drop your files to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Upload dropzone component when dragging renders correct template when drag event contains files and text 1`] = ` +<div + class="gl-w-full gl-relative" +> + <button + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} files to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="upload_file" + type="file" + /> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="" + > + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Drop your files to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Upload dropzone component when dragging renders correct template when drag event contains text 1`] = ` +<div + class="gl-w-full gl-relative" +> + <button + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} files to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="upload_file" + type="file" + /> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="" + > + <div + class="mw-50 gl-text-center" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Drop your files to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Upload dropzone component when dragging renders correct template when drag event is empty 1`] = ` +<div + class="gl-w-full gl-relative" +> + <button + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} files to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="upload_file" + type="file" + /> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="" + > + <div + class="mw-50 gl-text-center" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Drop your files to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Upload dropzone component when dragging renders correct template when dragging stops 1`] = ` +<div + class="gl-w-full gl-relative" +> + <button + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} files to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="upload_file" + type="file" + /> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="display: none;" + > + <div + class="mw-50 gl-text-center" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Drop your files to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Upload dropzone component when no slot provided renders default dropzone card 1`] = ` +<div + class="gl-w-full gl-relative" +> + <button + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-mb-0" + > + <gl-sprintf-stub + message="Drop or %{linkStart}upload%{linkEnd} files to attach" + /> + </p> + </div> + </button> + + <input + accept="image/*" + class="hide" + multiple="multiple" + name="upload_file" + type="file" + /> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="display: none;" + > + <div + class="mw-50 gl-text-center" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Drop your files to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Upload dropzone component when slot provided renders dropzone with slot content 1`] = ` +<div + class="gl-w-full gl-relative" +> + <div> + dropzone slot + </div> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="display: none;" + > + <div + class="mw-50 gl-text-center" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Drop your files to start your upload. + </span> + </div> + </div> + </transition-stub> +</div> +`; diff --git a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js new file mode 100644 index 00000000000..11982eb513d --- /dev/null +++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js @@ -0,0 +1,174 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; + +jest.mock('~/flash'); + +describe('Upload dropzone component', () => { + let wrapper; + + const mockDragEvent = ({ types = ['Files'], files = [] }) => { + return { dataTransfer: { types, files } }; + }; + + const findDropzoneCard = () => wrapper.find('.upload-dropzone-card'); + const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]'); + const findIcon = () => wrapper.find(GlIcon); + + function createComponent({ slots = {}, data = {}, props = {} } = {}) { + wrapper = shallowMount(UploadDropzone, { + slots, + propsData: { + displayAsCard: true, + ...props, + }, + data() { + return data; + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when slot provided', () => { + it('renders dropzone with slot content', () => { + createComponent({ + slots: { + default: ['<div>dropzone slot</div>'], + }, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('when no slot provided', () => { + it('renders default dropzone card', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('triggers click event on file input element when clicked', () => { + createComponent(); + const clickSpy = jest.spyOn(wrapper.find('input').element, 'click'); + + findDropzoneCard().trigger('click'); + expect(clickSpy).toHaveBeenCalled(); + }); + }); + + describe('when dragging', () => { + it.each` + description | eventPayload + ${'is empty'} | ${{}} + ${'contains text'} | ${mockDragEvent({ types: ['text'] })} + ${'contains files and text'} | ${mockDragEvent({ types: ['Files', 'text'] })} + ${'contains files'} | ${mockDragEvent({ types: ['Files'] })} + `('renders correct template when drag event $description', ({ eventPayload }) => { + createComponent(); + + wrapper.trigger('dragenter', eventPayload); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders correct template when dragging stops', () => { + createComponent(); + + wrapper.trigger('dragenter'); + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.trigger('dragleave'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + }); + + describe('when dropping', () => { + it('emits upload event', () => { + createComponent(); + const mockFile = { name: 'test', type: 'image/jpg' }; + const mockEvent = mockDragEvent({ files: [mockFile] }); + + wrapper.trigger('dragenter', mockEvent); + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.trigger('drop', mockEvent); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); + }); + }); + }); + + describe('ondrop', () => { + const mockData = { dragCounter: 1, isDragDataValid: true }; + + describe('when drag data is valid', () => { + it('emits upload event for valid files', () => { + createComponent({ data: mockData }); + + const mockFile = { type: 'image/jpg' }; + const mockEvent = mockDragEvent({ files: [mockFile] }); + + wrapper.vm.ondrop(mockEvent); + expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); + }); + + it('emits error event when files are invalid', () => { + createComponent({ data: mockData }); + const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); + + wrapper.vm.ondrop(mockEvent); + expect(wrapper.emitted()).toHaveProperty('error'); + }); + + it('allows validation function to be overwritten', () => { + createComponent({ data: mockData, props: { isFileValid: () => true } }); + + const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); + + wrapper.vm.ondrop(mockEvent); + expect(wrapper.emitted()).not.toHaveProperty('error'); + }); + }); + }); + + it('applies correct classes when displaying as a standalone item', () => { + createComponent({ props: { displayAsCard: false } }); + expect(findDropzoneArea().classes()).not.toContain('gl-flex-direction-column'); + expect(findIcon().classes()).toEqual(['gl-mr-3', 'gl-text-gray-500']); + expect(findIcon().props('size')).toBe(16); + }); + + it('applies correct classes when displaying in card mode', () => { + createComponent({ props: { displayAsCard: true } }); + expect(findDropzoneArea().classes()).toContain('gl-flex-direction-column'); + expect(findIcon().classes()).toEqual(['gl-mb-2']); + expect(findIcon().props('size')).toBe(24); + }); + + it('correctly overrides description and drop messages', () => { + createComponent({ + props: { + dropToStartMessage: 'Test drop-to-start message.', + validFileMimetypes: ['image/jpg', 'image/jpeg'], + }, + slots: { + 'upload-text': '<span>Test %{linkStart}description%{linkEnd} message.</span>', + }, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index c208d7b0226..7d58a865ba3 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -1,6 +1,8 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; +import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue'; +import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; const DEFAULT_PROPS = { user: { @@ -34,6 +36,7 @@ describe('User Popover Component', () => { const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`); const findUserStatus = () => wrapper.find('.js-user-status'); const findTarget = () => document.querySelector('.js-user-link'); + const findAvailabilityStatus = () => wrapper.find(UserAvailabilityStatus); const createWrapper = (props = {}, options = {}) => { wrapper = shallowMount(UserPopover, { @@ -43,7 +46,8 @@ describe('User Popover Component', () => { ...props, }, stubs: { - 'gl-sprintf': GlSprintf, + GlSprintf, + UserAvailabilityStatus, }, ...options, }); @@ -199,6 +203,30 @@ describe('User Popover Component', () => { expect(findUserStatus().exists()).toBe(false); }); + + it('should show the busy status if user set to busy', () => { + const user = { + ...DEFAULT_PROPS.user, + status: { availability: AVAILABILITY_STATUS.BUSY }, + }; + + createWrapper({ user }); + + expect(findAvailabilityStatus().exists()).toBe(true); + expect(wrapper.text()).toContain(user.name); + expect(wrapper.text()).toContain('(Busy)'); + }); + + it('should hide the busy status for any other status', () => { + const user = { + ...DEFAULT_PROPS.user, + status: { availability: AVAILABILITY_STATUS.NOT_SET }, + }; + + createWrapper({ user }); + + expect(wrapper.text()).not.toContain('(Busy)'); + }); }); describe('security bot', () => { diff --git a/spec/frontend/vue_shared/directives/validation_spec.js b/spec/frontend/vue_shared/directives/validation_spec.js new file mode 100644 index 00000000000..814d6f43589 --- /dev/null +++ b/spec/frontend/vue_shared/directives/validation_spec.js @@ -0,0 +1,132 @@ +import { shallowMount } from '@vue/test-utils'; +import validation from '~/vue_shared/directives/validation'; + +describe('validation directive', () => { + let wrapper; + + const createComponent = ({ inputAttributes, showValidation } = {}) => { + const defaultInputAttributes = { + type: 'text', + required: true, + }; + + const component = { + directives: { + validation: validation(), + }, + data() { + return { + attributes: inputAttributes || defaultInputAttributes, + showValidation, + form: { + state: null, + fields: { + exampleField: { + state: null, + feedback: '', + }, + }, + }, + }; + }, + template: ` + <form> + <input v-validation:[showValidation] name="exampleField" v-bind="attributes" /> + </form> + `, + }; + + wrapper = shallowMount(component, { attachToDocument: true }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const getFormData = () => wrapper.vm.form; + const findForm = () => wrapper.find('form'); + const findInput = () => wrapper.find('input'); + + describe.each([true, false])( + 'with fields untouched and "showValidation" set to "%s"', + showValidation => { + beforeEach(() => { + createComponent({ showValidation }); + }); + + it('sets the fields validity correctly', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: showValidation ? false : null, + feedback: showValidation ? expect.any(String) : '', + }); + }); + + it('sets the form validity correctly', () => { + expect(getFormData().state).toBe(false); + }); + }, + ); + + describe.each` + inputAttributes | validValue | invalidValue + ${{ required: true }} | ${'foo'} | ${''} + ${{ type: 'url' }} | ${'http://foo.com'} | ${'foo'} + ${{ type: 'number', min: 1, max: 5 }} | ${3} | ${0} + ${{ type: 'number', min: 1, max: 5 }} | ${3} | ${6} + ${{ pattern: 'foo|bar' }} | ${'bar'} | ${'quz'} + `( + 'with input-attributes set to $inputAttributes', + ({ inputAttributes, validValue, invalidValue }) => { + const setValueAndTriggerValidation = value => { + const input = findInput(); + input.setValue(value); + input.trigger('blur'); + }; + + beforeEach(() => { + createComponent({ inputAttributes }); + }); + + describe('with valid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(validValue); + }); + + it('sets the field to be valid', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: true, + feedback: '', + }); + }); + + it('sets the form to be valid', () => { + expect(getFormData().state).toBe(true); + }); + }); + + describe('with invalid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(invalidValue); + }); + + it('sets the field to be invalid', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: false, + feedback: expect.any(String), + }); + expect(getFormData().fields.exampleField.feedback.length).toBeGreaterThan(0); + }); + + it('sets the form to be invalid', () => { + expect(getFormData().state).toBe(false); + }); + + it('sets focus on the first invalid input when the form is submitted', () => { + findForm().trigger('submit'); + expect(findInput().element).toBe(document.activeElement); + }); + }); + }, + ); +}); diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js index 31bdfc931ac..ab87d80b291 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -5,7 +5,7 @@ import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_a jest.mock('~/flash'); -describe('Grouped security reports app', () => { +describe('Security reports app', () => { let wrapper; let mrTabsMock; @@ -21,6 +21,8 @@ describe('Grouped security reports app', () => { }); }; + const anyParams = expect.any(Object); + const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]'); const findHelpLink = () => wrapper.find('[data-testid="help"]'); const setupMrTabsMock = () => { @@ -43,10 +45,12 @@ describe('Grouped security reports app', () => { window.mrTabs = { tabShown: jest.fn() }; setupMockJobArtifact(reportType); createComponent(); + return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); }); it('renders the expected message', () => { @@ -75,10 +79,12 @@ describe('Grouped security reports app', () => { beforeEach(() => { setupMockJobArtifact('foo'); createComponent(); + return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); }); it('renders nothing', () => { @@ -86,6 +92,42 @@ describe('Grouped security reports app', () => { }); }); + describe('security artifacts on last page of multi-page response', () => { + const numPages = 3; + + beforeEach(() => { + jest + .spyOn(Api, 'pipelineJobs') + .mockImplementation(async (projectId, pipelineId, { page }) => { + const requestedPage = parseInt(page, 10); + if (requestedPage < numPages) { + return { + // Some jobs with no relevant artifacts + data: [{}, {}], + headers: { 'x-next-page': String(requestedPage + 1) }, + }; + } else if (requestedPage === numPages) { + return { + data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }], + }; + } + + throw new Error('Test failed due to request of non-existent jobs page'); + }); + + createComponent(); + return wrapper.vm.$nextTick(); + }); + + it('fetches all pages', () => { + expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages); + }); + + it('renders the expected message', () => { + expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun); + }); + }); + describe('given an error from the API', () => { let error; @@ -93,10 +135,12 @@ describe('Grouped security reports app', () => { error = new Error('an error'); jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error); createComponent(); + return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); }); it('renders nothing', () => { diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js new file mode 100644 index 00000000000..a11f4e05913 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js @@ -0,0 +1,203 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; + +import createState from '~/vue_shared/security_reports/store/modules/sast/state'; +import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types'; +import * as actions from '~/vue_shared/security_reports/store/modules/sast/actions'; +import axios from '~/lib/utils/axios_utils'; + +const diffEndpoint = 'diff-endpoint.json'; +const blobPath = 'blob-path.json'; +const reports = { + base: 'base', + head: 'head', + enrichData: 'enrichData', + diff: 'diff', +}; +const error = 'Something went wrong'; +const vulnerabilityFeedbackPath = 'vulnerability-feedback-path'; +const rootState = { vulnerabilityFeedbackPath, blobPath }; + +let state; + +describe('sast report actions', () => { + beforeEach(() => { + state = createState(); + }); + + describe('setDiffEndpoint', () => { + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, done => { + testAction( + actions.setDiffEndpoint, + diffEndpoint, + state, + [ + { + type: types.SET_DIFF_ENDPOINT, + payload: diffEndpoint, + }, + ], + [], + done, + ); + }); + }); + + describe('requestDiff', () => { + it(`should commit ${types.REQUEST_DIFF}`, done => { + testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + }); + }); + + describe('receiveDiffSuccess', () => { + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, done => { + testAction( + actions.receiveDiffSuccess, + reports, + state, + [ + { + type: types.RECEIVE_DIFF_SUCCESS, + payload: reports, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveDiffError', () => { + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, done => { + testAction( + actions.receiveDiffError, + error, + state, + [ + { + type: types.RECEIVE_DIFF_ERROR, + payload: error, + }, + ], + [], + done, + ); + }); + }); + + describe('fetchDiff', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state.paths.diffEndpoint = diffEndpoint; + rootState.canReadVulnerabilityFeedback = true; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('when diff and vulnerability feedback endpoints respond successfully', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(200, reports.diff) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(200, reports.enrichData); + }); + + it('should dispatch the `receiveDiffSuccess` action', done => { + const { diff, enrichData } = reports; + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [ + { type: 'requestDiff' }, + { + type: 'receiveDiffSuccess', + payload: { + diff, + enrichData, + }, + }, + ], + done, + ); + }); + }); + + describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => { + beforeEach(() => { + rootState.canReadVulnerabilityFeedback = false; + mock.onGet(diffEndpoint).replyOnce(200, reports.diff); + }); + + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', done => { + const { diff } = reports; + const enrichData = []; + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [ + { type: 'requestDiff' }, + { + type: 'receiveDiffSuccess', + payload: { + diff, + enrichData, + }, + }, + ], + done, + ); + }); + }); + + describe('when the vulnerability feedback endpoint fails', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(200, reports.diff) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(404); + }); + + it('should dispatch the `receiveError` action', done => { + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], + done, + ); + }); + }); + + describe('when the diff endpoint fails', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(404) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(200, reports.enrichData); + }); + + it('should dispatch the `receiveDiffError` action', done => { + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], + done, + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js new file mode 100644 index 00000000000..fd611f38a34 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js @@ -0,0 +1,84 @@ +import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types'; +import createState from '~/vue_shared/security_reports/store/modules/sast/state'; +import mutations from '~/vue_shared/security_reports/store/modules/sast/mutations'; + +const createIssue = ({ ...config }) => ({ changed: false, ...config }); + +describe('sast module mutations', () => { + const path = 'path'; + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(types.SET_DIFF_ENDPOINT, () => { + it('should set the SAST diff endpoint', () => { + mutations[types.SET_DIFF_ENDPOINT](state, path); + + expect(state.paths.diffEndpoint).toBe(path); + }); + }); + + describe(types.REQUEST_DIFF, () => { + it('should set the `isLoading` status to `true`', () => { + mutations[types.REQUEST_DIFF](state); + + expect(state.isLoading).toBe(true); + }); + }); + + describe(types.RECEIVE_DIFF_SUCCESS, () => { + beforeEach(() => { + const reports = { + diff: { + added: [ + createIssue({ cve: 'CVE-1' }), + createIssue({ cve: 'CVE-2' }), + createIssue({ cve: 'CVE-3' }), + ], + fixed: [createIssue({ cve: 'CVE-4' }), createIssue({ cve: 'CVE-5' })], + existing: [createIssue({ cve: 'CVE-6' })], + base_report_out_of_date: true, + }, + }; + state.isLoading = true; + mutations[types.RECEIVE_DIFF_SUCCESS](state, reports); + }); + + it('should set the `isLoading` status to `false`', () => { + expect(state.isLoading).toBe(false); + }); + + it('should set the `baseReportOutofDate` status to `false`', () => { + expect(state.baseReportOutofDate).toBe(true); + }); + + it('should have the relevant `new` issues', () => { + expect(state.newIssues).toHaveLength(3); + }); + + it('should have the relevant `resolved` issues', () => { + expect(state.resolvedIssues).toHaveLength(2); + }); + + it('should have the relevant `all` issues', () => { + expect(state.allIssues).toHaveLength(1); + }); + }); + + describe(types.RECEIVE_DIFF_ERROR, () => { + beforeEach(() => { + state.isLoading = true; + mutations[types.RECEIVE_DIFF_ERROR](state); + }); + + it('should set the `isLoading` status to `false`', () => { + expect(state.isLoading).toBe(false); + }); + + it('should set the `hasError` status to `true`', () => { + expect(state.hasError).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js new file mode 100644 index 00000000000..bbcdfb5cd99 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js @@ -0,0 +1,203 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; + +import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; +import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types'; +import * as actions from '~/vue_shared/security_reports/store/modules/secret_detection/actions'; +import axios from '~/lib/utils/axios_utils'; + +const diffEndpoint = 'diff-endpoint.json'; +const blobPath = 'blob-path.json'; +const reports = { + base: 'base', + head: 'head', + enrichData: 'enrichData', + diff: 'diff', +}; +const error = 'Something went wrong'; +const vulnerabilityFeedbackPath = 'vulnerability-feedback-path'; +const rootState = { vulnerabilityFeedbackPath, blobPath }; + +let state; + +describe('secret detection report actions', () => { + beforeEach(() => { + state = createState(); + }); + + describe('setDiffEndpoint', () => { + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, done => { + testAction( + actions.setDiffEndpoint, + diffEndpoint, + state, + [ + { + type: types.SET_DIFF_ENDPOINT, + payload: diffEndpoint, + }, + ], + [], + done, + ); + }); + }); + + describe('requestDiff', () => { + it(`should commit ${types.REQUEST_DIFF}`, done => { + testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + }); + }); + + describe('receiveDiffSuccess', () => { + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, done => { + testAction( + actions.receiveDiffSuccess, + reports, + state, + [ + { + type: types.RECEIVE_DIFF_SUCCESS, + payload: reports, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveDiffError', () => { + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, done => { + testAction( + actions.receiveDiffError, + error, + state, + [ + { + type: types.RECEIVE_DIFF_ERROR, + payload: error, + }, + ], + [], + done, + ); + }); + }); + + describe('fetchDiff', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state.paths.diffEndpoint = diffEndpoint; + rootState.canReadVulnerabilityFeedback = true; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('when diff and vulnerability feedback endpoints respond successfully', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(200, reports.diff) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(200, reports.enrichData); + }); + + it('should dispatch the `receiveDiffSuccess` action', done => { + const { diff, enrichData } = reports; + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [ + { type: 'requestDiff' }, + { + type: 'receiveDiffSuccess', + payload: { + diff, + enrichData, + }, + }, + ], + done, + ); + }); + }); + + describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => { + beforeEach(() => { + rootState.canReadVulnerabilityFeedback = false; + mock.onGet(diffEndpoint).replyOnce(200, reports.diff); + }); + + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', done => { + const { diff } = reports; + const enrichData = []; + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [ + { type: 'requestDiff' }, + { + type: 'receiveDiffSuccess', + payload: { + diff, + enrichData, + }, + }, + ], + done, + ); + }); + }); + + describe('when the vulnerability feedback endpoint fails', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(200, reports.diff) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(404); + }); + + it('should dispatch the `receiveDiffError` action', done => { + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], + done, + ); + }); + }); + + describe('when the diff endpoint fails', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(404) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(200, reports.enrichData); + }); + + it('should dispatch the `receiveDiffError` action', done => { + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], + done, + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js new file mode 100644 index 00000000000..13fcc0f47a3 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js @@ -0,0 +1,84 @@ +import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types'; +import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; +import mutations from '~/vue_shared/security_reports/store/modules/secret_detection/mutations'; + +const createIssue = ({ ...config }) => ({ changed: false, ...config }); + +describe('secret detection module mutations', () => { + const path = 'path'; + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(types.SET_DIFF_ENDPOINT, () => { + it('should set the secret detection diff endpoint', () => { + mutations[types.SET_DIFF_ENDPOINT](state, path); + + expect(state.paths.diffEndpoint).toBe(path); + }); + }); + + describe(types.REQUEST_DIFF, () => { + it('should set the `isLoading` status to `true`', () => { + mutations[types.REQUEST_DIFF](state); + + expect(state.isLoading).toBe(true); + }); + }); + + describe(types.RECEIVE_DIFF_SUCCESS, () => { + beforeEach(() => { + const reports = { + diff: { + added: [ + createIssue({ cve: 'CVE-1' }), + createIssue({ cve: 'CVE-2' }), + createIssue({ cve: 'CVE-3' }), + ], + fixed: [createIssue({ cve: 'CVE-4' }), createIssue({ cve: 'CVE-5' })], + existing: [createIssue({ cve: 'CVE-6' })], + base_report_out_of_date: true, + }, + }; + state.isLoading = true; + mutations[types.RECEIVE_DIFF_SUCCESS](state, reports); + }); + + it('should set the `isLoading` status to `false`', () => { + expect(state.isLoading).toBe(false); + }); + + it('should set the `baseReportOutofDate` status to `true`', () => { + expect(state.baseReportOutofDate).toBe(true); + }); + + it('should have the relevant `new` issues', () => { + expect(state.newIssues).toHaveLength(3); + }); + + it('should have the relevant `resolved` issues', () => { + expect(state.resolvedIssues).toHaveLength(2); + }); + + it('should have the relevant `all` issues', () => { + expect(state.allIssues).toHaveLength(1); + }); + }); + + describe(types.RECEIVE_DIFF_ERROR, () => { + beforeEach(() => { + state.isLoading = true; + mutations[types.RECEIVE_DIFF_ERROR](state); + }); + + it('should set the `isLoading` status to `false`', () => { + expect(state.isLoading).toBe(false); + }); + + it('should set the `hasError` status to `true`', () => { + expect(state.hasError).toBe(true); + }); + }); +}); |