diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 12:40:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 12:40:42 +0300 |
commit | ee664acb356f8123f4f6b00b73c1e1cf0866c7fb (patch) | |
tree | f8479f94a28f66654c6a4f6fb99bad6b4e86a40e /spec/frontend/vue_shared | |
parent | 62f7d5c5b69180e82ae8196b7b429eeffc8e7b4f (diff) |
Add latest changes from gitlab-org/gitlab@15-5-stable-eev15.5.0-rc42
Diffstat (limited to 'spec/frontend/vue_shared')
61 files changed, 1634 insertions, 998 deletions
diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js index 27b6718fb8e..07cbfe1e79b 100644 --- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -1,7 +1,7 @@ +import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), @@ -86,18 +86,14 @@ describe('CI Badge Link Component', () => { wrapper.destroy(); }); - it.each(Object.keys(statuses))('should render badge for status: %s', async (status) => { + it.each(Object.keys(statuses))('should render badge for status: %s', (status) => { createComponent({ status: statuses[status] }); - expect(wrapper.attributes('href')).toBe(); + expect(wrapper.attributes('href')).toBe(statuses[status].details_path); expect(wrapper.text()).toBe(statuses[status].text); expect(wrapper.classes()).toContain('ci-status'); expect(wrapper.classes()).toContain(`ci-${statuses[status].group}`); expect(findIcon().exists()).toBe(true); - - await wrapper.trigger('click'); - - expect(visitUrl).toHaveBeenCalledWith(statuses[status].details_path); }); it('should not render label', () => { @@ -109,7 +105,7 @@ describe('CI Badge Link Component', () => { it('should emit ciStatusBadgeClick event', async () => { createComponent({ status: statuses.success }); - await wrapper.trigger('click'); + await wrapper.findComponent(GlLink).vm.$emit('click'); expect(wrapper.emitted('ciStatusBadgeClick')).toEqual([[]]); }); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js index 441e21ee905..5b0772f6e34 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; import DropdownValue from '~/vue_shared/components/color_select_dropdown/dropdown_value.vue'; @@ -146,7 +146,7 @@ describe('LabelsSelectRoot', () => { }); it('creates flash with error message', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ captureError: true, message: 'Error fetching epic color.', }); @@ -186,7 +186,7 @@ describe('LabelsSelectRoot', () => { findDropdownContents().vm.$emit('setColor', color); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ captureError: true, error: expect.anything(), message: 'An error occurred while updating color.', diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js index 10eacff630d..7a8f94b3746 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js @@ -121,7 +121,7 @@ describe('date time picker lib', () => { const utcResult = '2019-09-08T01:01:01Z'; const localResult = '2019-09-08T08:01:01Z'; - test.each` + it.each` val | locatTimezone | utc | result ${value} | ${'UTC'} | ${undefined} | ${utcResult} ${value} | ${'UTC'} | ${false} | ${utcResult} @@ -167,7 +167,7 @@ describe('date time picker lib', () => { const utcResult = '2019-09-08 08:01:01'; const localResult = '2019-09-08 01:01:01'; - test.each` + it.each` val | locatTimezone | utc | result ${value} | ${'UTC'} | ${undefined} | ${utcResult} ${value} | ${'UTC'} | ${false} | ${utcResult} diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js index 68684004b82..99c973bdd26 100644 --- a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js @@ -106,11 +106,11 @@ describe('Diff Stats Dropdown', () => { expectedAddedDeletedExpanded, expectedAddedDeletedCollapsed, }) => { - beforeAll(() => { + beforeEach(() => { createComponent({ changed, added, deleted }); }); - afterAll(() => { + afterEach(() => { wrapper.destroy(); }); diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js index 69964b2687d..6e0717c29d7 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -1,8 +1,6 @@ -import Vue, { nextTick } from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; -import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; describe('DiffViewer', () => { const requiredProps = { @@ -14,37 +12,28 @@ describe('DiffViewer', () => { oldPath: RED_BOX_IMAGE_URL, oldSha: 'DEF', }; - let vm; - - function createComponent(props) { - const DiffViewer = Vue.extend(diffViewer); + let wrapper; - vm = mountComponent(DiffViewer, props); + function createComponent(propsData) { + wrapper = mount(DiffViewer, { propsData }); } afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('renders image diff', async () => { + it('renders image diff', () => { window.gon = { relative_url_root: '', }; createComponent({ ...requiredProps, projectPath: '' }); - await nextTick(); - - expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe( - `//-/raw/DEF/${RED_BOX_IMAGE_URL}`, - ); - - expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe( - `//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`, - ); + expect(wrapper.find('.deleted img').attributes('src')).toBe(`//-/raw/DEF/${RED_BOX_IMAGE_URL}`); + expect(wrapper.find('.added img').attributes('src')).toBe(`//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`); }); - it('renders fallback download diff display', async () => { + it('renders fallback download diff display', () => { createComponent({ ...requiredProps, diffViewerMode: 'added', @@ -52,18 +41,10 @@ describe('DiffViewer', () => { oldPath: 'testold.abc', }); - await nextTick(); - - expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain('testold.abc'); - - expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain( - 'Download', - ); - - expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc'); - expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain( - 'Download', - ); + expect(wrapper.find('.deleted .file-info').text()).toContain('testold.abc'); + expect(wrapper.find('.deleted .btn.btn-default').text()).toContain('Download'); + expect(wrapper.find('.added .file-info').text()).toContain('test.abc'); + expect(wrapper.find('.added .btn.btn-default').text()).toContain('Download'); }); describe('renamed file', () => { @@ -85,7 +66,7 @@ describe('DiffViewer', () => { oldPath: 'testold.abc', }); - expect(vm.$el.textContent).toContain('File renamed with no changes.'); + expect(wrapper.text()).toContain('File renamed with no changes.'); }); }); @@ -99,6 +80,6 @@ describe('DiffViewer', () => { bMode: '321', }); - expect(vm.$el.textContent).toContain('File mode changed from 123 to 321'); + expect(wrapper.text()).toContain('File mode changed from 123 to 321'); }); }); diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js index b69c33055c1..f0998b1b5c6 100644 --- a/spec/frontend/vue_shared/components/file_finder/item_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js @@ -1,127 +1,119 @@ -import Vue, { nextTick } from 'vue'; -import createComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import { file } from 'jest/ide/helpers'; import ItemComponent from '~/vue_shared/components/file_finder/item.vue'; describe('File finder item spec', () => { - const Component = Vue.extend(ItemComponent); - let vm; - let localFile; - - beforeEach(() => { - localFile = { - ...file(), - name: 'test file', - path: 'test/file', - }; - - vm = createComponent(Component, { - file: localFile, - focused: true, - searchText: '', - index: 0, + let wrapper; + + const createComponent = ({ file: customFileFields = {}, ...otherProps } = {}) => { + wrapper = mount(ItemComponent, { + propsData: { + file: { + ...file(), + name: 'test file', + path: 'test/file', + ...customFileFields, + }, + focused: true, + searchText: '', + index: 0, + ...otherProps, + }, }); - }); + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders file name & path', () => { - expect(vm.$el.textContent).toContain('test file'); - expect(vm.$el.textContent).toContain('test/file'); + createComponent(); + + expect(wrapper.text()).toContain('test file'); + expect(wrapper.text()).toContain('test/file'); }); describe('focused', () => { it('adds is-focused class', () => { - expect(vm.$el.classList).toContain('is-focused'); + createComponent(); + + expect(wrapper.classes()).toContain('is-focused'); }); it('does not have is-focused class when not focused', async () => { - vm.focused = false; + createComponent({ focused: false }); - await nextTick(); - expect(vm.$el.classList).not.toContain('is-focused'); + expect(wrapper.classes()).not.toContain('is-focused'); }); }); describe('changed file icon', () => { it('does not render when not a changed or temp file', () => { - expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null); + createComponent(); + + expect(wrapper.find('.diff-changed-stats').exists()).toBe(false); }); it('renders when a changed file', async () => { - vm.file.changed = true; + createComponent({ file: { changed: true } }); - await nextTick(); - expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + expect(wrapper.find('.diff-changed-stats').exists()).toBe(true); }); it('renders when a temp file', async () => { - vm.file.tempFile = true; + createComponent({ file: { tempFile: true } }); - await nextTick(); - expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + expect(wrapper.find('.diff-changed-stats').exists()).toBe(true); }); }); - it('emits event when clicked', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); + it('emits event when clicked', async () => { + createComponent(); - vm.$el.click(); + await wrapper.find('*').trigger('click'); - expect(vm.$emit).toHaveBeenCalledWith('click', vm.file); + expect(wrapper.emitted('click')[0]).toStrictEqual([wrapper.props('file')]); }); describe('path', () => { - let el; - - beforeEach(async () => { - vm.searchText = 'file'; - - el = vm.$el.querySelector('.diff-changed-file-path'); - - nextTick(); - }); + const findChangedFilePath = () => wrapper.find('.diff-changed-file-path'); it('highlights text', () => { - expect(el.querySelectorAll('.highlighted').length).toBe(4); + createComponent({ searchText: 'file' }); + + expect(findChangedFilePath().findAll('.highlighted')).toHaveLength(4); }); it('adds ellipsis to long text', async () => { - vm.file.path = new Array(70) + const path = new Array(70) .fill() .map((_, i) => `${i}-`) .join(''); - await nextTick(); - expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`); + createComponent({ searchText: 'file', file: { path } }); + + expect(findChangedFilePath().text()).toBe(`...${path.substring(path.length - 60)}`); }); }); describe('name', () => { - let el; - - beforeEach(async () => { - vm.searchText = 'file'; - - el = vm.$el.querySelector('.diff-changed-file-name'); - - await nextTick(); - }); + const findChangedFileName = () => wrapper.find('.diff-changed-file-name'); it('highlights text', () => { - expect(el.querySelectorAll('.highlighted').length).toBe(4); + createComponent({ searchText: 'file' }); + + expect(findChangedFileName().findAll('.highlighted')).toHaveLength(4); }); it('does not add ellipsis to long text', async () => { - vm.file.name = new Array(70) + const name = new Array(70) .fill() .map((_, i) => `${i}-`) .join(''); - await nextTick(); - expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`); + createComponent({ searchText: 'file', file: { name } }); + + expect(findChangedFileName().text()).not.toBe(`...${name.substring(name.length - 60)}`); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js index 4140ec09b4e..66ef473f368 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions'; import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; @@ -159,7 +159,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); @@ -233,7 +233,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -252,7 +252,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); @@ -298,7 +298,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); @@ -376,7 +376,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -395,7 +395,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); @@ -441,7 +441,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); 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 302dfabffb2..5371b9af475 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 @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -140,13 +140,13 @@ describe('AuthorToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); getBaseToken().vm.$emit('fetch-suggestions', 'root'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching users.', }); }); 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 1de35daa3a5..05b42011fe1 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 @@ -9,7 +9,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; @@ -87,13 +87,13 @@ describe('BranchToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); wrapper.vm.fetchBranches('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching branches.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js index c9879987931..5b744521979 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -8,7 +8,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -94,7 +94,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -108,7 +108,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -134,7 +134,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -148,7 +148,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -159,7 +159,7 @@ describe('CrmContactToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', async () => { + it('calls `createAlert` with flash error message when request fails', async () => { mountComponent(); jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); @@ -167,7 +167,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching CRM contacts.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js index 16333b052e6..3a3e96032e8 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -8,7 +8,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -93,7 +93,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -107,7 +107,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -133,7 +133,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -147,7 +147,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -158,7 +158,7 @@ describe('CrmOrganizationToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', async () => { + it('calls `createAlert` with flash error message when request fails', async () => { mountComponent(); jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); @@ -166,7 +166,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching CRM organizations.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index bf4a6eb7635..e8436d2db17 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { @@ -93,13 +93,13 @@ describe('EmojiToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); wrapper.vm.fetchEmojis('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching emojis.', }); }); 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 01e281884ed..8ca12afacec 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 @@ -11,7 +11,7 @@ import { mockRegularLabel, mockLabels, } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -116,13 +116,13 @@ describe('LabelToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching labels.', }); }); 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 f71ba51fc5b..589697fe542 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 @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { sortMilestonesByDueDate } from '~/milestones/utils'; @@ -112,13 +112,13 @@ describe('MilestoneToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); wrapper.vm.fetchMilestones('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching milestones.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js index 4bbbaab9b7a..0e5fa0f66d4 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js @@ -2,7 +2,7 @@ import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui' import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; import { mockReleaseToken } from '../mock_data'; @@ -73,7 +73,7 @@ describe('ReleaseToken', () => { }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching releases.', }); }); diff --git a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js index 6699ae5fb69..38f28837cc1 100644 --- a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js +++ b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js @@ -1,7 +1,9 @@ import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { mockTracking } from 'helpers/tracking_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import axios from '~/lib/utils/axios_utils'; import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue'; @@ -9,6 +11,8 @@ describe('GitlabVersionCheck', () => { let wrapper; let mock; + const UPGRADE_DOCS_URL = helpPagePath('update/index'); + const defaultResponse = { code: 200, res: { severity: 'success' }, @@ -23,7 +27,7 @@ describe('GitlabVersionCheck', () => { mock = new MockAdapter(axios); mock.onGet().replyOnce(response.code, response.res); - wrapper = shallowMount(GitlabVersionCheck); + wrapper = shallowMountExtended(GitlabVersionCheck); }; const dummyGon = { @@ -38,6 +42,7 @@ describe('GitlabVersionCheck', () => { window.gon = originalGon; }); + const findGlBadgeClickWrapper = () => wrapper.findByTestId('badge-click-wrapper'); const findGlBadge = () => wrapper.findComponent(GlBadge); describe.each` @@ -77,7 +82,8 @@ describe('GitlabVersionCheck', () => { await waitForPromises(); // Ensure we wrap up the axios call }); - it(`does${renders ? '' : ' not'} render GlBadge`, () => { + it(`does${renders ? '' : ' not'} render Badge Click Wrapper and GlBadge`, () => { + expect(findGlBadgeClickWrapper().exists()).toBe(renders); expect(findGlBadge().exists()).toBe(renders); }); }); @@ -90,8 +96,11 @@ describe('GitlabVersionCheck', () => { ${{ code: 200, res: { severity: 'danger' } }} | ${{ title: 'Update ASAP', variant: 'danger' }} `('badge ui', ({ mockResponse, expectedUI }) => { describe(`when response is ${mockResponse.res.severity}`, () => { + let trackingSpy; + beforeEach(async () => { createComponent(mockResponse); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); await waitForPromises(); // Ensure we wrap up the axios call }); @@ -102,6 +111,24 @@ describe('GitlabVersionCheck', () => { it(`variant is ${expectedUI.variant}`, () => { expect(findGlBadge().attributes('variant')).toBe(expectedUI.variant); }); + + it(`tracks rendered_version_badge with label ${expectedUI.title}`, () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'rendered_version_badge', { + label: expectedUI.title, + }); + }); + + it(`link is ${UPGRADE_DOCS_URL}`, () => { + expect(findGlBadge().attributes('href')).toBe(UPGRADE_DOCS_URL); + }); + + it(`tracks click_version_badge with label ${expectedUI.title} when badge is clicked`, async () => { + await findGlBadgeClickWrapper().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_version_badge', { + label: expectedUI.title, + }); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js index 0d1d42082ab..af53d256236 100644 --- a/spec/frontend/vue_shared/components/gl_countdown_spec.js +++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js @@ -1,10 +1,9 @@ import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; describe('GlCountdown', () => { - const Component = Vue.extend(GlCountdown); - let vm; + let wrapper; let now = '2000-01-01T00:00:00Z'; beforeEach(() => { @@ -12,21 +11,20 @@ describe('GlCountdown', () => { }); afterEach(() => { - vm.$destroy(); - jest.clearAllTimers(); + wrapper.destroy(); }); describe('when there is time remaining', () => { beforeEach(async () => { - vm = mountComponent(Component, { - endDateString: '2000-01-01T01:02:03Z', + wrapper = mount(GlCountdown, { + propsData: { + endDateString: '2000-01-01T01:02:03Z', + }, }); - - await nextTick(); }); it('displays remaining time', () => { - expect(vm.$el.textContent).toContain('01:02:03'); + expect(wrapper.text()).toContain('01:02:03'); }); it('updates remaining time', async () => { @@ -34,21 +32,21 @@ describe('GlCountdown', () => { jest.advanceTimersByTime(1000); await nextTick(); - expect(vm.$el.textContent).toContain('01:02:02'); + expect(wrapper.text()).toContain('01:02:02'); }); }); describe('when there is no time remaining', () => { beforeEach(async () => { - vm = mountComponent(Component, { - endDateString: '1900-01-01T00:00:00Z', + wrapper = mount(GlCountdown, { + propsData: { + endDateString: '1900-01-01T00:00:00Z', + }, }); - - await nextTick(); }); it('displays 00:00:00', () => { - expect(vm.$el.textContent).toContain('00:00:00'); + expect(wrapper.text()).toContain('00:00:00'); }); }); @@ -62,8 +60,10 @@ describe('GlCountdown', () => { }); it('throws a validation error', () => { - vm = mountComponent(Component, { - endDateString: 'this is invalid', + wrapper = mount(GlCountdown, { + propsData: { + endDateString: 'this is invalid', + }, }); expect(Vue.config.warnHandler).toHaveBeenCalledTimes(1); diff --git a/spec/frontend/vue_shared/components/group_select/utils_spec.js b/spec/frontend/vue_shared/components/group_select/utils_spec.js new file mode 100644 index 00000000000..5188e1aabf1 --- /dev/null +++ b/spec/frontend/vue_shared/components/group_select/utils_spec.js @@ -0,0 +1,24 @@ +import { groupsPath } from '~/vue_shared/components/group_select/utils'; + +describe('group_select utils', () => { + describe('groupsPath', () => { + it.each` + groupsFilter | parentGroupID | expectedPath + ${undefined} | ${undefined} | ${'/api/:version/groups.json'} + ${undefined} | ${1} | ${'/api/:version/groups.json'} + ${'descendant_groups'} | ${1} | ${'/api/:version/groups/1/descendant_groups'} + ${'subgroups'} | ${1} | ${'/api/:version/groups/1/subgroups'} + `( + 'returns $expectedPath with groupsFilter = $groupsFilter and parentGroupID = $parentGroupID', + ({ groupsFilter, parentGroupID, expectedPath }) => { + expect(groupsPath(groupsFilter, parentGroupID)).toBe(expectedPath); + }, + ); + }); + + it('throws if groupsFilter is passed but parentGroupID is undefined', () => { + expect(() => { + groupsPath('descendant_groups'); + }).toThrow('Cannot use groupsFilter without a parentGroupID'); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 9831908f806..ed417097e1e 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -54,6 +54,8 @@ describe('Markdown field header component', () => { 'Add a bullet list', 'Add a numbered list', 'Add a checklist', + 'Indent line (⌘])', + 'Outdent line (⌘[)', 'Add a collapsible section', 'Add a table', 'Go full screen', @@ -140,7 +142,7 @@ describe('Markdown field header component', () => { const tableButton = findToolbarButtonByProp('icon', 'table'); expect(tableButton.props('tag')).toEqual( - '| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |', + '| header | header |\n| ------ | ------ |\n| | |\n| | |', ); }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js new file mode 100644 index 00000000000..f7e93f45148 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -0,0 +1,289 @@ +import { GlSegmentedControl } from '@gitlab/ui'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '~/vue_shared/constants'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import ContentEditor from '~/content_editor/components/content_editor.vue'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { stubComponent } from 'helpers/stub_component'; + +jest.mock('~/emoji'); + +describe('vue_shared/component/markdown/markdown_editor', () => { + let wrapper; + const value = 'test markdown'; + const renderMarkdownPath = '/api/markdown'; + const markdownDocsPath = '/help/markdown'; + const quickActionsDocsPath = '/help/quickactions'; + const enableAutocomplete = true; + const enablePreview = false; + const formFieldId = 'markdown_field'; + const formFieldName = 'form[markdown_field]'; + const formFieldPlaceholder = 'Write some markdown'; + const formFieldAriaLabel = 'Edit your content'; + let mock; + + const buildWrapper = ({ propsData = {}, attachTo } = {}) => { + wrapper = mountExtended(MarkdownEditor, { + attachTo, + propsData: { + value, + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + enableAutocomplete, + enablePreview, + formFieldId, + formFieldName, + formFieldPlaceholder, + formFieldAriaLabel, + ...propsData, + }, + stubs: { + BubbleMenu: stubComponent(BubbleMenu), + }, + }); + }; + const findSegmentedControl = () => wrapper.findComponent(GlSegmentedControl); + const findMarkdownField = () => wrapper.findComponent(MarkdownField); + const findTextarea = () => wrapper.find('textarea'); + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findContentEditor = () => wrapper.findComponent(ContentEditor); + + beforeEach(() => { + window.uploads_path = 'uploads'; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + it('displays markdown field by default', () => { + buildWrapper({ propsData: { supportsQuickActions: true } }); + + expect(findMarkdownField().props()).toEqual( + expect.objectContaining({ + markdownPreviewPath: renderMarkdownPath, + quickActionsDocsPath, + canAttachFile: true, + enableAutocomplete, + textareaValue: value, + markdownDocsPath, + uploadsPath: window.uploads_path, + enablePreview, + }), + ); + }); + + it('renders markdown field textarea', () => { + buildWrapper(); + + expect(findTextarea().attributes()).toEqual( + expect.objectContaining({ + id: formFieldId, + name: formFieldName, + placeholder: formFieldPlaceholder, + 'aria-label': formFieldAriaLabel, + }), + ); + + expect(findTextarea().element.value).toBe(value); + }); + + it('renders switch segmented control', () => { + buildWrapper(); + + expect(findSegmentedControl().props()).toEqual({ + checked: EDITING_MODE_MARKDOWN_FIELD, + options: [ + { + text: expect.any(String), + value: EDITING_MODE_MARKDOWN_FIELD, + }, + { + text: expect.any(String), + value: EDITING_MODE_CONTENT_EDITOR, + }, + ], + }); + }); + + describe.each` + editingMode + ${EDITING_MODE_CONTENT_EDITOR} + ${EDITING_MODE_MARKDOWN_FIELD} + `('when segmented control emits change event with $editingMode value', ({ editingMode }) => { + it(`emits ${editingMode} event`, () => { + buildWrapper(); + + findSegmentedControl().vm.$emit('change', editingMode); + + expect(wrapper.emitted(editingMode)).toHaveLength(1); + }); + }); + + describe(`when editingMode is ${EDITING_MODE_MARKDOWN_FIELD}`, () => { + it('emits input event when markdown field textarea changes', async () => { + buildWrapper(); + const newValue = 'new value'; + + await findTextarea().setValue(newValue); + + expect(wrapper.emitted('input')).toEqual([[newValue]]); + }); + + describe('when initOnAutofocus is true', () => { + beforeEach(async () => { + buildWrapper({ attachTo: document.body, propsData: { initOnAutofocus: true } }); + + await nextTick(); + }); + + it('sets the markdown field as the active element in the document', () => { + expect(document.activeElement).toBe(findTextarea().element); + }); + }); + + it('bubbles up keydown event', async () => { + buildWrapper(); + + await findTextarea().trigger('keydown'); + + expect(wrapper.emitted('keydown')).toHaveLength(1); + }); + + describe(`when segmented control triggers input event with ${EDITING_MODE_CONTENT_EDITOR} value`, () => { + beforeEach(() => { + buildWrapper(); + findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + findSegmentedControl().vm.$emit('change', EDITING_MODE_CONTENT_EDITOR); + }); + + it('displays the content editor', () => { + expect(findContentEditor().props()).toEqual( + expect.objectContaining({ + renderMarkdown: expect.any(Function), + uploadsPath: window.uploads_path, + markdown: value, + autofocus: 'end', + }), + ); + }); + + it('adds hidden field with current markdown', () => { + const hiddenField = wrapper.find(`#${formFieldId}`); + + expect(hiddenField.attributes()).toEqual( + expect.objectContaining({ + id: formFieldId, + name: formFieldName, + }), + ); + expect(hiddenField.element.value).toBe(value); + }); + + it('hides the markdown field', () => { + expect(findMarkdownField().exists()).toBe(false); + }); + + it('updates localStorage value', () => { + expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_CONTENT_EDITOR); + }); + }); + }); + + describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => { + beforeEach(() => { + buildWrapper(); + findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + }); + + describe('when initOnAutofocus is true', () => { + beforeEach(() => { + buildWrapper({ propsData: { initOnAutofocus: true } }); + findLocalStorageSync().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + }); + + it('sets the content editor autofocus property to end', () => { + expect(findContentEditor().props().autofocus).toBe('end'); + }); + }); + + it('emits input event when content editor emits change event', async () => { + const newValue = 'new value'; + + await findContentEditor().vm.$emit('change', { markdown: newValue }); + + expect(wrapper.emitted('input')).toEqual([[newValue]]); + }); + + it('bubbles up keydown event', () => { + const event = new Event('keydown'); + + findContentEditor().vm.$emit('keydown', event); + + expect(wrapper.emitted('keydown')).toEqual([[event]]); + }); + + describe(`when segmented control triggers input event with ${EDITING_MODE_MARKDOWN_FIELD} value`, () => { + beforeEach(() => { + findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD); + }); + + it('hides the content editor', () => { + expect(findContentEditor().exists()).toBe(false); + }); + + it('shows the markdown field', () => { + expect(findMarkdownField().exists()).toBe(true); + }); + + it('updates localStorage value', () => { + expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_MARKDOWN_FIELD); + }); + + it('sets the textarea as the activeElement in the document', async () => { + // The component should be rebuilt to attach it to the document body + buildWrapper({ attachTo: document.body }); + await findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + + expect(findContentEditor().exists()).toBe(true); + + await findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD); + await findSegmentedControl().vm.$emit('change', EDITING_MODE_MARKDOWN_FIELD); + + expect(document.activeElement).toBe(findTextarea().element); + }); + }); + + describe('when content editor emits loading event', () => { + beforeEach(() => { + findContentEditor().vm.$emit('loading'); + }); + + it('disables switch editing mode control', () => { + // This is the only way that I found to check the segmented control is disabled + expect(findSegmentedControl().find('input[disabled]').exists()).toBe(true); + }); + + describe.each` + event + ${'loadingSuccess'} + ${'loadingError'} + `('when content editor emits $event event', ({ event }) => { + beforeEach(() => { + findContentEditor().vm.$emit(event); + }); + it('enables the switch editing mode control', () => { + expect(findSegmentedControl().find('input[disabled]').exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js index d792bd46ccd..9c91dc9b5fc 100644 --- a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js @@ -139,8 +139,7 @@ describe('Metrics upload item', () => { closeModal(); await waitForPromises(); - - expect(findModal().attributes('visible')).toBeFalsy(); + expect(findModal().attributes('visible')).toBeUndefined(); }); it('should delete the image when selected', async () => { @@ -189,8 +188,7 @@ describe('Metrics upload item', () => { closeEditModal(); await waitForPromises(); - - expect(findEditModal().attributes('visible')).toBeFalsy(); + expect(findEditModal().attributes('visible')).toBeUndefined(); }); it('should delete the image when selected', async () => { diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js index 518cf354675..537367940e0 100644 --- a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js @@ -4,7 +4,7 @@ import actionsFactory from '~/vue_shared/components/metric_images/store/actions' import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; import createStore from '~/vue_shared/components/metric_images/store'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { fileList, initialData } from '../mock_data'; @@ -35,7 +35,7 @@ describe('Metrics tab store actions', () => { }); afterEach(() => { - createFlash.mockClear(); + createAlert.mockClear(); }); describe('fetching metric images', () => { @@ -61,7 +61,7 @@ describe('Metrics tab store actions', () => { [{ type: types.REQUEST_METRIC_IMAGES }, { type: types.RECEIVE_METRIC_IMAGES_ERROR }], [], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -98,7 +98,7 @@ describe('Metrics tab store actions', () => { [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], [], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -129,7 +129,7 @@ describe('Metrics tab store actions', () => { [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], [], ); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); 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 b57efc88d57..61e4e774420 100644 --- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js +++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js @@ -17,9 +17,16 @@ describe('modal copy button', () => { title: 'Copy this value', id: 'test-id', }, + slots: { + default: 'test', + }, }); }); + it('should show the default slot', () => { + expect(wrapper.text()).toBe('test'); + }); + describe('clipboard', () => { it('should fire a `success` event on click', async () => { const root = createWrapper(wrapper.vm.$root); diff --git a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js b/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js index 2c14d65186b..d930ef63dad 100644 --- a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js +++ b/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js @@ -11,14 +11,14 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NamespaceSelect, { i18n, EMPTY_NAMESPACE_ID, -} from '~/vue_shared/components/namespace_select/namespace_select.vue'; +} from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue'; import { userNamespaces, groupNamespaces } from './mock_data'; const FLAT_NAMESPACES = [...userNamespaces, ...groupNamespaces]; const EMPTY_NAMESPACE_TITLE = 'Empty namespace TEST'; const EMPTY_NAMESPACE_ITEM = { id: EMPTY_NAMESPACE_ID, humanName: EMPTY_NAMESPACE_TITLE }; -describe('Namespace Select', () => { +describe('NamespaceSelectDeprecated', () => { let wrapper; const createComponent = (props = {}) => @@ -207,9 +207,9 @@ describe('Namespace Select', () => { expect(wrapper.emitted('load-more-groups')).toEqual([[]]); }); - describe('when `isLoadingMoreGroups` prop is `true`', () => { + describe('when `isLoading` prop is `true`', () => { it('renders a loading icon', () => { - wrapper = createComponent({ hasNextPageOfGroups: true, isLoadingMoreGroups: true }); + wrapper = createComponent({ hasNextPageOfGroups: true, isLoading: true }); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); @@ -223,4 +223,14 @@ describe('Namespace Select', () => { expect(wrapper.findComponent(GlSearchBoxByType).props('isLoading')).toBe(true); }); }); + + describe('when dropdown is opened', () => { + it('emits `show` event', () => { + wrapper = createComponent(); + + findDropdown().vm.$emit('show'); + + expect(wrapper.emitted('show')).toEqual([[]]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap index bf6c8e8c704..3bac96069ec 100644 --- a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap @@ -2,13 +2,12 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` <timeline-entry-item-stub - class="note note-wrapper being-posted fade-in-half" + class="note note-wrapper note-comment being-posted fade-in-half" > <div - class="timeline-icon" + class="timeline-avatar gl-float-left" > <gl-avatar-link-stub - class="gl-mr-3" href="/root" > <gl-avatar-stub @@ -16,7 +15,7 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` entityid="0" entityname="root" shape="circle" - size="[object Object]" + size="32" src="mock_path" /> </gl-avatar-link-stub> @@ -50,16 +49,20 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` </div> <div - class="note-body" + class="timeline-discussion-body" > <div - class="note-text md" + class="note-body" > - <p> - Foo - </p> - + <div + class="note-text md" + > + <p> + Foo + </p> + + </div> </div> </div> </div> diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index b86c8946e96..8f9f1bb336f 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlAvatar } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; @@ -53,17 +52,4 @@ describe('Issue placeholder note component', () => { expect(findNote().classes()).toContain('discussion'); }); - - describe('avatar size', () => { - it.each` - size | line | isOverviewTab - ${{ default: 24, md: 32 }} | ${null} | ${false} - ${24} | ${{ line_code: '123' }} | ${false} - ${{ default: 24, md: 32 }} | ${{ line_code: '123' }} | ${true} - `('renders avatar $size for $line and $isOverviewTab', ({ size, line, isOverviewTab }) => { - createComponent(false, { line, isOverviewTab }); - - expect(wrapper.findComponent(GlAvatar).props('size')).toEqual(size); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js index b3be2f8a775..112cdaf74c6 100644 --- a/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js +++ b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js @@ -2,6 +2,7 @@ import { GlPagination, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; describe('Pagination bar', () => { const DEFAULT_PROPS = { @@ -20,6 +21,7 @@ describe('Pagination bar', () => { ...DEFAULT_PROPS, ...propsData, }, + stubs: { LocalStorageSync: true }, }); }; @@ -90,4 +92,28 @@ describe('Pagination bar', () => { 'Showing 21 - 40 of 1000+', ); }); + + describe('local storage sync', () => { + it('does not perform local storage sync when no storage key is provided', () => { + createComponent(); + + expect(wrapper.findComponent(LocalStorageSync).exists()).toBe(false); + }); + + it('passes current page size to local storage sync when storage key is provided', () => { + const STORAGE_KEY = 'fakeStorageKey'; + createComponent({ storageKey: STORAGE_KEY }); + + expect(wrapper.getComponent(LocalStorageSync).props('storageKey')).toBe(STORAGE_KEY); + }); + + it('emits set-page event when local storage sync provides new value', () => { + const SAVED_SIZE = 50; + createComponent({ storageKey: 'some storage key' }); + + wrapper.getComponent(LocalStorageSync).vm.$emit('input', SAVED_SIZE); + + expect(wrapper.emitted('set-page-size')).toEqual([[SAVED_SIZE]]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/panel_resizer_spec.js b/spec/frontend/vue_shared/components/panel_resizer_spec.js index d8b903e5bfd..0e261124cbf 100644 --- a/spec/frontend/vue_shared/components/panel_resizer_spec.js +++ b/spec/frontend/vue_shared/components/panel_resizer_spec.js @@ -1,12 +1,10 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import panelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { mount } from '@vue/test-utils'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; describe('Panel Resizer component', () => { - let vm; - let PanelResizer; + let wrapper; - const triggerEvent = (eventName, el = vm.$el, clientX = 0) => { + const triggerEvent = (eventName, el = wrapper.element, clientX = 0) => { const event = document.createEvent('MouseEvents'); event.initMouseEvent( eventName, @@ -29,57 +27,64 @@ describe('Panel Resizer component', () => { el.dispatchEvent(event); }; - beforeEach(() => { - PanelResizer = Vue.extend(panelResizer); - }); - afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('should render a div element with the correct classes and styles', () => { - vm = mountComponent(PanelResizer, { - startSize: 100, - side: 'left', + wrapper = mount(PanelResizer, { + propsData: { + startSize: 100, + side: 'left', + }, }); - expect(vm.$el.tagName).toEqual('DIV'); - expect(vm.$el.getAttribute('class')).toBe( - 'position-absolute position-top-0 position-bottom-0 drag-handle position-left-0', - ); + expect(wrapper.element.tagName).toEqual('DIV'); + expect(wrapper.classes().sort()).toStrictEqual([ + 'drag-handle', + 'position-absolute', + 'position-bottom-0', + 'position-left-0', + 'position-top-0', + ]); - expect(vm.$el.getAttribute('style')).toBe('cursor: ew-resize;'); + expect(wrapper.element.getAttribute('style')).toBe('cursor: ew-resize;'); }); it('should render a div element with the correct classes for a right side panel', () => { - vm = mountComponent(PanelResizer, { - startSize: 100, - side: 'right', + wrapper = mount(PanelResizer, { + propsData: { + startSize: 100, + side: 'right', + }, }); - expect(vm.$el.tagName).toEqual('DIV'); - expect(vm.$el.getAttribute('class')).toBe( - 'position-absolute position-top-0 position-bottom-0 drag-handle position-right-0', - ); + expect(wrapper.element.tagName).toEqual('DIV'); + expect(wrapper.classes().sort()).toStrictEqual([ + 'drag-handle', + 'position-absolute', + 'position-bottom-0', + 'position-right-0', + 'position-top-0', + ]); }); it('drag the resizer', () => { - vm = mountComponent(PanelResizer, { - startSize: 100, - side: 'left', + wrapper = mount(PanelResizer, { + propsData: { + startSize: 100, + side: 'left', + }, }); - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - triggerEvent('mousedown', vm.$el); + triggerEvent('mousedown'); triggerEvent('mousemove', document); triggerEvent('mouseup', document); - expect(vm.$emit.mock.calls).toEqual([ - ['resize-start', 100], - ['update:size', 100], - ['resize-end', 100], - ]); - - expect(vm.size).toBe(100); + expect(wrapper.emitted()).toEqual({ + 'resize-start': [[100]], + 'update:size': [[100]], + 'resize-end': [[100]], + }); }); }); diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap index 2abae33bc19..66cf2354bc7 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap @@ -2,7 +2,7 @@ exports[`History Item renders the correct markup 1`] = ` <li - class="timeline-entry system-note note-wrapper gl-mb-6!" + class="timeline-entry system-note note-wrapper" > <div class="timeline-entry-inner" @@ -22,11 +22,13 @@ exports[`History Item renders the correct markup 1`] = ` <div class="note-header" > - <span> + <div + class="note-header-info" + > <div data-testid="default-slot" /> - </span> + </div> </div> <div diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js index c5672bc28cc..09b0b3d43ad 100644 --- a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js +++ b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js @@ -6,7 +6,7 @@ import { expectedDownloadDropdownPropsWithTitle, securityReportMergeRequestDownloadPathsQueryResponse, } from 'jest/vue_shared/security_reports/mock_data'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Component from '~/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; import { @@ -93,8 +93,8 @@ describe('Merge request artifact Download', () => { }); }); - it('calls createFlash correctly', () => { - expect(createFlash).toHaveBeenCalledWith({ + it('calls createAlert correctly', () => { + expect(createAlert).toHaveBeenCalledWith({ message: Component.i18n.apiError, captureError: true, error: expect.any(Error), diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index 4c7ac6e9a6f..30c1a4b7d2f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -67,9 +67,9 @@ describe('LabelsSelectRoot', () => { // We're utilizing `onDropdownClose` event emitted from the component to always include `touchedLabels` // while the first param of the method is the labels list which were added/removed. - expect(wrapper.emitted('updateSelectedLabels')).toBeTruthy(); + expect(wrapper.emitted('updateSelectedLabels')).toHaveLength(1); expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([touchedLabels]); - expect(wrapper.emitted('onDropdownClose')).toBeTruthy(); + expect(wrapper.emitted('onDropdownClose')).toHaveLength(1); expect(wrapper.emitted('onDropdownClose')[0]).toEqual([touchedLabels]); }); @@ -88,7 +88,7 @@ describe('LabelsSelectRoot', () => { }, ); - expect(wrapper.emitted('updateSelectedLabels')).toBeTruthy(); + expect(wrapper.emitted('updateSelectedLabels')).toHaveLength(1); expect(wrapper.emitted('updateSelectedLabels')[0]).toEqual([ [ { @@ -97,7 +97,7 @@ describe('LabelsSelectRoot', () => { }, ], ]); - expect(wrapper.emitted('onDropdownClose')).toBeTruthy(); + expect(wrapper.emitted('onDropdownClose')).toHaveLength(1); expect(wrapper.emitted('onDropdownClose')[0]).toEqual([[]]); }); }); @@ -106,8 +106,7 @@ describe('LabelsSelectRoot', () => { it('emits `toggleCollapse` event on component', () => { createComponent(); wrapper.vm.handleCollapsedValueClick(); - - expect(wrapper.emitted().toggleCollapse).toBeTruthy(); + expect(wrapper.emitted().toggleCollapse).toHaveLength(1); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index 2bc513e87bf..edd044bd754 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions'; import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; @@ -102,7 +102,7 @@ describe('LabelsSelect Actions', () => { it('shows flash error', () => { actions.receiveLabelsFailure({ commit: () => {} }); - expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); }); }); @@ -186,7 +186,7 @@ describe('LabelsSelect Actions', () => { it('shows flash error', () => { actions.receiveCreateLabelFailure({ commit: () => {} }); - expect(createFlash).toHaveBeenCalledWith({ message: 'Error creating label.' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'Error creating label.' }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index 1819e750324..2b2508b5e11 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -189,10 +189,20 @@ describe('LabelsSelect Mutations', () => { }); labelGroupIds.forEach((l) => { - expect(state.labels[l.id - 1].touched).toBeFalsy(); + expect(state.labels[l.id - 1].touched).toBeUndefined(); expect(state.labels[l.id - 1].set).toBe(false); }); }); + it('allows selection of multiple scoped labels', () => { + const state = { labels: cloneDeep(labels), allowMultipleScopedLabels: true }; + + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: labels[4].id }] }); + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: labels[5].id }] }); + + expect(state.labels[4].set).toBe(true); + expect(state.labels[5].set).toBe(true); + expect(state.labels[6].set).toBe(true); + }); }); describe(`${types.UPDATE_LABELS_SET_STATE}`, () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index 9c29f304c71..237f174e048 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -4,7 +4,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { workspaceLabelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; @@ -202,7 +202,7 @@ describe('DropdownContentsCreateView', () => { }); }); - it('calls createFlash is mutation has a user-recoverable error', async () => { + it('calls createAlert is mutation has a user-recoverable error', async () => { createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler }); fillLabelAttributes(); await nextTick(); @@ -210,10 +210,10 @@ describe('DropdownContentsCreateView', () => { findCreateButton().vm.$emit('click'); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); - it('calls createFlash is mutation was rejected', async () => { + it('calls createAlert is mutation was rejected', async () => { createComponent({ mutationHandler: createLabelErrorHandler }); fillLabelAttributes(); await nextTick(); @@ -221,7 +221,7 @@ describe('DropdownContentsCreateView', () => { findCreateButton().vm.$emit('click'); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('displays error in alert if label title is already taken', async () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 7f6770e0bea..5d8ad5ddee5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -9,7 +9,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; @@ -143,13 +143,13 @@ describe('DropdownContentsLabelsView', () => { expect(findNoResultsMessage().isVisible()).toBe(true); }); - it('calls `createFlash` when fetching labels failed', async () => { + it('calls `createAlert` when fetching labels failed', async () => { createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); await makeObserverAppear(); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await waitForPromises(); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); it('emits an `input` event on label click', async () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index cad401e0013..b58c44645d6 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -3,7 +3,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; @@ -151,7 +151,7 @@ describe('LabelsSelectRoot', () => { it('creates flash with error message when query is rejected', async () => { createComponent({ queryHandler: errorQueryHandler }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); + expect(createAlert).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); }); }); @@ -197,7 +197,7 @@ describe('LabelsSelectRoot', () => { findDropdownContents().vm.$emit('setLabels', [label]); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ captureError: true, error: expect.anything(), message: 'An error occurred while updating labels.', diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js index fd3ff9ce892..f661bd6747a 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js @@ -1,10 +1,5 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; -import { - BIDI_CHARS, - BIDI_CHARS_CLASS_LIST, - BIDI_CHAR_TOOLTIP, -} from '~/vue_shared/components/source_viewer/constants'; const DEFAULT_PROPS = { number: 2, @@ -31,7 +26,6 @@ describe('Chunk Line component', () => { const findLineLink = () => wrapper.find('.file-line-num'); const findBlameLink = () => wrapper.find('.file-line-blame'); const findContent = () => wrapper.findByTestId('content'); - const findWrappedBidiChars = () => wrapper.findAllByTestId('bidi-wrapper'); beforeEach(() => { createComponent(); @@ -40,22 +34,6 @@ describe('Chunk Line component', () => { afterEach(() => wrapper.destroy()); describe('rendering', () => { - it('wraps BiDi characters', () => { - const content = `// some content ${BIDI_CHARS.toString()} with BiDi chars`; - createComponent({ content }); - const wrappedBidiChars = findWrappedBidiChars(); - - expect(wrappedBidiChars.length).toBe(BIDI_CHARS.length); - - wrappedBidiChars.wrappers.forEach((_, i) => { - expect(wrappedBidiChars.at(i).text()).toBe(BIDI_CHARS[i]); - expect(wrappedBidiChars.at(i).attributes()).toMatchObject({ - class: BIDI_CHARS_CLASS_LIST, - title: BIDI_CHAR_TOOLTIP, - }); - }); - }); - it('renders a blame link', () => { expect(findBlameLink().attributes()).toMatchObject({ href: `${DEFAULT_PROPS.blamePath}#L${DEFAULT_PROPS.number}`, diff --git a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js new file mode 100644 index 00000000000..4a995e2fde1 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js @@ -0,0 +1,44 @@ +import hljs from 'highlight.js/lib/core'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; +import { highlight } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; + +jest.mock('highlight.js/lib/core', () => ({ + highlight: jest.fn().mockReturnValue({}), + registerLanguage: jest.fn(), +})); + +jest.mock('~/content_editor/services/highlight_js_language_loader', () => ({ + javascript: jest.fn().mockReturnValue({ default: jest.fn() }), +})); + +jest.mock('~/vue_shared/components/source_viewer/plugins/index', () => ({ + registerPlugins: jest.fn(), +})); + +const fileType = 'text'; +const content = 'function test() { return true };'; +const language = 'javascript'; + +describe('Highlight utility', () => { + beforeEach(() => highlight(fileType, content, language)); + + it('loads the language', () => { + expect(languageLoader.javascript).toHaveBeenCalled(); + }); + + it('registers the plugins', () => { + expect(registerPlugins).toHaveBeenCalled(); + }); + + it('registers the language', () => { + expect(hljs.registerLanguage).toHaveBeenCalledWith( + language, + languageLoader[language]().default, + ); + }); + + it('highlights the content', () => { + expect(hljs.highlight).toHaveBeenCalledWith(content, { language }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js index 83fdc5d669d..57045ca54ae 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/index_spec.js @@ -1,14 +1,18 @@ -import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; -import { HLJS_ON_AFTER_HIGHLIGHT } from '~/vue_shared/components/source_viewer/constants'; -import wrapComments from '~/vue_shared/components/source_viewer/plugins/wrap_comments'; +import { + registerPlugins, + HLJS_ON_AFTER_HIGHLIGHT, +} from '~/vue_shared/components/source_viewer/plugins/index'; +import wrapChildNodes from '~/vue_shared/components/source_viewer/plugins/wrap_child_nodes'; +import wrapBidiChars from '~/vue_shared/components/source_viewer/plugins/wrap_bidi_chars'; -jest.mock('~/vue_shared/components/source_viewer/plugins/wrap_comments'); +jest.mock('~/vue_shared/components/source_viewer/plugins/wrap_child_nodes'); const hljsMock = { addPlugin: jest.fn() }; describe('Highlight.js plugin registration', () => { beforeEach(() => registerPlugins(hljsMock)); it('registers our plugins', () => { - expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments }); + expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars }); + expect(hljsMock.addPlugin).toHaveBeenCalledWith({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js index 8079d5ad99a..e4ce07ec668 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js @@ -15,7 +15,7 @@ describe('createLink', () => { it('escapes the user-controlled content', () => { const unescapedXSS = '<script>XSS</script>'; const escapedPackageName = '<script>XSS</script>'; - const escapedHref = '&lt;script&gt;XSS&lt;/script&gt;'; + const escapedHref = '<script>XSS</script>'; const href = `http://test.com/${unescapedXSS}`; const innerText = `testing${unescapedXSS}`; const result = `<a href="http://test.com/${escapedHref}" rel="nofollow noreferrer noopener">testing${escapedPackageName}</a>`; diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_bidi_chars_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_bidi_chars_spec.js new file mode 100644 index 00000000000..f40f8b22627 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_bidi_chars_spec.js @@ -0,0 +1,17 @@ +import wrapBidiChars from '~/vue_shared/components/source_viewer/plugins/wrap_bidi_chars'; +import { + BIDI_CHARS, + BIDI_CHARS_CLASS_LIST, + BIDI_CHAR_TOOLTIP, +} from '~/vue_shared/components/source_viewer/constants'; + +describe('Highlight.js plugin for wrapping BiDi characters', () => { + it.each(BIDI_CHARS)('wraps %s BiDi char', (bidiChar) => { + const inputValue = `// some content ${bidiChar} with BiDi chars`; + const outputValue = `// some content <span class="${BIDI_CHARS_CLASS_LIST}" title="${BIDI_CHAR_TOOLTIP}">${bidiChar}</span>`; + const hljsResultMock = { value: inputValue }; + + wrapBidiChars(hljsResultMock); + expect(hljsResultMock.value).toContain(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js new file mode 100644 index 00000000000..bc6df1a2565 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js @@ -0,0 +1,22 @@ +import wrapChildNodes from '~/vue_shared/components/source_viewer/plugins/wrap_child_nodes'; + +describe('Highlight.js plugin for wrapping _emitter nodes', () => { + it('mutates the input value by wrapping each node in a span tag', () => { + const hljsResultMock = { + _emitter: { + rootNode: { + children: [ + { kind: 'string', children: ['Text 1'] }, + { kind: 'string', children: ['Text 2', { kind: 'comment', children: ['Text 3'] }] }, + 'Text4\nText5', + ], + }, + }, + }; + + const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text4</span>\n<span class="">Text5</span>`; + + wrapChildNodes(hljsResultMock); + expect(hljsResultMock.value).toBe(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_comments_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_comments_spec.js deleted file mode 100644 index 5fd4182da29..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_comments_spec.js +++ /dev/null @@ -1,29 +0,0 @@ -import { HLJS_COMMENT_SELECTOR } from '~/vue_shared/components/source_viewer/constants'; -import wrapComments from '~/vue_shared/components/source_viewer/plugins/wrap_comments'; - -describe('Highlight.js plugin for wrapping comments', () => { - it('mutates the input value by wrapping each line in a span tag', () => { - const inputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 \n* Line 2 \n*/</span>`; - const outputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 \n<span class="${HLJS_COMMENT_SELECTOR}">* Line 2 </span>\n<span class="${HLJS_COMMENT_SELECTOR}">*/</span>`; - const hljsResultMock = { value: inputValue }; - - wrapComments(hljsResultMock); - expect(hljsResultMock.value).toBe(outputValue); - }); - - it('does not mutate the input value if the hljs comment selector is not present', () => { - const inputValue = '<span class="hljs-keyword">const</span>'; - const hljsResultMock = { value: inputValue }; - - wrapComments(hljsResultMock); - expect(hljsResultMock.value).toBe(inputValue); - }); - - it('does not mutate the input value if the hljs comment line includes a closing tag', () => { - const inputValue = `<span class="${HLJS_COMMENT_SELECTOR}">/* Line 1 </span> \n* Line 2 \n*/`; - const hljsResultMock = { value: inputValue }; - - wrapComments(hljsResultMock); - expect(hljsResultMock.value).toBe(inputValue); - }); -}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index e020d9a557e..6d319b37b02 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -22,10 +22,10 @@ jest.mock('~/vue_shared/components/source_viewer/plugins/index'); Vue.use(VueRouter); const router = new VueRouter(); -const generateContent = (content, totalLines = 1) => { +const generateContent = (content, totalLines = 1, delimiter = '\n') => { let generatedContent = ''; for (let i = 0; i < totalLines; i += 1) { - generatedContent += `Line: ${i + 1} = ${content}\n`; + generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; } return generatedContent; }; @@ -38,7 +38,9 @@ describe('Source Viewer component', () => { const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; const chunk1 = generateContent('// Some source code 1', 70); const chunk2 = generateContent('// Some source code 2', 70); - const content = chunk1 + chunk2; + const chunk3 = generateContent('// Some source code 3', 70, '\r\n'); + const chunk3Result = generateContent('// Some source code 3', 70, '\n'); + const content = chunk1 + chunk2 + chunk3; const path = 'some/path.js'; const blamePath = 'some/blame/path.js'; const fileType = 'javascript'; @@ -152,6 +154,19 @@ describe('Source Viewer component', () => { startingFrom: 70, }); }); + + it('renders the third chunk', async () => { + const thirdChunk = findChunks().at(2); + + expect(thirdChunk.props('content')).toContain(chunk3Result.trim()); + + expect(chunk3Result).toEqual(chunk3.replace(/\r?\n/g, '\n')); + + expect(thirdChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 140, + }); + }); }); it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { 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 c6f01efa71a..79b1f17afa0 100644 --- a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js +++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js @@ -1,121 +1,109 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue'; - -const createComponent = (config) => { - const Component = Vue.extend(stackedProgressBarComponent); - const defaultConfig = { - successLabel: 'Synced', - failureLabel: 'Failed', - neutralLabel: 'Out of sync', - successCount: 25, - failureCount: 10, - totalCount: 5000, - ...config, - }; - - return mountComponent(Component, defaultConfig); -}; +import { mount } from '@vue/test-utils'; +import StackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue'; describe('StackedProgressBarComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); + let wrapper; + + const createComponent = (config) => { + const defaultConfig = { + successLabel: 'Synced', + failureLabel: 'Failed', + neutralLabel: 'Out of sync', + successCount: 25, + failureCount: 10, + totalCount: 5000, + ...config, + }; + + wrapper = mount(StackedProgressBarComponent, { propsData: defaultConfig }); + }; afterEach(() => { - vm.$destroy(); + wrapper.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', () => { - expect(vm.neutralCount).toBe(4965); // 5000 - 25 - 10 - }); - }); - }); + const findSuccessBar = () => wrapper.find('.status-green'); + const findNeutralBar = () => wrapper.find('.status-neutral'); + const findFailureBar = () => wrapper.find('.status-red'); + const findUnavailableBar = () => wrapper.find('.status-unavailable'); describe('template', () => { it('renders container element', () => { - expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); + createComponent(); + + expect(wrapper.classes()).toContain('stacked-progress-bar'); }); it('renders empty state when count is unavailable', () => { - const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); + createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); - expect(findUnavailableBarText(vmX)).not.toBeUndefined(); + expect(findUnavailableBar()).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(); + createComponent(); + + expect(findSuccessBar().exists()).toBe(true); + expect(findNeutralBar().exists()).toBe(true); + expect(findFailureBar().exists()).toBe(true); }); describe('getPercent', () => { it('returns correct percentages from provided count based on `totalCount`', () => { - vm = createComponent({ totalCount: 100, successCount: 25, failureCount: 10 }); + createComponent({ totalCount: 100, successCount: 25, failureCount: 10 }); - expect(findSuccessBarText(vm)).toBe('25%'); - expect(findNeutralBarText(vm)).toBe('65%'); - expect(findFailureBarText(vm)).toBe('10%'); + expect(findSuccessBar().text()).toBe('25%'); + expect(findNeutralBar().text()).toBe('65%'); + expect(findFailureBar().text()).toBe('10%'); }); it('returns percentage with decimal place when decimal is greater than 1', () => { - vm = createComponent({ successCount: 67 }); + createComponent({ successCount: 67 }); - expect(findSuccessBarText(vm)).toBe('1.3%'); + expect(findSuccessBar().text()).toBe('1.3%'); }); it('returns percentage as `< 1%` from provided count based on `totalCount` when evaluated value is less than 1', () => { - vm = createComponent({ successCount: 10 }); + createComponent({ successCount: 10 }); - expect(findSuccessBarText(vm)).toBe('< 1%'); + expect(findSuccessBar().text()).toBe('< 1%'); }); it('returns not available if totalCount is falsy', () => { - vm = createComponent({ totalCount: 0 }); + createComponent({ totalCount: 0 }); - expect(findUnavailableBarText(vm)).toBe('Not available'); + expect(findUnavailableBar().text()).toBe('Not available'); }); it('returns 99.9% when numbers are extreme decimals', () => { - vm = createComponent({ totalCount: 1000000 }); + createComponent({ totalCount: 1000000 }); - expect(findNeutralBarText(vm)).toBe('99.9%'); + expect(findNeutralBar().text()).toBe('99.9%'); }); }); - describe('barStyle', () => { - it('returns style string based on percentage provided', () => { - expect(vm.barStyle(50)).toBe('width: 50%;'); + describe('bar style', () => { + it('renders width based on percentage provided', () => { + createComponent({ totalCount: 100, successCount: 25 }); + + expect(findSuccessBar().element.style.width).toBe('25%'); }); }); - describe('getTooltip', () => { + describe('tooltip', () => { describe('when hideTooltips is false', () => { it('returns label string based on label and count provided', () => { - expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10'); + createComponent({ successCount: 10, successLabel: 'Synced', hideTooltips: false }); + + expect(findSuccessBar().attributes('title')).toBe('Synced: 10'); }); }); describe('when hideTooltips is true', () => { - beforeEach(() => { - vm = createComponent({ hideTooltips: true }); - }); - it('returns an empty string', () => { - expect(vm.getTooltip('Synced', 10)).toBe(''); + createComponent({ successCount: 10, successLabel: 'Synced', hideTooltips: true }); + + expect(findSuccessBar().attributes('title')).toBe(''); }); }); }); diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/helpers.js b/spec/frontend/vue_shared/components/timezone_dropdown/helpers.js new file mode 100644 index 00000000000..dee4c92add4 --- /dev/null +++ b/spec/frontend/vue_shared/components/timezone_dropdown/helpers.js @@ -0,0 +1,6 @@ +import timezoneDataFixture from 'test_fixtures/timezones/short.json'; + +export { timezoneDataFixture }; + +export const findTzByName = (identifier = '') => + timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js new file mode 100644 index 00000000000..e5f56c63031 --- /dev/null +++ b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js @@ -0,0 +1,111 @@ +import { GlDropdownItem, GlDropdown } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; +import { formatTimezone } from '~/lib/utils/datetime_utility'; +import { findTzByName, timezoneDataFixture } from './helpers'; + +describe('Deploy freeze timezone dropdown', () => { + let wrapper; + let store; + + const createComponent = (searchTerm, selectedTimezone) => { + wrapper = shallowMountExtended(TimezoneDropdown, { + store, + propsData: { + value: selectedTimezone, + timezoneData: timezoneDataFixture, + name: 'user[timezone]', + }, + }); + + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ searchTerm }); + }; + + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findEmptyResultsItem = () => wrapper.findByTestId('noMatchingResults'); + const findHiddenInput = () => wrapper.find('input'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('No time zones found', () => { + beforeEach(() => { + createComponent('UTC timezone'); + }); + + it('renders empty results message', () => { + expect(findDropdownItemByIndex(0).text()).toBe('No matching results'); + }); + }); + + describe('Search term is empty', () => { + beforeEach(() => { + createComponent(''); + }); + + it('renders all timezones when search term is empty', () => { + expect(findAllDropdownItems()).toHaveLength(timezoneDataFixture.length); + }); + }); + + describe('Time zones found', () => { + beforeEach(() => { + createComponent('Alaska'); + }); + + it('renders only the time zone searched for', () => { + const selectedTz = findTzByName('Alaska'); + expect(findAllDropdownItems()).toHaveLength(1); + expect(findDropdownItemByIndex(0).text()).toBe(formatTimezone(selectedTz)); + }); + + it('should not display empty results message', () => { + expect(findEmptyResultsItem().exists()).toBe(false); + }); + + describe('Custom events', () => { + const selectedTz = findTzByName('Alaska'); + + it('should emit input if a time zone is clicked', () => { + findDropdownItemByIndex(0).vm.$emit('click'); + expect(wrapper.emitted('input')).toEqual([ + [ + { + formattedTimezone: formatTimezone(selectedTz), + identifier: selectedTz.identifier, + }, + ], + ]); + }); + }); + }); + + describe('Selected time zone not found', () => { + beforeEach(() => { + createComponent('', 'Berlin'); + }); + + it('renders empty selections', () => { + expect(wrapper.findComponent(GlDropdown).props().text).toBe('Select timezone'); + }); + + it('preserves initial value in the associated input', () => { + expect(findHiddenInput().attributes('value')).toBe('Berlin'); + }); + }); + + describe('Selected time zone found', () => { + beforeEach(() => { + createComponent('', 'Europe/Berlin'); + }); + + it('renders selected time zone as dropdown label', () => { + expect(wrapper.findComponent(GlDropdown).props().text).toBe('[UTC + 2] Berlin'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js index aefe6a5c3e8..acda1a64a75 100644 --- a/spec/frontend/vue_shared/components/url_sync_spec.js +++ b/spec/frontend/vue_shared/components/url_sync_spec.js @@ -1,10 +1,11 @@ import { shallowMount } from '@vue/test-utils'; import { historyPushState } from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import UrlSyncComponent from '~/vue_shared/components/url_sync.vue'; +import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility'; +import UrlSyncComponent, { URL_SET_PARAMS_STRATEGY } from '~/vue_shared/components/url_sync.vue'; jest.mock('~/lib/utils/url_utility', () => ({ - mergeUrlParams: jest.fn((query, url) => `urlParams: ${query} ${url}`), + mergeUrlParams: jest.fn((query, url) => `urlParams: ${JSON.stringify(query)} ${url}`), + setUrlParams: jest.fn((query, url) => `urlParams: ${JSON.stringify(query)} ${url}`), })); jest.mock('~/lib/utils/common_utils', () => ({ @@ -17,9 +18,14 @@ describe('url sync component', () => { const findButton = () => wrapper.find('button'); - const createComponent = ({ query = mockQuery, scopedSlots, slots } = {}) => { + const createComponent = ({ + query = mockQuery, + scopedSlots, + slots, + urlParamsUpdateStrategy, + } = {}) => { wrapper = shallowMount(UrlSyncComponent, { - propsData: { query }, + propsData: { query, ...(urlParamsUpdateStrategy && { urlParamsUpdateStrategy }) }, scopedSlots, slots, }); @@ -29,21 +35,39 @@ describe('url sync component', () => { wrapper.destroy(); }); - const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => { - expect(mergeUrlParams).toHaveBeenCalledTimes(times); - expect(mergeUrlParams).toHaveBeenCalledWith(query, window.location.href, { - spreadArrays: true, - }); + const expectUrlSyncFactory = ( + query, + times, + urlParamsUpdateStrategy, + urlOptions, + urlReturnValue, + ) => { + expect(urlParamsUpdateStrategy).toHaveBeenCalledTimes(times); + expect(urlParamsUpdateStrategy).toHaveBeenCalledWith(query, window.location.href, urlOptions); expect(historyPushState).toHaveBeenCalledTimes(times); - expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue); + expect(historyPushState).toHaveBeenCalledWith(urlReturnValue); + }; + + const expectUrlSyncWithMergeUrlParams = (query, times, mergeUrlParamsReturnValue) => { + expectUrlSyncFactory( + query, + times, + mergeUrlParams, + { spreadArrays: true }, + mergeUrlParamsReturnValue, + ); + }; + + const expectUrlSyncWithSetUrlParams = (query, times, setUrlParamsReturnValue) => { + expectUrlSyncFactory(query, times, setUrlParams, true, setUrlParamsReturnValue); }; describe('with query as a props', () => { it('immediately syncs the query to the URL', () => { createComponent(); - expectUrlSync(mockQuery, 1, mergeUrlParams.mock.results[0].value); + expectUrlSyncWithMergeUrlParams(mockQuery, 1, mergeUrlParams.mock.results[0].value); }); describe('when the query is modified', () => { @@ -54,11 +78,21 @@ describe('url sync component', () => { // using setProps to test the watcher await wrapper.setProps({ query: newQuery }); - expectUrlSync(mockQuery, 2, mergeUrlParams.mock.results[1].value); + expectUrlSyncWithMergeUrlParams(mockQuery, 2, mergeUrlParams.mock.results[1].value); }); }); }); + describe('with url-params-update-strategy equals to URL_SET_PARAMS_STRATEGY', () => { + it('uses setUrlParams to generate URL', () => { + createComponent({ + urlParamsUpdateStrategy: URL_SET_PARAMS_STRATEGY, + }); + + expectUrlSyncWithSetUrlParams(mockQuery, 1, setUrlParams.mock.results[0].value); + }); + }); + describe('with scoped slot', () => { const scopedSlots = { default: ` @@ -77,7 +111,7 @@ describe('url sync component', () => { findButton().trigger('click'); - expectUrlSync({ bar: 'baz' }, 1, mergeUrlParams.mock.results[0].value); + expectUrlSyncWithMergeUrlParams({ bar: 'baz' }, 1, mergeUrlParams.mock.results[0].value); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js deleted file mode 100644 index f87737ca86a..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js +++ /dev/null @@ -1,134 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlAvatar, GlTooltip } from '@gitlab/ui'; -import defaultAvatarUrl from 'images/no_avatar.png'; -import { placeholderImage } from '~/lazy_loader'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image_new.vue'; - -jest.mock('images/no_avatar.png', () => 'default-avatar-url'); - -const PROVIDED_PROPS = { - size: 32, - imgSrc: 'myavatarurl.com', - imgAlt: 'mydisplayname', - cssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', -}; - -describe('User Avatar Image Component', () => { - let wrapper; - - const findAvatar = () => wrapper.findComponent(GlAvatar); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('Initialization', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - }, - }); - }); - - it('should render `GlAvatar` and provide correct properties to it', () => { - expect(findAvatar().attributes('data-src')).toBe( - `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - ); - expect(findAvatar().props()).toMatchObject({ - src: `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - alt: PROVIDED_PROPS.imgAlt, - size: PROVIDED_PROPS.size, - }); - }); - - it('should add correct CSS classes', () => { - const classes = wrapper.findComponent(GlAvatar).classes(); - expect(classes).toContain(PROVIDED_PROPS.cssClasses); - expect(classes).not.toContain('lazy'); - }); - }); - - describe('Initialization when lazy', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - lazy: true, - }, - }); - }); - - it('should add lazy attributes', () => { - expect(findAvatar().classes()).toContain('lazy'); - expect(findAvatar().attributes()).toMatchObject({ - src: placeholderImage, - 'data-src': `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - }); - }); - - it('should use maximum number when size is provided as an object', () => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - size: { default: 16, md: 64, lg: 24 }, - lazy: true, - }, - }); - - expect(findAvatar().attributes('data-src')).toBe(`${PROVIDED_PROPS.imgSrc}?width=${64}`); - }); - }); - - describe('Initialization without src', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - imgSrc: null, - }, - }); - }); - - it('should have default avatar image', () => { - expect(findAvatar().props('src')).toBe(`${defaultAvatarUrl}?width=${PROVIDED_PROPS.size}`); - }); - }); - - describe('Dynamic tooltip content', () => { - const slots = { - default: ['Action!'], - }; - - describe('when `tooltipText` is provided and no default slot', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { ...PROVIDED_PROPS }, - }); - }); - - it('renders the tooltip with `tooltipText` as content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); - }); - }); - - describe('when `tooltipText` and default slot is provided', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { ...PROVIDED_PROPS }, - slots, - }); - }); - - it('does not render `tooltipText` inside the tooltip', () => { - expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); - }); - - it('renders the content provided via default slot', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js deleted file mode 100644 index 2c1be6ec47e..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js +++ /dev/null @@ -1,127 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlTooltip } from '@gitlab/ui'; -import defaultAvatarUrl from 'images/no_avatar.png'; -import { placeholderImage } from '~/lazy_loader'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image_old.vue'; - -jest.mock('images/no_avatar.png', () => 'default-avatar-url'); - -const PROVIDED_PROPS = { - size: 32, - imgSrc: 'myavatarurl.com', - imgAlt: 'mydisplayname', - cssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', -}; - -const DEFAULT_PROPS = { - size: 20, -}; - -describe('User Avatar Image Component', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('Initialization', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - }, - }); - }); - - it('should have <img> as a child element', () => { - const imageElement = wrapper.find('img'); - - expect(imageElement.exists()).toBe(true); - expect(imageElement.attributes('src')).toBe( - `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - ); - expect(imageElement.attributes('data-src')).toBe( - `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - ); - expect(imageElement.attributes('alt')).toBe(PROVIDED_PROPS.imgAlt); - }); - - it('should properly render img css', () => { - const classes = wrapper.find('img').classes(); - expect(classes).toEqual(['avatar', 's32', PROVIDED_PROPS.cssClasses]); - expect(classes).not.toContain('lazy'); - }); - }); - - describe('Initialization when lazy', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - lazy: true, - }, - }); - }); - - it('should add lazy attributes', () => { - const imageElement = wrapper.find('img'); - - expect(imageElement.classes()).toContain('lazy'); - expect(imageElement.attributes('src')).toBe(placeholderImage); - expect(imageElement.attributes('data-src')).toBe( - `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, - ); - }); - }); - - describe('Initialization without src', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage); - }); - - it('should have default avatar image', () => { - const imageElement = wrapper.find('img'); - - expect(imageElement.attributes('src')).toBe( - `${defaultAvatarUrl}?width=${DEFAULT_PROPS.size}`, - ); - }); - }); - - describe('Dynamic tooltip content', () => { - const slots = { - default: ['Action!'], - }; - - describe('when `tooltipText` is provided and no default slot', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { ...PROVIDED_PROPS }, - }); - }); - - it('renders the tooltip with `tooltipText` as content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); - }); - }); - - describe('when `tooltipText` and default slot is provided', () => { - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { ...PROVIDED_PROPS }, - slots, - }); - }); - - it('does not render `tooltipText` inside the tooltip', () => { - expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); - }); - - it('renders the content provided via default slot', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js index 6ad2ef226c2..d63b13981ac 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -1,7 +1,10 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAvatar, GlTooltip } from '@gitlab/ui'; +import defaultAvatarUrl from 'images/no_avatar.png'; +import { placeholderImage } from '~/lazy_loader'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import UserAvatarImageNew from '~/vue_shared/components/user_avatar/user_avatar_image_new.vue'; -import UserAvatarImageOld from '~/vue_shared/components/user_avatar/user_avatar_image_old.vue'; + +jest.mock('images/no_avatar.png', () => 'default-avatar-url'); const PROVIDED_PROPS = { size: 32, @@ -15,37 +18,117 @@ const PROVIDED_PROPS = { describe('User Avatar Image Component', () => { let wrapper; - const createWrapper = (props = {}, { glAvatarForAllUserAvatars } = {}) => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { - ...PROVIDED_PROPS, - ...props, - }, - provide: { - glFeatures: { - glAvatarForAllUserAvatars, - }, - }, - }); - }; + const findAvatar = () => wrapper.findComponent(GlAvatar); afterEach(() => { wrapper.destroy(); }); - describe.each([ - [false, true, true], - [true, false, true], - [true, true, true], - [false, false, false], - ])( - 'when glAvatarForAllUserAvatars=%s and enforceGlAvatar=%s', - (glAvatarForAllUserAvatars, enforceGlAvatar, isUsingNewVersion) => { - it(`will render ${isUsingNewVersion ? 'new' : 'old'} version`, () => { - createWrapper({ enforceGlAvatar }, { glAvatarForAllUserAvatars }); - expect(wrapper.findComponent(UserAvatarImageNew).exists()).toBe(isUsingNewVersion); - expect(wrapper.findComponent(UserAvatarImageOld).exists()).toBe(!isUsingNewVersion); - }); - }, - ); + describe('Initialization', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...PROVIDED_PROPS, + }, + }); + }); + + it('should render `GlAvatar` and provide correct properties to it', () => { + expect(findAvatar().attributes('data-src')).toBe( + `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, + ); + expect(findAvatar().props()).toMatchObject({ + src: `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, + alt: PROVIDED_PROPS.imgAlt, + size: PROVIDED_PROPS.size, + }); + }); + + it('should add correct CSS classes', () => { + const classes = wrapper.findComponent(GlAvatar).classes(); + expect(classes).toContain(PROVIDED_PROPS.cssClasses); + expect(classes).not.toContain('lazy'); + }); + }); + + describe('Initialization when lazy', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...PROVIDED_PROPS, + lazy: true, + }, + }); + }); + + it('should add lazy attributes', () => { + expect(findAvatar().classes()).toContain('lazy'); + expect(findAvatar().attributes()).toMatchObject({ + src: placeholderImage, + 'data-src': `${PROVIDED_PROPS.imgSrc}?width=${PROVIDED_PROPS.size}`, + }); + }); + + it('should use maximum number when size is provided as an object', () => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...PROVIDED_PROPS, + size: { default: 16, md: 64, lg: 24 }, + lazy: true, + }, + }); + + expect(findAvatar().attributes('data-src')).toBe(`${PROVIDED_PROPS.imgSrc}?width=${64}`); + }); + }); + + describe('Initialization without src', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...PROVIDED_PROPS, + imgSrc: null, + }, + }); + }); + + it('should have default avatar image', () => { + expect(findAvatar().props('src')).toBe(`${defaultAvatarUrl}?width=${PROVIDED_PROPS.size}`); + }); + }); + + describe('Dynamic tooltip content', () => { + const slots = { + default: ['Action!'], + }; + + describe('when `tooltipText` is provided and no default slot', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + }); + }); + + it('renders the tooltip with `tooltipText` as content', () => { + expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); + }); + }); + + describe('when `tooltipText` and default slot is provided', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + slots, + }); + }); + + it('does not render `tooltipText` inside the tooltip', () => { + expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); + }); + + it('renders the content provided via default slot', () => { + expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js deleted file mode 100644 index f485a14cfea..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_new_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { GlAvatarLink } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { TEST_HOST } from 'spec/test_constants'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link_new.vue'; - -describe('User Avatar Link Component', () => { - let wrapper; - - const findUserName = () => wrapper.findByTestId('user-avatar-link-username'); - - const defaultProps = { - linkHref: `${TEST_HOST}/myavatarurl.com`, - imgSize: 32, - imgSrc: `${TEST_HOST}/myavatarurl.com`, - imgAlt: 'mydisplayname', - imgCssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', - username: 'username', - }; - - const createWrapper = (props, slots) => { - wrapper = shallowMountExtended(UserAvatarLink, { - propsData: { - ...defaultProps, - ...props, - ...slots, - }, - }); - }; - - beforeEach(() => { - createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render GlLink with correct props', () => { - const link = wrapper.findComponent(GlAvatarLink); - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe(defaultProps.linkHref); - }); - - it('should render UserAvatarImage and provide correct props to it', () => { - expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true); - expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({ - cssClasses: defaultProps.imgCssClasses, - imgAlt: defaultProps.imgAlt, - imgSrc: defaultProps.imgSrc, - lazy: false, - size: defaultProps.imgSize, - tooltipPlacement: defaultProps.tooltipPlacement, - tooltipText: '', - enforceGlAvatar: false, - }); - }); - - describe('when username provided', () => { - beforeEach(() => { - createWrapper({ username: defaultProps.username }); - }); - - it('should render provided username', () => { - expect(findUserName().text()).toBe(defaultProps.username); - }); - - it('should provide the tooltip data for the username', () => { - expect(findUserName().attributes()).toEqual( - expect.objectContaining({ - title: defaultProps.tooltipText, - 'tooltip-placement': defaultProps.tooltipPlacement, - }), - ); - }); - }); - - describe('when username is NOT provided', () => { - beforeEach(() => { - createWrapper({ username: '' }); - }); - - it('should NOT render username', () => { - expect(findUserName().exists()).toBe(false); - }); - }); - - describe('avatar-badge slot', () => { - const badge = '<span>User badge</span>'; - - beforeEach(() => { - createWrapper(defaultProps, { - 'avatar-badge': badge, - }); - }); - - it('should render provided `avatar-badge` slot content', () => { - expect(wrapper.html()).toContain(badge); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js deleted file mode 100644 index cf7a1025dba..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_old_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { TEST_HOST } from 'spec/test_constants'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link_old.vue'; - -describe('User Avatar Link Component', () => { - let wrapper; - - const findUserName = () => wrapper.find('[data-testid="user-avatar-link-username"]'); - - const defaultProps = { - linkHref: `${TEST_HOST}/myavatarurl.com`, - imgSize: 32, - imgSrc: `${TEST_HOST}/myavatarurl.com`, - imgAlt: 'mydisplayname', - imgCssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', - username: 'username', - }; - - const createWrapper = (props, slots) => { - wrapper = shallowMountExtended(UserAvatarLink, { - propsData: { - ...defaultProps, - ...props, - ...slots, - }, - }); - }; - - beforeEach(() => { - createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should render GlLink with correct props', () => { - const link = wrapper.findComponent(GlLink); - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe(defaultProps.linkHref); - }); - - it('should render UserAvatarImage and povide correct props to it', () => { - expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true); - expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({ - cssClasses: defaultProps.imgCssClasses, - imgAlt: defaultProps.imgAlt, - imgSrc: defaultProps.imgSrc, - lazy: false, - size: defaultProps.imgSize, - tooltipPlacement: defaultProps.tooltipPlacement, - tooltipText: '', - enforceGlAvatar: false, - }); - }); - - describe('when username provided', () => { - beforeEach(() => { - createWrapper({ username: defaultProps.username }); - }); - - it('should render provided username', () => { - expect(findUserName().text()).toBe(defaultProps.username); - }); - - it('should provide the tooltip data for the username', () => { - expect(findUserName().attributes()).toEqual( - expect.objectContaining({ - title: defaultProps.tooltipText, - 'tooltip-placement': defaultProps.tooltipPlacement, - }), - ); - }); - }); - - describe('when username is NOT provided', () => { - beforeEach(() => { - createWrapper({ username: '' }); - }); - - it('should NOT render username', () => { - expect(findUserName().exists()).toBe(false); - }); - }); - - describe('avatar-badge slot', () => { - const badge = '<span>User badge</span>'; - - beforeEach(() => { - createWrapper(defaultProps, { - 'avatar-badge': badge, - }); - }); - - it('should render provided `avatar-badge` slot content', () => { - expect(wrapper.html()).toContain(badge); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js index fd3f59008ec..df7ce449678 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -1,51 +1,102 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import UserAvatarLinkNew from '~/vue_shared/components/user_avatar/user_avatar_link_new.vue'; -import UserAvatarLinkOld from '~/vue_shared/components/user_avatar/user_avatar_link_old.vue'; - -const PROVIDED_PROPS = { - size: 32, - imgSrc: 'myavatarurl.com', - imgAlt: 'mydisplayname', - cssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', -}; describe('User Avatar Link Component', () => { let wrapper; - const createWrapper = (props = {}, { glAvatarForAllUserAvatars } = {}) => { - wrapper = shallowMount(UserAvatarLink, { + const findUserName = () => wrapper.findByTestId('user-avatar-link-username'); + + const defaultProps = { + linkHref: `${TEST_HOST}/myavatarurl.com`, + imgSize: 32, + imgSrc: `${TEST_HOST}/myavatarurl.com`, + imgAlt: 'mydisplayname', + imgCssClasses: 'myextraavatarclass', + tooltipText: 'tooltip text', + tooltipPlacement: 'bottom', + username: 'username', + }; + + const createWrapper = (props, slots) => { + wrapper = shallowMountExtended(UserAvatarLink, { propsData: { - ...PROVIDED_PROPS, + ...defaultProps, ...props, - }, - provide: { - glFeatures: { - glAvatarForAllUserAvatars, - }, + ...slots, }, }); }; + beforeEach(() => { + createWrapper(); + }); + afterEach(() => { wrapper.destroy(); }); - describe.each([ - [false, true, true], - [true, false, true], - [true, true, true], - [false, false, false], - ])( - 'when glAvatarForAllUserAvatars=%s and enforceGlAvatar=%s', - (glAvatarForAllUserAvatars, enforceGlAvatar, isUsingNewVersion) => { - it(`will render ${isUsingNewVersion ? 'new' : 'old'} version`, () => { - createWrapper({ enforceGlAvatar }, { glAvatarForAllUserAvatars }); - expect(wrapper.findComponent(UserAvatarLinkNew).exists()).toBe(isUsingNewVersion); - expect(wrapper.findComponent(UserAvatarLinkOld).exists()).toBe(!isUsingNewVersion); + it('should render GlLink with correct props', () => { + const link = wrapper.findComponent(GlAvatarLink); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(defaultProps.linkHref); + }); + + it('should render UserAvatarImage and provide correct props to it', () => { + expect(wrapper.findComponent(UserAvatarImage).exists()).toBe(true); + expect(wrapper.findComponent(UserAvatarImage).props()).toEqual({ + cssClasses: defaultProps.imgCssClasses, + imgAlt: defaultProps.imgAlt, + imgSrc: defaultProps.imgSrc, + lazy: false, + size: defaultProps.imgSize, + tooltipPlacement: defaultProps.tooltipPlacement, + tooltipText: '', + }); + }); + + describe('when username provided', () => { + beforeEach(() => { + createWrapper({ username: defaultProps.username }); + }); + + it('should render provided username', () => { + expect(findUserName().text()).toBe(defaultProps.username); + }); + + it('should provide the tooltip data for the username', () => { + expect(findUserName().attributes()).toEqual( + expect.objectContaining({ + title: defaultProps.tooltipText, + 'tooltip-placement': defaultProps.tooltipPlacement, + }), + ); + }); + }); + + describe('when username is NOT provided', () => { + beforeEach(() => { + createWrapper({ username: '' }); + }); + + it('should NOT render username', () => { + expect(findUserName().exists()).toBe(false); + }); + }); + + describe('avatar-badge slot', () => { + const badge = '<span>User badge</span>'; + + beforeEach(() => { + createWrapper(defaultProps, { + 'avatar-badge': badge, }); - }, - ); + }); + + it('should render provided `avatar-badge` slot content', () => { + expect(wrapper.html()).toContain(badge); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index b9accbf0373..1ad6d043399 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -153,29 +153,4 @@ describe('UserAvatarList', () => { }); }); }); - - describe('additional styling for the image', () => { - it('should not add CSS class when feature flag `glAvatarForAllUserAvatars` is disabled', () => { - factory({ - propsData: { items: createList(1) }, - }); - - const link = wrapper.findComponent(UserAvatarLink); - expect(link.props('imgCssClasses')).not.toBe('gl-mr-3'); - }); - - it('should add CSS class when feature flag `glAvatarForAllUserAvatars` is enabled', () => { - factory({ - propsData: { items: createList(1) }, - provide: { - glFeatures: { - glAvatarForAllUserAvatars: true, - }, - }, - }); - - const link = wrapper.findComponent(UserAvatarLink); - expect(link.props('imgCssClasses')).toBe('gl-mr-3'); - }); - }); }); 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 6d48000beb0..f6316af6ad8 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 @@ -8,10 +8,12 @@ import { I18N_USER_BLOCKED, I18N_USER_LEARN, I18N_USER_FOLLOW, + I18N_ERROR_FOLLOW, I18N_USER_UNFOLLOW, + I18N_ERROR_UNFOLLOW, } from '~/vue_shared/components/user_popover/constants'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { followUser, unfollowUser } from '~/api/user_api'; import { mockTracking } from 'helpers/tracking_helper'; @@ -239,6 +241,18 @@ describe('User Popover Component', () => { expect(wrapper.html()).toContain('<gl-emoji data-name="basketball_player"'); }); + it('should show only emoji', () => { + const user = { + ...DEFAULT_PROPS.user, + status: { emoji: 'basketball_player' }, + }; + + createWrapper({ user }); + + expect(findUserStatus().exists()).toBe(true); + expect(wrapper.html()).toContain('<gl-emoji data-name="basketball_player"'); + }); + it('hides the div when status is null', () => { const user = { ...DEFAULT_PROPS.user, status: null }; @@ -367,27 +381,49 @@ describe('User Popover Component', () => { itTracksToggleFollowButtonClick('follow_from_user_popover'); describe('when an error occurs', () => { - beforeEach(() => { - followUser.mockRejectedValue({}); + describe('api send error message', () => { + const mockedMessage = sprintf(I18N_ERROR_UNFOLLOW, { limit: 300 }); + const apiResponse = { response: { data: { message: mockedMessage } } }; - findToggleFollowButton().trigger('click'); - }); + beforeEach(() => { + followUser.mockRejectedValue(apiResponse); + findToggleFollowButton().trigger('click'); + }); - it('shows an error message', async () => { - await axios.waitForAll(); + it('show an error message from api response', async () => { + await axios.waitForAll(); - expect(createFlash).toHaveBeenCalledWith({ - message: 'An error occurred while trying to follow this user, please try again.', - error: {}, - captureError: true, + expect(createAlert).toHaveBeenCalledWith({ + message: mockedMessage, + error: apiResponse, + captureError: true, + }); }); }); - it('emits no events', async () => { - await axios.waitForAll(); + describe('api did not send error message', () => { + beforeEach(() => { + followUser.mockRejectedValue({}); - expect(wrapper.emitted().follow).toBeUndefined(); - expect(wrapper.emitted().unfollow).toBeUndefined(); + findToggleFollowButton().trigger('click'); + }); + + it('shows an error message', async () => { + await axios.waitForAll(); + + expect(createAlert).toHaveBeenCalledWith({ + message: I18N_ERROR_FOLLOW, + error: {}, + captureError: true, + }); + }); + + it('emits no events', async () => { + await axios.waitForAll(); + + expect(wrapper.emitted().follow).toBeUndefined(); + expect(wrapper.emitted().unfollow).toBeUndefined(); + }); }); }); }); @@ -425,8 +461,8 @@ describe('User Popover Component', () => { }); it('shows an error message', () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'An error occurred while trying to unfollow this user, please try again.', + expect(createAlert).toHaveBeenCalledWith({ + message: I18N_ERROR_UNFOLLOW, error: {}, captureError: true, }); diff --git a/spec/frontend/vue_shared/directives/safe_html_spec.js b/spec/frontend/vue_shared/directives/safe_html_spec.js new file mode 100644 index 00000000000..ba1de8e4596 --- /dev/null +++ b/spec/frontend/vue_shared/directives/safe_html_spec.js @@ -0,0 +1,116 @@ +import { shallowMount } from '@vue/test-utils'; +import safeHtml from '~/vue_shared/directives/safe_html'; +import { defaultConfig } from '~/lib/dompurify'; +/* eslint-disable no-script-url */ +const invalidProtocolUrls = [ + 'javascript:alert(1)', + 'jAvascript:alert(1)', + 'data:text/html,<script>alert(1);</script>', + ' javascript:', + 'javascript :', +]; +/* eslint-enable no-script-url */ +const validProtocolUrls = ['slack://open', 'x-devonthink-item://90909', 'x-devonthink-item:90909']; + +describe('safe html directive', () => { + let wrapper; + + const createComponent = ({ template, html, config } = {}) => { + const defaultTemplate = `<div v-safe-html="rawHtml"></div>`; + const defaultHtml = 'hello <script>alert(1)</script>world'; + + const component = { + directives: { + safeHtml, + }, + data() { + return { + rawHtml: html || defaultHtml, + config: config || {}, + }; + }, + template: template || defaultTemplate, + }; + + wrapper = shallowMount(component); + }; + + describe('default', () => { + it('should remove the script tag', () => { + createComponent(); + + expect(wrapper.html()).toEqual('<div>hello world</div>'); + }); + + it('should remove javascript hrefs', () => { + createComponent({ html: '<a href="javascript:prompt(1)">click here</a>' }); + + expect(wrapper.html()).toEqual('<div><a>click here</a></div>'); + }); + + it('should remove any existing children', () => { + createComponent({ + template: `<div v-safe-html="rawHtml">foo <i>bar</i></div>`, + }); + + expect(wrapper.html()).toEqual('<div>hello world</div>'); + }); + + describe('with non-http links', () => { + it.each(validProtocolUrls)('should allow %s', (url) => { + createComponent({ + html: `<a href="${url}">internal link</a>`, + }); + expect(wrapper.html()).toContain(`<a href="${url}">internal link</a>`); + }); + + it.each(invalidProtocolUrls)('should not allow %s', (url) => { + createComponent({ + html: `<a href="${url}">internal link</a>`, + }); + expect(wrapper.html()).toContain(`<a>internal link</a>`); + }); + }); + + describe('handles data attributes correctly', () => { + const allowedDataAttrs = ['data-safe', 'data-random']; + + it.each(defaultConfig.FORBID_ATTR)('removes dangerous `%s` attribute', (attr) => { + const html = `<a ${attr}="true"></a>`; + createComponent({ html }); + + expect(wrapper.html()).not.toContain(html); + }); + + it.each(allowedDataAttrs)('does not remove allowed `%s` attribute', (attr) => { + const html = `<a ${attr}="true"></a>`; + createComponent({ html }); + + expect(wrapper.html()).toContain(html); + }); + }); + }); + + describe('advance config', () => { + const template = '<div v-safe-html:[config]="rawHtml"></div>'; + it('should only allow <b> tags', () => { + createComponent({ + template, + html: '<a href="javascript:prompt(1)"><b>click here</b></a>', + config: { ALLOWED_TAGS: ['b'] }, + }); + + expect(wrapper.html()).toEqual('<div><b>click here</b></div>'); + }); + + it('should strip all html tags', () => { + createComponent({ + template, + html: '<a href="javascript:prompt(1)"><u>click here</u></a>', + config: { ALLOWED_TAGS: [] }, + }); + + expect(wrapper.html()).toEqual('<div>click here</div>'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap b/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap new file mode 100644 index 00000000000..dd011b9d84e --- /dev/null +++ b/spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IssuableBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = ` +"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issuable-blocked-icon\\" role=\\"img\\" aria-hidden=\\"true\\" class=\\"issuable-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500 gl-icon s16\\" id=\\"blocked-icon-uniqueId\\"> + <use href=\\"#issue-block\\"></use> + </svg> + <div class=\\"gl-popover\\"> + <ul class=\\"gl-list-style-none gl-p-0 gl-mb-0\\"> + <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/6\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#6</a> + <p data-testid=\\"issuable-title\\" class=\\"gl-display-block! gl-mb-3\\"> + blocking issue title 1 + </p> + </li> + <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/5\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#5</a> + <p data-testid=\\"issuable-title\\" class=\\"gl-display-block! gl-mb-3\\"> + blocking issue title 2 + blocking issue title 2 + blocking issue title 2 + bloc… + </p> + </li> + <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/4\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#4</a> + <p data-testid=\\"issuable-title\\" class=\\"gl-display-block! gl-mb-0\\"> + blocking issue title 3 + </p> + </li> + </ul> + <div class=\\"gl-mt-4\\"> + <p data-testid=\\"hidden-blocking-count\\" class=\\"gl-mb-3\\">+ 1 more issue</p> <a data-testid=\\"view-all-issues\\" href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0#related-issues\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">View all blocking issues</a> + </div><span data-testid=\\"popover-title\\">Blocked by 4 issues</span> + </div> +</div>" +`; diff --git a/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js new file mode 100644 index 00000000000..d59cbce6633 --- /dev/null +++ b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js @@ -0,0 +1,265 @@ +import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import IssuableBlockedIcon from '~/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue'; +import { blockingIssuablesQueries } from '~/vue_shared/components/issuable_blocked_icon/constants'; +import { issuableTypes } from '~/boards/constants'; +import { truncate } from '~/lib/utils/text_utility'; +import { + mockIssue, + mockEpic, + mockBlockingIssue1, + mockBlockingIssue2, + mockBlockingEpic1, + mockBlockingIssuablesResponse1, + mockBlockingIssuablesResponse2, + mockBlockingIssuablesResponse3, + mockBlockedIssue1, + mockBlockedIssue2, + mockBlockedEpic1, + mockBlockingEpicIssuablesResponse1, +} from '../../boards/mock_data'; + +describe('IssuableBlockedIcon', () => { + let wrapper; + let mockApollo; + + const findGlIcon = () => wrapper.findComponent(GlIcon); + const findGlPopover = () => wrapper.findComponent(GlPopover); + const findGlLink = () => wrapper.findComponent(GlLink); + const findPopoverTitle = () => wrapper.findByTestId('popover-title'); + const findIssuableTitle = () => wrapper.findByTestId('issuable-title'); + const findHiddenBlockingCount = () => wrapper.findByTestId('hidden-blocking-count'); + const findViewAllIssuableLink = () => wrapper.findByTestId('view-all-issues'); + + const waitForApollo = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + const mouseenter = async () => { + findGlIcon().vm.$emit('mouseenter'); + + await nextTick(); + await waitForApollo(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const createWrapperWithApollo = ({ + item = mockBlockedIssue1, + blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1), + issuableItem = mockIssue, + issuableType = issuableTypes.issue, + } = {}) => { + mockApollo = createMockApollo([ + [blockingIssuablesQueries[issuableType].query, blockingIssuablesSpy], + ]); + + Vue.use(VueApollo); + wrapper = extendedWrapper( + mount(IssuableBlockedIcon, { + apolloProvider: mockApollo, + propsData: { + item: { + ...issuableItem, + ...item, + }, + uniqueId: 'uniqueId', + issuableType, + }, + attachTo: document.body, + }), + ); + }; + + const createWrapper = ({ + item = {}, + queries = {}, + data = {}, + loading = false, + mockIssuable = mockIssue, + issuableType = issuableTypes.issue, + } = {}) => { + wrapper = extendedWrapper( + shallowMount(IssuableBlockedIcon, { + propsData: { + item: { + ...mockIssuable, + ...item, + }, + uniqueId: 'uniqueid', + issuableType, + }, + data() { + return { + ...data, + }; + }, + mocks: { + $apollo: { + queries: { + blockingIssuables: { loading }, + ...queries, + }, + }, + }, + stubs: { + GlPopover, + }, + attachTo: document.body, + }), + ); + }; + + it.each` + mockIssuable | issuableType | expectedIcon + ${mockIssue} | ${issuableTypes.issue} | ${'issue-block'} + ${mockEpic} | ${issuableTypes.epic} | ${'entity-blocked'} + `( + 'should render blocked icon for $issuableType', + ({ mockIssuable, issuableType, expectedIcon }) => { + createWrapper({ + mockIssuable, + issuableType, + }); + + expect(findGlIcon().exists()).toBe(true); + const icon = findGlIcon(); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe(expectedIcon); + }, + ); + + it('should display a loading spinner while loading', () => { + createWrapper({ loading: true }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('should not query for blocking issuables by default', async () => { + createWrapperWithApollo(); + + expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title); + }); + + describe('on mouseenter on blocked icon', () => { + it.each` + item | issuableType | mockBlockingIssuable | issuableItem | blockingIssuablesSpy + ${mockBlockedIssue1} | ${issuableTypes.issue} | ${mockBlockingIssue1} | ${mockIssue} | ${jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1)} + ${mockBlockedEpic1} | ${issuableTypes.epic} | ${mockBlockingEpic1} | ${mockEpic} | ${jest.fn().mockResolvedValue(mockBlockingEpicIssuablesResponse1)} + `( + 'should query for blocking issuables and render the result for $issuableType', + async ({ item, issuableType, issuableItem, mockBlockingIssuable, blockingIssuablesSpy }) => { + createWrapperWithApollo({ + item, + issuableType, + issuableItem, + blockingIssuablesSpy, + }); + + expect(findGlPopover().text()).not.toContain(mockBlockingIssuable.title); + + await mouseenter(); + + expect(findGlPopover().exists()).toBe(true); + expect(findIssuableTitle().text()).toContain(mockBlockingIssuable.title); + expect(wrapper.vm.skip).toBe(true); + }, + ); + + it('should emit "blocking-issuables-error" event on query error', async () => { + const mockError = new Error('mayday'); + createWrapperWithApollo({ blockingIssuablesSpy: jest.fn().mockRejectedValue(mockError) }); + + await mouseenter(); + + const [ + [ + { + message, + error: { networkError }, + }, + ], + ] = wrapper.emitted('blocking-issuables-error'); + expect(message).toBe('Failed to fetch blocking issues'); + expect(networkError).toBe(mockError); + }); + + describe('with a single blocking issue', () => { + beforeEach(async () => { + createWrapperWithApollo(); + + await mouseenter(); + }); + + it('should render a title of the issuable', async () => { + expect(findIssuableTitle().text()).toBe(mockBlockingIssue1.title); + }); + + it('should render issuable reference and link to the issuable', async () => { + const formattedRef = mockBlockingIssue1.reference.split('/')[1]; + + expect(findGlLink().text()).toBe(formattedRef); + expect(findGlLink().attributes('href')).toBe(mockBlockingIssue1.webUrl); + }); + + it('should render popover title with correct blocking issuable count', async () => { + expect(findPopoverTitle().text()).toBe('Blocked by 1 issue'); + }); + }); + + describe('when issue has a long title', () => { + it('should render a truncated title', async () => { + createWrapperWithApollo({ + blockingIssuablesSpy: jest.fn().mockResolvedValue(mockBlockingIssuablesResponse2), + }); + + await mouseenter(); + + const truncatedTitle = truncate( + mockBlockingIssue2.title, + wrapper.vm.$options.textTruncateWidth, + ); + expect(findIssuableTitle().text()).toBe(truncatedTitle); + }); + }); + + describe('with more than three blocking issues', () => { + beforeEach(async () => { + createWrapperWithApollo({ + item: mockBlockedIssue2, + blockingIssuablesSpy: jest.fn().mockResolvedValue(mockBlockingIssuablesResponse3), + }); + + await mouseenter(); + }); + + it('matches the snapshot', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should render popover title with correct blocking issuable count', async () => { + expect(findPopoverTitle().text()).toBe('Blocked by 4 issues'); + }); + + it('should render the number of hidden blocking issuables', () => { + expect(findHiddenBlockingCount().text()).toBe('+ 1 more issue'); + }); + + it('should link to the blocked issue page at the related issue anchor', async () => { + expect(findViewAllIssuableLink().text()).toBe('View all blocking issues'); + expect(findViewAllIssuableLink().attributes('href')).toBe( + `${mockBlockedIssue2.webUrl}#related-issues`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js index 39a76a51191..6b20f0c77a3 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js @@ -138,7 +138,7 @@ describe('IssuableBody', () => { wrapper.vm.handleTaskListUpdateSuccess(updatedIssuable); - expect(wrapper.emitted('task-list-update-success')).toBeTruthy(); + expect(wrapper.emitted('task-list-update-success')).toHaveLength(1); expect(wrapper.emitted('task-list-update-success')[0]).toEqual([updatedIssuable]); }); }); @@ -147,7 +147,7 @@ describe('IssuableBody', () => { it('emits `task-list-update-failure` event on component', () => { wrapper.vm.handleTaskListUpdateFailure(); - expect(wrapper.emitted('task-list-update-failure')).toBeTruthy(); + expect(wrapper.emitted('task-list-update-failure')).toHaveLength(1); }); }); }); @@ -202,7 +202,7 @@ describe('IssuableBody', () => { issuableTitle.vm.$emit('edit-issuable'); - expect(wrapper.emitted('edit-issuable')).toBeTruthy(); + expect(wrapper.emitted('edit-issuable')).toHaveLength(1); }); it.each(['keydown-title', 'keydown-description'])( @@ -227,7 +227,7 @@ describe('IssuableBody', () => { issuableEditForm.vm.$emit(eventName, eventObj, issuableMeta); - expect(wrapper.emitted(eventName)).toBeTruthy(); + expect(wrapper.emitted(eventName)).toHaveLength(1); expect(wrapper.emitted(eventName)[0]).toMatchObject([eventObj, issuableMeta]); }, ); 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 a9651cf8bac..43ff68e30b5 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 @@ -14,7 +14,7 @@ import { sastDiffSuccessMock, secretDetectionDiffSuccessMock, } from 'jest/vue_shared/security_reports/mock_data'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; @@ -135,8 +135,8 @@ describe('Security reports app', () => { }); }); - it('calls createFlash correctly', () => { - expect(createFlash).toHaveBeenCalledWith({ + it('calls createAlert correctly', () => { + expect(createAlert).toHaveBeenCalledWith({ message: SecurityReportsApp.i18n.apiError, captureError: true, error: expect.any(Error), |