diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-20 13:49:51 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-20 13:49:51 +0000 |
commit | 71786ddc8e28fbd3cb3fcc4b3ff15e5962a1c82e (patch) | |
tree | 6a2d93ef3fb2d353bb7739e4b57e6541f51cdd71 /spec/frontend/vue_shared | |
parent | a7253423e3403b8c08f8a161e5937e1488f5f407 (diff) | |
download | gitlab-ce-71786ddc8e28fbd3cb3fcc4b3ff15e5962a1c82e.tar.gz |
Add latest changes from gitlab-org/gitlab@15-9-stable-eev15.9.0-rc42
Diffstat (limited to 'spec/frontend/vue_shared')
45 files changed, 1846 insertions, 819 deletions
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js index 5a0ee5a59ba..98a357bac2b 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js @@ -3,6 +3,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import SidebarAssignee from '~/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue'; import SidebarAssignees from '~/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue'; import AlertSetAssignees from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; @@ -97,7 +98,7 @@ describe('Alert Details Sidebar Assignees', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(mockPath).replyOnce(200, mockUsers); + mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers); mountComponent({ data: { alert: mockAlert }, sidebarCollapsed: false, @@ -187,7 +188,7 @@ describe('Alert Details Sidebar Assignees', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(mockPath).replyOnce(200, mockUsers); + mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers); mountComponent({ data: { alert: mockAlert }, diff --git a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap index b7b43264330..ad08120fada 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap @@ -9,6 +9,7 @@ exports[`MemoryGraph Render chart should draw container with chart 1`] = ` data="Nov 12 2019 19:17:33,2.87,Nov 12 2019 19:18:33,2.78,Nov 12 2019 19:19:33,2.78,Nov 12 2019 19:20:33,3.01" gradient="" height="25" + smooth="0" tooltiplabel="MB" /> </div> diff --git a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js index e1860d3399b..3f7ec156c19 100644 --- a/spec/frontend/vue_shared/components/confidentiality_badge_spec.js +++ b/spec/frontend/vue_shared/components/confidentiality_badge_spec.js @@ -1,13 +1,13 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { WorkspaceType, IssuableType } from '~/issues/constants'; +import { WorkspaceType, TYPE_ISSUE, TYPE_EPIC } from '~/issues/constants'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; const createComponent = ({ workspaceType = WorkspaceType.project, - issuableType = IssuableType.Issue, + issuableType = TYPE_ISSUE, } = {}) => shallowMount(ConfidentialityBadge, { propsData: { @@ -28,9 +28,9 @@ describe('ConfidentialityBadge', () => { }); it.each` - workspaceType | issuableType | expectedTooltip - ${WorkspaceType.project} | ${IssuableType.Issue} | ${'Only project members with at least the Reporter role, the author, and assignees can view or be notified about this issue.'} - ${WorkspaceType.group} | ${IssuableType.Epic} | ${'Only group members with at least the Reporter role can view or be notified about this epic.'} + workspaceType | issuableType | expectedTooltip + ${WorkspaceType.project} | ${TYPE_ISSUE} | ${'Only project members with at least the Reporter role, the author, and assignees can view or be notified about this issue.'} + ${WorkspaceType.group} | ${TYPE_EPIC} | ${'Only group members with at least the Reporter role can view or be notified about this epic.'} `( 'should render gl-badge with correct tooltip when workspaceType is $workspaceType and issuableType is $issuableType', ({ workspaceType, issuableType, expectedTooltip }) => { diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js index 0d329b6a065..b0c0fc79676 100644 --- a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import MarkdownViewer from '~/vue_shared/components/content_viewer/viewers/markdown_viewer.vue'; jest.mock('~/behaviors/markdown/render_gfm'); @@ -35,9 +36,11 @@ describe('MarkdownViewer', () => { describe('success', () => { beforeEach(() => { - mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, { - body: '<b>testing</b> {{gl_md_img_1}}', - }); + mock + .onPost(`${gon.relative_url_root}/testproject/preview_markdown`) + .replyOnce(HTTP_STATUS_OK, { + body: '<b>testing</b> {{gl_md_img_1}}', + }); }); it('renders a skeleton loader while the markdown is loading', () => { @@ -100,9 +103,11 @@ describe('MarkdownViewer', () => { describe('error', () => { beforeEach(() => { - mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(500, { - body: 'Internal Server Error', - }); + mock + .onPost(`${gon.relative_url_root}/testproject/preview_markdown`) + .replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR, { + body: 'Internal Server Error', + }); }); it('renders an error message if loading the markdown preview fails', () => { createComponent(); diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js index 2c5bb86d8a5..c1495e8264a 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js @@ -56,11 +56,11 @@ describe('DateTimePickerInput', () => { it('input event is emitted when focus is lost', () => { createComponent(); - jest.spyOn(wrapper.vm, '$emit'); + const input = wrapper.find('input'); input.setValue(inputValue); input.trigger('blur'); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', inputValue); + expect(wrapper.emitted('input')[0][0]).toEqual(inputValue); }); }); diff --git a/spec/frontend/vue_shared/components/dismissible_container_spec.js b/spec/frontend/vue_shared/components/dismissible_container_spec.js index f7030f38709..7d8581e11e9 100644 --- a/spec/frontend/vue_shared/components/dismissible_container_spec.js +++ b/spec/frontend/vue_shared/components/dismissible_container_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import dismissibleContainer from '~/vue_shared/components/dismissible_container.vue'; describe('DismissibleContainer', () => { @@ -28,7 +29,7 @@ describe('DismissibleContainer', () => { }); it('successfully dismisses', () => { - mockAxios.onPost(propsData.path).replyOnce(200); + mockAxios.onPost(propsData.path).replyOnce(HTTP_STATUS_OK); const button = findBtn(); button.trigger('click'); diff --git a/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js index c34041f9305..119d6448507 100644 --- a/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js +++ b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js @@ -61,27 +61,8 @@ describe('DropdownKeyboardNavigation', () => { }); describe('keydown events', () => { - let incrementSpy; - beforeEach(() => { createComponent(); - incrementSpy = jest.spyOn(wrapper.vm, 'increment'); - }); - - afterEach(() => { - incrementSpy.mockRestore(); - }); - - it('onKeydown-Down calls increment(1)', () => { - helpers.arrowDown(); - - expect(incrementSpy).toHaveBeenCalledWith(1); - }); - - it('onKeydown-Up calls increment(-1)', () => { - helpers.arrowUp(); - - expect(incrementSpy).toHaveBeenCalledWith(-1); }); it('onKeydown-Tab $emits @tab event', () => { diff --git a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js new file mode 100644 index 00000000000..6b98f6c5e89 --- /dev/null +++ b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js @@ -0,0 +1,268 @@ +import { nextTick } from 'vue'; +import { GlCollapsibleListbox, GlFormGroup } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import EntitySelect from '~/vue_shared/components/entity_select/entity_select.vue'; +import { QUERY_TOO_SHORT_MESSAGE } from '~/vue_shared/components/entity_select/constants'; +import waitForPromises from 'helpers/wait_for_promises'; + +describe('EntitySelect', () => { + let wrapper; + let fetchItemsMock; + let fetchInitialSelectionTextMock; + + // Mocks + const itemMock = { + text: 'selectedGroup', + value: '1', + }; + + // Stubs + const GlAlert = { + template: '<div><slot /></div>', + }; + + // Props + const label = 'label'; + const inputName = 'inputName'; + const inputId = 'inputId'; + const headerText = 'headerText'; + const defaultToggleText = 'defaultToggleText'; + + // Finders + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findInput = () => wrapper.findByTestId('input'); + + // Helpers + const createComponent = ({ props = {}, slots = {}, stubs = {} } = {}) => { + wrapper = shallowMountExtended(EntitySelect, { + propsData: { + label, + inputName, + inputId, + headerText, + defaultToggleText, + fetchItems: fetchItemsMock, + ...props, + }, + stubs: { + GlAlert, + EntitySelect, + ...stubs, + }, + slots, + }); + }; + const openListbox = () => findListbox().vm.$emit('shown'); + const search = (searchString) => findListbox().vm.$emit('search', searchString); + const selectGroup = async () => { + openListbox(); + await nextTick(); + findListbox().vm.$emit('select', itemMock.value); + return nextTick(); + }; + + beforeEach(() => { + fetchItemsMock = jest.fn().mockImplementation(() => ({ items: [itemMock], totalPages: 1 })); + }); + + describe('on mount', () => { + it('calls the fetch function when the listbox is opened', async () => { + createComponent(); + openListbox(); + await nextTick(); + + expect(fetchItemsMock).toHaveBeenCalledTimes(1); + }); + + it("fetches the initially selected value's name", async () => { + fetchInitialSelectionTextMock = jest.fn().mockImplementation(() => itemMock.text); + createComponent({ + props: { + fetchInitialSelectionText: fetchInitialSelectionTextMock, + initialSelection: itemMock.value, + }, + }); + await nextTick(); + + expect(fetchInitialSelectionTextMock).toHaveBeenCalledTimes(1); + expect(findListbox().props('toggleText')).toBe(itemMock.text); + }); + }); + + it("renders the error slot's content", () => { + const selector = 'data-test-id="error-element"'; + createComponent({ + slots: { + error: `<div ${selector} />`, + }, + }); + + expect(wrapper.find(`[${selector}]`).exists()).toBe(true); + }); + + it('renders the label slot if provided', () => { + const testid = 'label-slot'; + createComponent({ + slots: { + label: `<div data-testid="${testid}" />`, + }, + stubs: { + GlFormGroup, + }, + }); + + expect(wrapper.findByTestId(testid).exists()).toBe(true); + }); + + describe('selection', () => { + it('uses the default toggle text while no group is selected', () => { + createComponent(); + + expect(findListbox().props('toggleText')).toBe(defaultToggleText); + }); + + describe('once a group is selected', () => { + it(`uses the selected group's name as the toggle text`, async () => { + createComponent(); + await selectGroup(); + + expect(findListbox().props('toggleText')).toBe(itemMock.text); + }); + + it(`uses the selected group's ID as the listbox' and input value`, async () => { + createComponent(); + await selectGroup(); + + expect(findListbox().attributes('selected')).toBe(itemMock.value); + expect(findInput().attributes('value')).toBe(itemMock.value); + }); + + it(`on reset, falls back to the default toggle text`, async () => { + createComponent(); + await selectGroup(); + + findListbox().vm.$emit('reset'); + await nextTick(); + + expect(findListbox().props('toggleText')).toBe(defaultToggleText); + }); + }); + }); + + describe('search', () => { + it('sets `searching` to `true` when first opening the dropdown', async () => { + createComponent(); + + expect(findListbox().props('searching')).toBe(false); + + openListbox(); + await nextTick(); + + expect(findListbox().props('searching')).toBe(true); + }); + + it('sets `searching` to `true` while searching', async () => { + createComponent(); + + expect(findListbox().props('searching')).toBe(false); + + search('foo'); + await nextTick(); + + expect(findListbox().props('searching')).toBe(true); + }); + + it('fetches groups matching the search string', async () => { + const searchString = 'searchString'; + createComponent(); + openListbox(); + + expect(fetchItemsMock).toHaveBeenCalledTimes(1); + + fetchItemsMock.mockImplementation(() => ({ items: [], totalPages: 1 })); + search(searchString); + await nextTick(); + + expect(fetchItemsMock).toHaveBeenCalledTimes(2); + }); + + it('shows a notice if the search query is too short', async () => { + const searchString = 'a'; + createComponent(); + openListbox(); + search(searchString); + await nextTick(); + + expect(fetchItemsMock).toHaveBeenCalledTimes(1); + expect(findListbox().props('noResultsText')).toBe(QUERY_TOO_SHORT_MESSAGE); + }); + }); + + describe('pagination', () => { + const searchString = 'searchString'; + + beforeEach(async () => { + let requestCount = 0; + fetchItemsMock.mockImplementation((searchQuery, page) => { + requestCount += 1; + return { + items: [ + { + text: `Group [page: ${page} - search: ${searchQuery}]`, + value: `id:${requestCount}`, + }, + ], + totalPages: 3, + }; + }); + createComponent(); + openListbox(); + findListbox().vm.$emit('bottom-reached'); + return nextTick(); + }); + + it('fetches the next page when bottom is reached', () => { + expect(fetchItemsMock).toHaveBeenCalledTimes(2); + expect(fetchItemsMock).toHaveBeenLastCalledWith('', 2); + }); + + it('fetches the first page when the search query changes', async () => { + search(searchString); + await nextTick(); + + expect(fetchItemsMock).toHaveBeenCalledTimes(3); + expect(fetchItemsMock).toHaveBeenLastCalledWith(searchString, 1); + }); + + it('retains the search query when infinite scrolling', async () => { + search(searchString); + await nextTick(); + findListbox().vm.$emit('bottom-reached'); + await nextTick(); + + expect(fetchItemsMock).toHaveBeenCalledTimes(4); + expect(fetchItemsMock).toHaveBeenLastCalledWith(searchString, 2); + }); + + it('pauses infinite scroll after fetching the last page', async () => { + expect(findListbox().props('infiniteScroll')).toBe(true); + + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(false); + }); + + it('resumes infinite scroll when search query changes', async () => { + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(false); + + search(searchString); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/entity_select/group_select_spec.js b/spec/frontend/vue_shared/components/entity_select/group_select_spec.js new file mode 100644 index 00000000000..83560e367ea --- /dev/null +++ b/spec/frontend/vue_shared/components/entity_select/group_select_spec.js @@ -0,0 +1,135 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import GroupSelect from '~/vue_shared/components/entity_select/group_select.vue'; +import EntitySelect from '~/vue_shared/components/entity_select/entity_select.vue'; +import { + GROUP_TOGGLE_TEXT, + GROUP_HEADER_TEXT, + FETCH_GROUPS_ERROR, + FETCH_GROUP_ERROR, +} from '~/vue_shared/components/entity_select/constants'; +import waitForPromises from 'helpers/wait_for_promises'; + +describe('GroupSelect', () => { + let wrapper; + let mock; + + // Mocks + const groupMock = { + full_name: 'selectedGroup', + id: '1', + }; + const groupEndpoint = `/api/undefined/groups/${groupMock.id}`; + + // Stubs + const GlAlert = { + template: '<div><slot /></div>', + }; + + // Props + const label = 'label'; + const inputName = 'inputName'; + const inputId = 'inputId'; + + // Finders + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findEntitySelect = () => wrapper.findComponent(EntitySelect); + const findAlert = () => wrapper.findComponent(GlAlert); + + // Helpers + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(GroupSelect, { + propsData: { + label, + inputName, + inputId, + ...props, + }, + stubs: { + GlAlert, + EntitySelect, + }, + }); + }; + const openListbox = () => findListbox().vm.$emit('shown'); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('entity_select props', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + prop | expectedValue + ${'label'} | ${label} + ${'inputName'} | ${inputName} + ${'inputId'} | ${inputId} + ${'defaultToggleText'} | ${GROUP_TOGGLE_TEXT} + ${'headerText'} | ${GROUP_HEADER_TEXT} + `('passes the $prop prop to entity-select', ({ prop, expectedValue }) => { + expect(findEntitySelect().props(prop)).toBe(expectedValue); + }); + }); + + describe('on mount', () => { + it('fetches groups when the listbox is opened', async () => { + createComponent(); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(0); + + openListbox(); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(1); + }); + + describe('with an initial selection', () => { + it("fetches the initially selected value's name", async () => { + mock.onGet(groupEndpoint).reply(HTTP_STATUS_OK, groupMock); + createComponent({ props: { initialSelection: groupMock.id } }); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(1); + expect(findListbox().props('toggleText')).toBe(groupMock.full_name); + }); + + it('show an error if fetching the individual group fails', async () => { + mock + .onGet('/api/undefined/groups.json') + .reply(HTTP_STATUS_OK, [{ full_name: 'notTheSelectedGroup', id: '2' }]); + mock.onGet(groupEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + createComponent({ props: { initialSelection: groupMock.id } }); + + expect(findAlert().exists()).toBe(false); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(FETCH_GROUP_ERROR); + }); + }); + }); + + it('shows an error when fetching groups fails', async () => { + mock.onGet('/api/undefined/groups.json').reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + createComponent(); + openListbox(); + expect(findAlert().exists()).toBe(false); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(FETCH_GROUPS_ERROR); + }); +}); diff --git a/spec/frontend/vue_shared/components/entity_select/project_select_spec.js b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js new file mode 100644 index 00000000000..57dce032d30 --- /dev/null +++ b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js @@ -0,0 +1,248 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; +import ProjectSelect from '~/vue_shared/components/entity_select/project_select.vue'; +import EntitySelect from '~/vue_shared/components/entity_select/entity_select.vue'; +import { + PROJECT_TOGGLE_TEXT, + PROJECT_HEADER_TEXT, + FETCH_PROJECTS_ERROR, + FETCH_PROJECT_ERROR, +} from '~/vue_shared/components/entity_select/constants'; +import waitForPromises from 'helpers/wait_for_promises'; + +describe('ProjectSelect', () => { + let wrapper; + let mock; + + // Stubs + const GlAlert = { + template: '<div><slot /></div>', + }; + + // Props + const label = 'label'; + const inputName = 'inputName'; + const inputId = 'inputId'; + const groupId = '22'; + const userId = '1'; + + // Mocks + const apiVersion = 'v4'; + const projectMock = { + name_with_namespace: 'selectedProject', + id: '1', + }; + const projectsEndpoint = `/api/${apiVersion}/projects.json`; + const groupProjectEndpoint = `/api/${apiVersion}/groups/${groupId}/projects.json`; + const userProjectEndpoint = `/api/${apiVersion}/users/${userId}/projects`; + const projectEndpoint = `/api/${apiVersion}/projects/${projectMock.id}`; + + // Finders + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findEntitySelect = () => wrapper.findComponent(EntitySelect); + const findAlert = () => wrapper.findComponent(GlAlert); + + // Helpers + const createComponent = ({ props = {} } = {}) => { + wrapper = mountExtended(ProjectSelect, { + propsData: { + label, + inputName, + inputId, + groupId, + ...props, + }, + stubs: { + GlAlert, + EntitySelect, + }, + }); + }; + const openListbox = () => findListbox().vm.$emit('shown'); + + beforeAll(() => { + gon.api_version = apiVersion; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('renders HTML label when hasHtmlLabel is true', () => { + const testid = 'html-label'; + createComponent({ + props: { + label: `<div data-testid="${testid}" />`, + hasHtmlLabel: true, + }, + }); + + expect(wrapper.findByTestId(testid).exists()).toBe(true); + }); + + describe('entity_select props', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + prop | expectedValue + ${'label'} | ${label} + ${'inputName'} | ${inputName} + ${'inputId'} | ${inputId} + ${'defaultToggleText'} | ${PROJECT_TOGGLE_TEXT} + ${'headerText'} | ${PROJECT_HEADER_TEXT} + ${'clearable'} | ${true} + `('passes the $prop prop to entity-select', ({ prop, expectedValue }) => { + expect(findEntitySelect().props(prop)).toBe(expectedValue); + }); + }); + + describe('on mount', () => { + it('fetches projects when the listbox is opened', async () => { + createComponent(); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(0); + + openListbox(); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0].url).toBe(groupProjectEndpoint); + expect(mock.history.get[0].params).toEqual({ + include_subgroups: false, + order_by: 'similarity', + per_page: 20, + search: '', + simple: true, + with_shared: true, + }); + }); + + it('includes projects from subgroups if includeSubgroups is true', async () => { + createComponent({ + props: { + includeSubgroups: true, + }, + }); + openListbox(); + await waitForPromises(); + + expect(mock.history.get[0].params.include_subgroups).toBe(true); + }); + + it('fetches projects globally if no group ID is provided', async () => { + createComponent({ + props: { + groupId: null, + }, + }); + openListbox(); + await waitForPromises(); + + expect(mock.history.get[0].url).toBe(projectsEndpoint); + expect(mock.history.get[0].params).toEqual({ + membership: false, + order_by: 'similarity', + per_page: 20, + search: '', + simple: true, + }); + }); + + it('restricts search to owned projects if membership is true', async () => { + createComponent({ + props: { + groupId: null, + membership: true, + }, + }); + openListbox(); + await waitForPromises(); + + expect(mock.history.get[0].params.membership).toBe(true); + }); + + it("fetches the user's projects if a user ID is provided", async () => { + createComponent({ + props: { + groupId: null, + userId, + }, + }); + openListbox(); + await waitForPromises(); + + expect(mock.history.get[0].url).toBe(userProjectEndpoint); + expect(mock.history.get[0].params).toEqual({ + per_page: 20, + search: '', + with_shared: true, + include_subgroups: false, + }); + }); + + it.each([null, groupId])( + 'fetches with the provided sort key when groupId is %s', + async (groupIdProp) => { + const orderBy = 'last_activity_at'; + createComponent({ + props: { + groupId: groupIdProp, + orderBy, + }, + }); + openListbox(); + await waitForPromises(); + + expect(mock.history.get[0].params.order_by).toBe(orderBy); + }, + ); + + describe('with an initial selection', () => { + it("fetches the initially selected value's name", async () => { + mock.onGet(projectEndpoint).reply(HTTP_STATUS_OK, projectMock); + createComponent({ props: { initialSelection: projectMock.id } }); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(1); + expect(findListbox().props('toggleText')).toBe(projectMock.name_with_namespace); + }); + + it('show an error if fetching the individual project fails', async () => { + mock + .onGet(groupProjectEndpoint) + .reply(HTTP_STATUS_OK, [{ full_name: 'notTheSelectedProject', id: '2' }]); + mock.onGet(projectEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + createComponent({ props: { initialSelection: projectMock.id } }); + + expect(findAlert().exists()).toBe(false); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(FETCH_PROJECT_ERROR); + }); + }); + }); + + it('shows an error when fetching projects fails', async () => { + mock.onGet(groupProjectEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + createComponent(); + openListbox(); + expect(findAlert().exists()).toBe(false); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(FETCH_PROJECTS_ERROR); + }); +}); diff --git a/spec/frontend/vue_shared/components/group_select/utils_spec.js b/spec/frontend/vue_shared/components/entity_select/utils_spec.js index 5188e1aabf1..9aa1baf204e 100644 --- a/spec/frontend/vue_shared/components/group_select/utils_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/utils_spec.js @@ -1,6 +1,6 @@ -import { groupsPath } from '~/vue_shared/components/group_select/utils'; +import { groupsPath } from '~/vue_shared/components/entity_select/utils'; -describe('group_select utils', () => { +describe('entity_select utils', () => { describe('groupsPath', () => { it.each` groupsFilter | parentGroupID | expectedPath diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js index 3f4bfc86b67..0fcc0678c13 100644 --- a/spec/frontend/vue_shared/components/file_icon_spec.js +++ b/spec/frontend/vue_shared/components/file_icon_spec.js @@ -8,10 +8,7 @@ describe('File Icon component', () => { const findSvgIcon = () => wrapper.find('svg'); const findGlIcon = () => wrapper.findComponent(GlIcon); const getIconName = () => - findSvgIcon() - .find('use') - .element.getAttribute('xlink:href') - .replace(`${gon.sprite_file_icons}#`, ''); + findSvgIcon().find('use').element.getAttribute('href').replace(`${gon.sprite_file_icons}#`, ''); const createComponent = (props = {}) => { wrapper = shallowMount(FileIcon, { diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index c3a71d7fda3..b70d4565f56 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -100,15 +100,6 @@ describe('File row component', () => { }); }); - it('indents row based on level', () => { - createComponent({ - file: file('t4'), - level: 2, - }); - - expect(wrapper.find('.file-row-name').element.style.marginLeft).toBe('16px'); - }); - it('renders header for file', () => { createComponent({ file: { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js index 66c6267027b..305f56255a5 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js @@ -1,6 +1,7 @@ import { get } from 'lodash'; import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; import mutations from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutations'; import initialState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state'; @@ -16,7 +17,6 @@ const labels = filterLabels.map(convertObjectPropsToCamelCase); const filterValue = { value: 'foo' }; describe('Filters mutations', () => { - const errorCode = 500; beforeEach(() => { state = initialState(); }); @@ -79,35 +79,35 @@ describe('Filters mutations', () => { ${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'errorCode'} | ${null} ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'isLoading'} | ${false} ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'data'} | ${[]} - ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'errorCode'} | ${errorCode} + ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'errorCode'} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} ${types.REQUEST_MILESTONES} | ${'milestones'} | ${'isLoading'} | ${true} ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'isLoading'} | ${false} ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'data'} | ${milestones} ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'errorCode'} | ${null} ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'isLoading'} | ${false} ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'data'} | ${[]} - ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'errorCode'} | ${errorCode} + ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'errorCode'} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} ${types.REQUEST_AUTHORS} | ${'authors'} | ${'isLoading'} | ${true} ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'isLoading'} | ${false} ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'data'} | ${users} ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'errorCode'} | ${null} ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'isLoading'} | ${false} ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'data'} | ${[]} - ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'errorCode'} | ${errorCode} + ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'errorCode'} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} ${types.REQUEST_LABELS} | ${'labels'} | ${'isLoading'} | ${true} ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'isLoading'} | ${false} ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'data'} | ${labels} ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'errorCode'} | ${null} ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'isLoading'} | ${false} ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'data'} | ${[]} - ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'errorCode'} | ${errorCode} + ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'errorCode'} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} ${types.REQUEST_ASSIGNEES} | ${'assignees'} | ${'isLoading'} | ${true} ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'isLoading'} | ${false} ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'data'} | ${users} ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'errorCode'} | ${null} ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'isLoading'} | ${false} ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'data'} | ${[]} - ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'errorCode'} | ${errorCode} + ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'errorCode'} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} `('$mutation will set $stateKey with a given value', ({ mutation, rootKey, stateKey, value }) => { mutations[mutation](state, value); diff --git a/spec/frontend/vue_shared/components/group_select/group_select_spec.js b/spec/frontend/vue_shared/components/group_select/group_select_spec.js deleted file mode 100644 index 87dd7795b98..00000000000 --- a/spec/frontend/vue_shared/components/group_select/group_select_spec.js +++ /dev/null @@ -1,322 +0,0 @@ -import { nextTick } from 'vue'; -import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import axios from '~/lib/utils/axios_utils'; -import GroupSelect from '~/vue_shared/components/group_select/group_select.vue'; -import { - TOGGLE_TEXT, - RESET_LABEL, - FETCH_GROUPS_ERROR, - FETCH_GROUP_ERROR, - QUERY_TOO_SHORT_MESSAGE, -} from '~/vue_shared/components/group_select/constants'; -import waitForPromises from 'helpers/wait_for_promises'; - -describe('GroupSelect', () => { - let wrapper; - let mock; - - // Mocks - const groupMock = { - full_name: 'selectedGroup', - id: '1', - }; - const groupEndpoint = `/api/undefined/groups/${groupMock.id}`; - - // Stubs - const GlAlert = { - template: '<div><slot /></div>', - }; - - // Props - const label = 'label'; - const inputName = 'inputName'; - const inputId = 'inputId'; - - // Finders - const findFormGroup = () => wrapper.findComponent(GlFormGroup); - const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); - const findInput = () => wrapper.findByTestId('input'); - const findAlert = () => wrapper.findComponent(GlAlert); - - // Helpers - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMountExtended(GroupSelect, { - propsData: { - label, - inputName, - inputId, - ...props, - }, - stubs: { - GlAlert, - }, - }); - }; - const openListbox = () => findListbox().vm.$emit('shown'); - const search = (searchString) => findListbox().vm.$emit('search', searchString); - const createComponentWithGroups = () => { - mock.onGet('/api/undefined/groups.json').reply(200, [groupMock]); - createComponent(); - openListbox(); - return waitForPromises(); - }; - const selectGroup = () => { - findListbox().vm.$emit('select', groupMock.id); - return nextTick(); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it('passes the label to GlFormGroup', () => { - createComponent(); - - expect(findFormGroup().attributes('label')).toBe(label); - }); - - describe('on mount', () => { - it('fetches groups when the listbox is opened', async () => { - createComponent(); - await waitForPromises(); - - expect(mock.history.get).toHaveLength(0); - - openListbox(); - await waitForPromises(); - - expect(mock.history.get).toHaveLength(1); - }); - - describe('with an initial selection', () => { - it('if the selected group is not part of the fetched list, fetches it individually', async () => { - mock.onGet(groupEndpoint).reply(200, groupMock); - createComponent({ props: { initialSelection: groupMock.id } }); - await waitForPromises(); - - expect(mock.history.get).toHaveLength(1); - expect(findListbox().props('toggleText')).toBe(groupMock.full_name); - }); - - it('show an error if fetching the individual group fails', async () => { - mock - .onGet('/api/undefined/groups.json') - .reply(200, [{ full_name: 'notTheSelectedGroup', id: '2' }]); - mock.onGet(groupEndpoint).reply(500); - createComponent({ props: { initialSelection: groupMock.id } }); - - expect(findAlert().exists()).toBe(false); - - await waitForPromises(); - - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(FETCH_GROUP_ERROR); - }); - }); - }); - - it('shows an error when fetching groups fails', async () => { - mock.onGet('/api/undefined/groups.json').reply(500); - createComponent(); - openListbox(); - expect(findAlert().exists()).toBe(false); - - await waitForPromises(); - - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(FETCH_GROUPS_ERROR); - }); - - describe('selection', () => { - it('uses the default toggle text while no group is selected', async () => { - await createComponentWithGroups(); - - expect(findListbox().props('toggleText')).toBe(TOGGLE_TEXT); - }); - - describe('once a group is selected', () => { - it(`uses the selected group's name as the toggle text`, async () => { - await createComponentWithGroups(); - await selectGroup(); - - expect(findListbox().props('toggleText')).toBe(groupMock.full_name); - }); - - it(`uses the selected group's ID as the listbox' and input value`, async () => { - await createComponentWithGroups(); - await selectGroup(); - - expect(findListbox().attributes('selected')).toBe(groupMock.id); - expect(findInput().attributes('value')).toBe(groupMock.id); - }); - - it(`on reset, falls back to the default toggle text`, async () => { - await createComponentWithGroups(); - await selectGroup(); - - findListbox().vm.$emit('reset'); - await nextTick(); - - expect(findListbox().props('toggleText')).toBe(TOGGLE_TEXT); - }); - }); - }); - - describe('search', () => { - it('sets `searching` to `true` when first opening the dropdown', async () => { - createComponent(); - - expect(findListbox().props('searching')).toBe(false); - - openListbox(); - await nextTick(); - - expect(findListbox().props('searching')).toBe(true); - }); - - it('sets `searching` to `true` while searching', async () => { - await createComponentWithGroups(); - - expect(findListbox().props('searching')).toBe(false); - - search('foo'); - await nextTick(); - - expect(findListbox().props('searching')).toBe(true); - }); - - it('fetches groups matching the search string', async () => { - const searchString = 'searchString'; - await createComponentWithGroups(); - - expect(mock.history.get).toHaveLength(1); - - search(searchString); - await waitForPromises(); - - expect(mock.history.get).toHaveLength(2); - expect(mock.history.get[1].params).toStrictEqual({ - page: 1, - per_page: 20, - search: searchString, - }); - }); - - it('shows a notice if the search query is too short', async () => { - const searchString = 'a'; - await createComponentWithGroups(); - search(searchString); - await waitForPromises(); - - expect(mock.history.get).toHaveLength(1); - expect(findListbox().props('noResultsText')).toBe(QUERY_TOO_SHORT_MESSAGE); - }); - }); - - describe('pagination', () => { - const searchString = 'searchString'; - - beforeEach(async () => { - let requestCount = 0; - mock.onGet('/api/undefined/groups.json').reply(({ params }) => { - requestCount += 1; - return [ - 200, - [ - { - full_name: `Group [page: ${params.page} - search: ${params.search}]`, - id: requestCount, - }, - ], - { - page: params.page, - 'x-total-pages': 3, - }, - ]; - }); - createComponent(); - openListbox(); - findListbox().vm.$emit('bottom-reached'); - return waitForPromises(); - }); - - it('fetches the next page when bottom is reached', async () => { - expect(mock.history.get).toHaveLength(2); - expect(mock.history.get[1].params).toStrictEqual({ - page: 2, - per_page: 20, - search: '', - }); - }); - - it('fetches the first page when the search query changes', async () => { - search(searchString); - await waitForPromises(); - - expect(mock.history.get).toHaveLength(3); - expect(mock.history.get[2].params).toStrictEqual({ - page: 1, - per_page: 20, - search: searchString, - }); - }); - - it('retains the search query when infinite scrolling', async () => { - search(searchString); - await waitForPromises(); - findListbox().vm.$emit('bottom-reached'); - await waitForPromises(); - - expect(mock.history.get).toHaveLength(4); - expect(mock.history.get[3].params).toStrictEqual({ - page: 2, - per_page: 20, - search: searchString, - }); - }); - - it('pauses infinite scroll after fetching the last page', async () => { - expect(findListbox().props('infiniteScroll')).toBe(true); - - findListbox().vm.$emit('bottom-reached'); - await waitForPromises(); - - expect(findListbox().props('infiniteScroll')).toBe(false); - }); - - it('resumes infinite scroll when search query changes', async () => { - findListbox().vm.$emit('bottom-reached'); - await waitForPromises(); - - expect(findListbox().props('infiniteScroll')).toBe(false); - - search(searchString); - await waitForPromises(); - - expect(findListbox().props('infiniteScroll')).toBe(true); - }); - }); - - it.each` - description | clearable | expectedLabel - ${'passes'} | ${true} | ${RESET_LABEL} - ${'does not pass'} | ${false} | ${''} - `( - '$description the reset button label to the listbox when clearable is $clearable', - ({ clearable, expectedLabel }) => { - createComponent({ - props: { - clearable, - }, - }); - - expect(findListbox().props('resetButtonLabel')).toBe(expectedLabel); - }, - ); -}); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js index 94e1ece8c6b..458f2cc5374 100644 --- a/spec/frontend/vue_shared/components/header_ci_component_spec.js +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlAvatarLink, GlTooltip } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import CiIconBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import HeaderCi from '~/vue_shared/components/header_ci_component.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -28,7 +28,7 @@ describe('Header CI Component', () => { hasSidebarButton: true, }; - const findIconBadge = () => wrapper.findComponent(CiIconBadge); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findTimeAgo = () => wrapper.findComponent(TimeagoTooltip); const findUserLink = () => wrapper.findComponent(GlAvatarLink); const findSidebarToggleBtn = () => wrapper.findComponent(GlButton); @@ -59,7 +59,7 @@ describe('Header CI Component', () => { }); it('should render status badge', () => { - expect(findIconBadge().exists()).toBe(true); + expect(findCiBadgeLink().exists()).toBe(true); }); it('should render timeago date', () => { diff --git a/spec/frontend/vue_shared/components/incubation/incubation_alert_spec.js b/spec/frontend/vue_shared/components/incubation/incubation_alert_spec.js new file mode 100644 index 00000000000..1783538beb3 --- /dev/null +++ b/spec/frontend/vue_shared/components/incubation/incubation_alert_spec.js @@ -0,0 +1,34 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlButton } from '@gitlab/ui'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; + +describe('IncubationAlert', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + wrapper = mount(IncubationAlert, { + propsData: { + featureName: 'some feature', + linkToFeedbackIssue: 'some_link', + }, + }); + }); + + it('displays the feature name in the title', () => { + expect(wrapper.html()).toContain('some feature is in incubating phase'); + }); + + it('displays link to issue', () => { + expect(findButton().attributes().href).toBe('some_link'); + }); + + it('is removed if dismissed', async () => { + await wrapper.find('[aria-label="Dismiss"]').trigger('click'); + + expect(findAlert().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/vue_shared/components/incubation/pagination_spec.js b/spec/frontend/vue_shared/components/incubation/pagination_spec.js new file mode 100644 index 00000000000..a621e60c627 --- /dev/null +++ b/spec/frontend/vue_shared/components/incubation/pagination_spec.js @@ -0,0 +1,76 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import Pagination from '~/vue_shared/components/incubation/pagination.vue'; + +describe('~/vue_shared/incubation/components/pagination.vue', () => { + let wrapper; + + const pageInfo = { + startCursor: 'eyJpZCI6IjE2In0', + endCursor: 'eyJpZCI6IjIifQ', + hasNextPage: true, + hasPreviousPage: true, + }; + + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + const createWrapper = (pageInfoProp) => { + wrapper = mountExtended(Pagination, { + propsData: pageInfoProp, + }); + }; + + describe('when neither next nor previous page exists', () => { + beforeEach(() => { + const emptyPageInfo = { ...pageInfo, hasPreviousPage: false, hasNextPage: false }; + + createWrapper(emptyPageInfo); + }); + + it('should not render pagination component', () => { + expect(wrapper.html()).toBe(''); + }); + }); + + describe('when Pagination is rendered for environment details page', () => { + beforeEach(() => { + createWrapper(pageInfo); + }); + + it('should pass correct props to keyset pagination', () => { + expect(findPagination().exists()).toBe(true); + expect(findPagination().props()).toEqual(expect.objectContaining(pageInfo)); + }); + + describe.each([ + { + testPageInfo: pageInfo, + expectedAfter: `cursor=${pageInfo.endCursor}`, + expectedBefore: `cursor=${pageInfo.startCursor}`, + }, + { + testPageInfo: { ...pageInfo, hasNextPage: true, hasPreviousPage: false }, + expectedAfter: `cursor=${pageInfo.endCursor}`, + expectedBefore: '', + }, + { + testPageInfo: { ...pageInfo, hasNextPage: false, hasPreviousPage: true }, + expectedAfter: '', + expectedBefore: `cursor=${pageInfo.startCursor}`, + }, + ])( + 'button links generation for $testPageInfo', + ({ testPageInfo, expectedAfter, expectedBefore }) => { + beforeEach(() => { + createWrapper(testPageInfo); + }); + + it(`should have button links defined as ${expectedAfter || 'empty'} and + ${expectedBefore || 'empty'}`, () => { + expect(findPagination().props().prevButtonLink).toContain(expectedBefore); + expect(findPagination().props().nextButtonLink).toContain(expectedAfter); + }); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 3b8e78bbadd..68ce07f86b9 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -1,11 +1,13 @@ +import $ from 'jquery'; import { nextTick } from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownFieldHeader from '~/vue_shared/components/markdown/header.vue'; import MarkdownToolbar from '~/vue_shared/components/markdown/toolbar.vue'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; jest.mock('~/behaviors/markdown/render_gfm'); @@ -74,6 +76,22 @@ describe('Markdown field component', () => { ); } + function createWrapper({ autocompleteDataSources = {} } = {}) { + subject = shallowMountExtended(MarkdownField, { + propsData: { + markdownDocsPath, + markdownPreviewPath, + isSubmitting: false, + textareaValue, + lines: [], + enablePreview: true, + restrictedToolBarItems, + showContentEditorSwitcher: false, + autocompleteDataSources, + }, + }); + } + const getPreviewLink = () => subject.findByTestId('preview-tab'); const getWriteLink = () => subject.findByTestId('write-tab'); const getMarkdownButton = () => subject.find('.js-md'); @@ -84,6 +102,7 @@ describe('Markdown field component', () => { const findDropzone = () => subject.find('.div-dropzone'); const findMarkdownHeader = () => subject.findComponent(MarkdownFieldHeader); const findMarkdownToolbar = () => subject.findComponent(MarkdownToolbar); + const findGlForm = () => $(subject.vm.$refs['gl-form']).data('glForm'); describe('mounted', () => { const previewHTML = ` @@ -100,6 +119,18 @@ describe('Markdown field component', () => { findDropzone().element.addEventListener('click', dropzoneSpy); }); + describe('GlForm', () => { + beforeEach(() => { + createWrapper({ autocompleteDataSources: { commands: '/foobar/-/autocomplete_sources' } }); + }); + + it('initializes GlForm with autocomplete data sources', () => { + expect(findGlForm().autoComplete.dataSources).toMatchObject({ + commands: '/foobar/-/autocomplete_sources', + }); + }); + }); + it('renders textarea inside backdrop', () => { expect(subject.find('.zen-backdrop textarea').element).not.toBeNull(); }); @@ -107,7 +138,7 @@ describe('Markdown field component', () => { it('renders referenced commands on markdown preview', async () => { axiosMock .onPost(markdownPreviewPath) - .reply(200, { references: { users: [], commands: 'test command' } }); + .reply(HTTP_STATUS_OK, { references: { users: [], commands: 'test command' } }); previewLink = getPreviewLink(); previewLink.vm.$emit('click', { target: {} }); @@ -121,7 +152,7 @@ describe('Markdown field component', () => { describe('markdown preview', () => { beforeEach(() => { - axiosMock.onPost(markdownPreviewPath).reply(200, { body: previewHTML }); + axiosMock.onPost(markdownPreviewPath).reply(HTTP_STATUS_OK, { body: previewHTML }); }); it('sets preview link as active', async () => { @@ -267,7 +298,7 @@ describe('Markdown field component', () => { const users = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => `user_${i}`); it('shows warning on mention of all users', async () => { - axiosMock.onPost(markdownPreviewPath).reply(200, { references: { users } }); + axiosMock.onPost(markdownPreviewPath).reply(HTTP_STATUS_OK, { references: { users } }); subject.setProps({ textareaValue: 'hello @all' }); @@ -279,7 +310,7 @@ describe('Markdown field component', () => { }); it('removes warning when all mention is removed', async () => { - axiosMock.onPost(markdownPreviewPath).reply(200, { references: { users } }); + axiosMock.onPost(markdownPreviewPath).reply(HTTP_STATUS_OK, { references: { users } }); subject.setProps({ textareaValue: 'hello @all' }); @@ -298,7 +329,7 @@ describe('Markdown field component', () => { }); it('removes warning when all mention is removed while endpoint is loading', async () => { - axiosMock.onPost(markdownPreviewPath).reply(200, { references: { users } }); + axiosMock.onPost(markdownPreviewPath).reply(HTTP_STATUS_OK, { references: { users } }); jest.spyOn(axios, 'post'); subject.setProps({ textareaValue: 'hello @all' }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index e3df2cde1c1..26b536984ff 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -24,6 +24,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { const formFieldName = 'form[markdown_field]'; const formFieldPlaceholder = 'Write some markdown'; const formFieldAriaLabel = 'Edit your content'; + const autocompleteDataSources = { commands: '/foobar/-/autcomplete_sources' }; let mock; const buildWrapper = ({ propsData = {}, attachTo, stubs = {} } = {}) => { @@ -35,11 +36,14 @@ describe('vue_shared/component/markdown/markdown_editor', () => { markdownDocsPath, quickActionsDocsPath, enableAutocomplete, + autocompleteDataSources, enablePreview, - formFieldId, - formFieldName, - formFieldPlaceholder, - formFieldAriaLabel, + formFieldProps: { + id: formFieldId, + name: formFieldName, + placeholder: formFieldPlaceholder, + 'aria-label': formFieldAriaLabel, + }, ...propsData, }, stubs: { @@ -66,18 +70,17 @@ describe('vue_shared/component/markdown/markdown_editor', () => { 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, - }), - ); + expect(findMarkdownField().props()).toMatchObject({ + autocompleteDataSources, + markdownPreviewPath: renderMarkdownPath, + quickActionsDocsPath, + canAttachFile: true, + enableAutocomplete, + textareaValue: value, + markdownDocsPath, + uploadsPath: window.uploads_path, + enablePreview, + }); }); it('renders markdown field textarea', () => { @@ -95,6 +98,12 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findTextarea().element.value).toBe(value); }); + it('fails to render if textarea id and name is not passed', () => { + expect(() => { + buildWrapper({ propsData: { formFieldProps: {} } }); + }).toThrow('Invalid prop: custom validator check failed for prop "formFieldProps"'); + }); + it(`emits ${EDITING_MODE_CONTENT_EDITOR} event when enableContentEditor emitted from markdown editor`, async () => { buildWrapper(); diff --git a/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js index adcf57b76a4..c1e61f6e43d 100644 --- a/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js +++ b/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js @@ -4,6 +4,7 @@ import { splitDocument, } from '~/vue_shared/components/markdown_drawer/utils/fetch'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { MOCK_HTML, MOCK_DRAWER_DATA, @@ -20,9 +21,9 @@ describe('utils/fetch', () => { }); describe.each` - axiosMock | type | toExpect - ${{ code: 200, res: MOCK_HTML }} | ${'success'} | ${MOCK_DRAWER_DATA} - ${{ code: 500, res: null }} | ${'error'} | ${MOCK_DRAWER_DATA_ERROR} + axiosMock | type | toExpect + ${{ code: HTTP_STATUS_OK, res: MOCK_HTML }} | ${'success'} | ${MOCK_DRAWER_DATA} + ${{ code: HTTP_STATUS_INTERNAL_SERVER_ERROR, res: null }} | ${'error'} | ${MOCK_DRAWER_DATA_ERROR} `('process markdown data', ({ axiosMock, type, toExpect }) => { describe(`if api fetch responds with ${type}`, () => { beforeEach(() => { diff --git a/spec/frontend/vue_shared/components/new_resource_dropdown/mock_data.js b/spec/frontend/vue_shared/components/new_resource_dropdown/mock_data.js new file mode 100644 index 00000000000..19b1453e8ac --- /dev/null +++ b/spec/frontend/vue_shared/components/new_resource_dropdown/mock_data.js @@ -0,0 +1,54 @@ +export const emptySearchProjectsQueryResponse = { + data: { + projects: { + nodes: [], + }, + }, +}; + +export const emptySearchProjectsWithinGroupQueryResponse = { + data: { + group: { + id: '1', + projects: emptySearchProjectsQueryResponse.data.projects, + }, + }, +}; + +export const project1 = { + id: 'gid://gitlab/Group/26', + name: 'Super Mario Project', + nameWithNamespace: 'Mushroom Kingdom / Super Mario Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/super-mario-project', +}; + +export const project2 = { + id: 'gid://gitlab/Group/59', + name: 'Mario Kart Project', + nameWithNamespace: 'Mushroom Kingdom / Mario Kart Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-kart-project', +}; + +export const project3 = { + id: 'gid://gitlab/Group/103', + name: 'Mario Party Project', + nameWithNamespace: 'Mushroom Kingdom / Mario Party Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-party-project', +}; + +export const searchProjectsQueryResponse = { + data: { + projects: { + nodes: [project1, project2, project3], + }, + }, +}; + +export const searchProjectsWithinGroupQueryResponse = { + data: { + group: { + id: '1', + projects: searchProjectsQueryResponse.data.projects, + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js new file mode 100644 index 00000000000..31320b1d2a6 --- /dev/null +++ b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js @@ -0,0 +1,262 @@ +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +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 NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue'; +import searchUserProjectsWithIssuesEnabledQuery from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql'; +import { RESOURCE_TYPES } from '~/vue_shared/components/new_resource_dropdown/constants'; +import searchProjectsWithinGroupQuery from '~/issues/list/queries/search_projects.query.graphql'; +import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { + emptySearchProjectsQueryResponse, + emptySearchProjectsWithinGroupQueryResponse, + project1, + project2, + project3, + searchProjectsQueryResponse, + searchProjectsWithinGroupQueryResponse, +} from './mock_data'; + +jest.mock('~/flash'); + +describe('NewResourceDropdown component', () => { + useLocalStorageSpy(); + + let wrapper; + + Vue.use(VueApollo); + + // Props + const withinGroupProps = { + query: searchProjectsWithinGroupQuery, + queryVariables: { fullPath: 'mushroom-kingdom' }, + extractProjects: (data) => data.group.projects.nodes, + }; + + const mountComponent = ({ + search = '', + query = searchUserProjectsWithIssuesEnabledQuery, + queryResponse = searchProjectsQueryResponse, + mountFn = shallowMount, + propsData = {}, + } = {}) => { + const requestHandlers = [[query, jest.fn().mockResolvedValue(queryResponse)]]; + const apolloProvider = createMockApollo(requestHandlers); + + return mountFn(NewResourceDropdown, { + apolloProvider, + propsData, + data() { + return { search }; + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findInput = () => wrapper.findComponent(GlSearchBoxByType); + const showDropdown = async () => { + findDropdown().vm.$emit('shown'); + await waitForPromises(); + jest.advanceTimersByTime(DEBOUNCE_DELAY); + await waitForPromises(); + }; + + afterEach(() => { + localStorage.clear(); + }); + + it('renders a split dropdown', () => { + wrapper = mountComponent(); + + expect(findDropdown().props('split')).toBe(true); + }); + + it('renders a label for the dropdown toggle button', () => { + wrapper = mountComponent(); + + expect(findDropdown().attributes('toggle-text')).toBe( + NewResourceDropdown.i18n.toggleButtonLabel, + ); + }); + + it('focuses on input when dropdown is shown', async () => { + wrapper = mountComponent({ mountFn: mount }); + + const inputSpy = jest.spyOn(findInput().vm, 'focusInput'); + + await showDropdown(); + + expect(inputSpy).toHaveBeenCalledTimes(1); + }); + + describe.each` + description | propsData | query | queryResponse | emptyResponse + ${'by default'} | ${undefined} | ${searchUserProjectsWithIssuesEnabledQuery} | ${searchProjectsQueryResponse} | ${emptySearchProjectsQueryResponse} + ${'within a group'} | ${withinGroupProps} | ${searchProjectsWithinGroupQuery} | ${searchProjectsWithinGroupQueryResponse} | ${emptySearchProjectsWithinGroupQueryResponse} + `('$description', ({ propsData, query, queryResponse, emptyResponse }) => { + it('renders projects options', async () => { + wrapper = mountComponent({ mountFn: mount, query, queryResponse, propsData }); + await showDropdown(); + + const listItems = wrapper.findAll('li'); + + expect(listItems.at(0).text()).toBe(project1.nameWithNamespace); + expect(listItems.at(1).text()).toBe(project2.nameWithNamespace); + expect(listItems.at(2).text()).toBe(project3.nameWithNamespace); + }); + + it('renders `No matches found` when there are no matches', async () => { + wrapper = mountComponent({ + search: 'no matches', + query, + queryResponse: emptyResponse, + mountFn: mount, + propsData, + }); + + await showDropdown(); + + expect(wrapper.find('li').text()).toBe(NewResourceDropdown.i18n.noMatchesFound); + }); + + describe.each` + resourceType | expectedDefaultLabel | expectedPath | expectedLabel + ${'issue'} | ${'Select project to create issue'} | ${'issues/new'} | ${'New issue in'} + ${'merge-request'} | ${'Select project to create merge request'} | ${'merge_requests/new'} | ${'New merge request in'} + ${'milestone'} | ${'Select project to create milestone'} | ${'milestones/new'} | ${'New milestone in'} + `( + 'with resource type $resourceType', + ({ resourceType, expectedDefaultLabel, expectedPath, expectedLabel }) => { + describe('when no project is selected', () => { + beforeEach(() => { + wrapper = mountComponent({ + query, + queryResponse, + propsData: { ...propsData, resourceType }, + }); + }); + + it('dropdown button is not a link', () => { + expect(findDropdown().attributes('split-href')).toBeUndefined(); + }); + + it('displays default text on the dropdown button', () => { + expect(findDropdown().props('text')).toBe(expectedDefaultLabel); + }); + }); + + describe('when a project is selected', () => { + beforeEach(async () => { + wrapper = mountComponent({ + mountFn: mount, + query, + queryResponse, + propsData: { ...propsData, resourceType }, + }); + await showDropdown(); + + wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + }); + + it('dropdown button is a link', () => { + const href = joinPaths(project1.webUrl, DASH_SCOPE, expectedPath); + + expect(findDropdown().attributes('split-href')).toBe(href); + }); + + it('displays project name on the dropdown button', () => { + expect(findDropdown().props('text')).toBe(`${expectedLabel} ${project1.name}`); + }); + }); + }, + ); + }); + + describe('without localStorage', () => { + beforeEach(() => { + wrapper = mountComponent({ mountFn: mount }); + }); + + it('does not attempt to save the selected project to the localStorage', async () => { + await showDropdown(); + wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('with localStorage', () => { + it('retrieves the selected project from the localStorage', async () => { + localStorage.setItem( + 'group--new-issue-recent-project', + JSON.stringify({ + webUrl: project1.webUrl, + name: project1.name, + }), + ); + wrapper = mountComponent({ mountFn: mount, propsData: { withLocalStorage: true } }); + await nextTick(); + const dropdown = findDropdown(); + + expect(dropdown.attributes('split-href')).toBe( + joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new'), + ); + expect(dropdown.props('text')).toBe(`New issue in ${project1.name}`); + }); + + it('retrieves legacy cache from the localStorage', async () => { + localStorage.setItem( + 'group--new-issue-recent-project', + JSON.stringify({ + url: `${project1.webUrl}/issues/new`, + name: project1.name, + }), + ); + wrapper = mountComponent({ mountFn: mount, propsData: { withLocalStorage: true } }); + await nextTick(); + const dropdown = findDropdown(); + + expect(dropdown.attributes('split-href')).toBe( + joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new'), + ); + expect(dropdown.props('text')).toBe(`New issue in ${project1.name}`); + }); + + describe.each(RESOURCE_TYPES)('with resource type %s', (resourceType) => { + it('computes the local storage key without a group', async () => { + wrapper = mountComponent({ + mountFn: mount, + propsData: { resourceType, withLocalStorage: true }, + }); + await showDropdown(); + wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + await nextTick(); + + expect(localStorage.setItem).toHaveBeenLastCalledWith( + `group--new-${resourceType}-recent-project`, + expect.any(String), + ); + }); + + it('computes the local storage key with a group', async () => { + const groupId = '22'; + wrapper = mountComponent({ + mountFn: mount, + propsData: { groupId, resourceType, withLocalStorage: true }, + }); + await showDropdown(); + wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + await nextTick(); + + expect(localStorage.setItem).toHaveBeenLastCalledWith( + `group-${groupId}-new-${resourceType}-recent-project`, + expect.any(String), + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index 559f9bcb1a8..bcfd7a8ec70 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -4,6 +4,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import createStore from '~/notes/stores'; import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; jest.mock('~/behaviors/markdown/render_gfm'); @@ -85,7 +86,7 @@ describe('system note component', () => { it('renders outdated code lines', async () => { mock .onGet('/outdated_line_change_path') - .reply(200, [ + .reply(HTTP_STATUS_OK, [ { rich_text: 'console.log', type: 'new', line_code: '123', old_line: null, new_line: 1 }, ]); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js deleted file mode 100644 index c8ca75787f1..00000000000 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { s__ } from '~/locale'; -import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue'; -import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; - -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - visitUrl: jest.fn(), -})); - -const mockModalId = 'runner-aws-deployments-modal'; - -describe('RunnerAwsDeploymentsModal', () => { - let wrapper; - - const findModal = () => wrapper.findComponent(GlModal); - const findRunnerAwsInstructions = () => wrapper.findComponent(RunnerAwsInstructions); - - const createComponent = (options) => { - wrapper = shallowMount(RunnerAwsDeploymentsModal, { - propsData: { - modalId: mockModalId, - }, - ...options, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders modal', () => { - expect(findModal().props()).toMatchObject({ - size: 'sm', - modalId: mockModalId, - title: s__('Runners|Deploy GitLab Runner in AWS'), - }); - expect(findModal().attributes()).toMatchObject({ - 'hide-footer': '', - }); - }); - - it('renders modal contents', () => { - expect(findRunnerAwsInstructions().exists()).toBe(true); - }); - - it('when contents trigger closing, modal closes', () => { - const mockClose = jest.fn(); - - createComponent({ - stubs: { - GlModal: { - template: '<div><slot/></div>', - methods: { - close: mockClose, - }, - }, - }, - }); - - expect(mockClose).toHaveBeenCalledTimes(0); - - findRunnerAwsInstructions().vm.$emit('close'); - - expect(mockClose).toHaveBeenCalledTimes(1); - }); -}); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js deleted file mode 100644 index 639668761ea..00000000000 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import RunnerAwsDeployments from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue'; -import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue'; - -describe('RunnerAwsDeployments component', () => { - let wrapper; - - const findModalButton = () => wrapper.findByTestId('show-modal-button'); - const findModal = () => wrapper.findComponent(RunnerAwsDeploymentsModal); - - const createComponent = () => { - wrapper = extendedWrapper(shallowMount(RunnerAwsDeployments)); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should show the "Deploy GitLab Runner in AWS" button', () => { - expect(findModalButton().exists()).toBe(true); - expect(findModalButton().text()).toBe('Deploy GitLab Runner in AWS'); - }); - - it('should not render the modal once mounted', () => { - expect(findModal().exists()).toBe(false); - }); - - it('should render the modal once clicked', async () => { - findModalButton().vm.$emit('click'); - - await nextTick(); - - expect(findModal().exists()).toBe(true); - }); -}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js index 4d566dbec0c..6d8f895a185 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js @@ -16,14 +16,18 @@ import { AWS_TEMPLATES_BASE_URL, AWS_EASY_BUTTONS, } from '~/vue_shared/components/runner_instructions/constants'; -import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; import { __ } from '~/locale'; +import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; + jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), visitUrl: jest.fn(), })); +const mockRegistrationToken = 'MY_TOKEN'; + describe('RunnerAwsInstructions', () => { let wrapper; @@ -31,6 +35,7 @@ describe('RunnerAwsInstructions', () => { const findEasyButtons = () => wrapper.findAllComponents(GlFormRadio); const findEasyButtonAt = (i) => findEasyButtons().at(i); const findLink = () => wrapper.findComponent(GlLink); + const findModalCopyButton = () => wrapper.findComponent(ModalCopyButton); const findOkButton = () => wrapper .findAllComponents(GlButton) @@ -38,8 +43,12 @@ describe('RunnerAwsInstructions', () => { .at(0); const findCloseButton = () => wrapper.findByText(__('Close')); - const createComponent = () => { + const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(RunnerAwsInstructions, { + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, stubs: { GlSprintf, }, @@ -109,6 +118,22 @@ describe('RunnerAwsInstructions', () => { expect(findLink().attributes('href')).toBe(AWS_README_URL); }); + it('shows registration token and copy button', () => { + const token = wrapper.findByText(mockRegistrationToken); + + expect(token.exists()).toBe(true); + expect(token.element.tagName).toBe('PRE'); + + expect(findModalCopyButton().props('text')).toBe(mockRegistrationToken); + }); + + it('does not show registration token and copy button when token is not present', () => { + createComponent({ props: { registrationToken: null } }); + + expect(wrapper.find('pre').exists()).toBe(false); + expect(findModalCopyButton().exists()).toBe(false); + }); + it('triggers the modal to close', () => { findCloseButton().vm.$emit('click'); diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 19f2dd137ff..8f593b6aa1b 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -11,6 +11,7 @@ import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions import RunnerCliInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue'; import RunnerDockerInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue'; import RunnerKubernetesInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue'; +import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; import { mockRunnerPlatforms } from './mock_data'; @@ -156,6 +157,7 @@ describe('RunnerInstructionsModal component', () => { platform | component ${'docker'} | ${RunnerDockerInstructions} ${'kubernetes'} | ${RunnerKubernetesInstructions} + ${'aws'} | ${RunnerAwsInstructions} `('with platform "$platform"', ({ platform, component }) => { beforeEach(async () => { createComponent({ props: { defaultPlatformName: platform } }); diff --git a/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap b/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap index 1e08394dd56..66d27b5d605 100644 --- a/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap +++ b/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap @@ -22,7 +22,7 @@ exports[`SecuritySummary component given the message {"countMessage": "%{critica <span> <strong - class="text-danger-600 gl-px-2" + class="gl-text-red-600 gl-px-2" > 1 High @@ -55,7 +55,7 @@ exports[`SecuritySummary component given the message {"countMessage": "%{critica > <span> <strong - class="text-danger-800 gl-pl-4" + class="gl-text-red-800 gl-pl-4" > 1 Critical @@ -98,7 +98,7 @@ exports[`SecuritySummary component given the message {"countMessage": "%{critica > <span> <strong - class="text-danger-800 gl-pl-4" + class="gl-text-red-800 gl-pl-4" > 1 Critical @@ -108,7 +108,7 @@ exports[`SecuritySummary component given the message {"countMessage": "%{critica <span> <strong - class="text-danger-600 gl-px-2" + class="gl-text-red-600 gl-px-2" > 2 High diff --git a/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap new file mode 100644 index 00000000000..26c9a6f8d5a --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Chunk component rendering isHighlighted is true renders line numbers 1`] = ` +<div + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + data-testid="line-numbers" +> + <a + class="gl-user-select-none gl-shadow-none! file-line-blame" + href="some/blame/path.js#L71" + /> + + <a + class="gl-user-select-none gl-shadow-none! file-line-num" + data-line-number="71" + href="#L71" + id="L71" + > + + 71 + + </a> +</div> +`; diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js new file mode 100644 index 00000000000..da9067a8ddc --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js @@ -0,0 +1,123 @@ +import { nextTick } from 'vue'; +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk_deprecated.vue'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; +import LineHighlighter from '~/blob/line_highlighter'; + +const lineHighlighter = new LineHighlighter(); +jest.mock('~/blob/line_highlighter', () => + jest.fn().mockReturnValue({ + highlightHash: jest.fn(), + }), +); + +const DEFAULT_PROPS = { + chunkIndex: 2, + isHighlighted: false, + content: '// Line 1 content \n // Line 2 content', + startingFrom: 140, + totalLines: 50, + language: 'javascript', + blamePath: 'blame/file.js', +}; + +const hash = '#L142'; + +describe('Chunk component', () => { + let wrapper; + let idleCallbackSpy; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(Chunk, { + mocks: { $route: { hash } }, + propsData: { ...DEFAULT_PROPS, ...props }, + }); + }; + + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findChunkLines = () => wrapper.findAllComponents(ChunkLine); + const findLineNumbers = () => wrapper.findAllByTestId('line-number'); + const findContent = () => wrapper.findByTestId('content'); + + beforeEach(() => { + idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('Intersection observer', () => { + it('renders an Intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('emits an appear event when intersection-observer appears', () => { + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); + }); + + it('does not emit an appear event is isHighlighted is true', () => { + createComponent({ isHighlighted: true }); + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual(undefined); + }); + }); + + describe('rendering', () => { + it('does not register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => { + jest.clearAllMocks(); + createComponent({ isFirstChunk: true }); + + expect(window.requestIdleCallback).not.toHaveBeenCalled(); + expect(findContent().exists()).toBe(true); + }); + + it('does not render a Chunk Line component if isHighlighted is false', () => { + expect(findChunkLines().length).toBe(0); + }); + + it('does not render simplified line numbers and content if browser is not in idle state', () => { + idleCallbackSpy.mockRestore(); + createComponent(); + + expect(findLineNumbers()).toHaveLength(0); + expect(findContent().exists()).toBe(false); + }); + + it('renders simplified line numbers and content if isHighlighted is false', () => { + expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); + + expect(findLineNumbers().at(0).attributes('id')).toBe(`L${DEFAULT_PROPS.startingFrom + 1}`); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + + it('renders Chunk Line components if isHighlighted is true', () => { + const splitContent = DEFAULT_PROPS.content.split('\n'); + createComponent({ isHighlighted: true }); + + expect(findChunkLines().length).toBe(splitContent.length); + + expect(findChunkLines().at(0).props()).toMatchObject({ + number: DEFAULT_PROPS.startingFrom + 1, + content: splitContent[0], + language: DEFAULT_PROPS.language, + blamePath: DEFAULT_PROPS.blamePath, + }); + }); + + it('does not scroll to route hash if last chunk is not loaded', () => { + expect(LineHighlighter).not.toHaveBeenCalled(); + }); + + it('scrolls to route hash if last chunk is loaded', async () => { + createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 }); + await nextTick(); + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); + expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js index 657bd59dac6..95ef11d776a 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -2,27 +2,7 @@ import { nextTick } from 'vue'; import { GlIntersectionObserver } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; -import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; -import LineHighlighter from '~/blob/line_highlighter'; - -const lineHighlighter = new LineHighlighter(); -jest.mock('~/blob/line_highlighter', () => - jest.fn().mockReturnValue({ - highlightHash: jest.fn(), - }), -); - -const DEFAULT_PROPS = { - chunkIndex: 2, - isHighlighted: false, - content: '// Line 1 content \n // Line 2 content', - startingFrom: 140, - totalLines: 50, - language: 'javascript', - blamePath: 'blame/file.js', -}; - -const hash = '#L142'; +import { CHUNK_1, CHUNK_2 } from '../mock_data'; describe('Chunk component', () => { let wrapper; @@ -30,14 +10,13 @@ describe('Chunk component', () => { const createComponent = (props = {}) => { wrapper = shallowMountExtended(Chunk, { - mocks: { $route: { hash } }, - propsData: { ...DEFAULT_PROPS, ...props }, + propsData: { ...CHUNK_1, ...props }, + provide: { glFeatures: { fileLineBlame: true } }, }); }; const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); - const findChunkLines = () => wrapper.findAllComponents(ChunkLine); - const findLineNumbers = () => wrapper.findAllByTestId('line-number'); + const findLineNumbers = () => wrapper.findAllByTestId('line-numbers'); const findContent = () => wrapper.findByTestId('content'); beforeEach(() => { @@ -52,72 +31,57 @@ describe('Chunk component', () => { expect(findIntersectionObserver().exists()).toBe(true); }); - it('emits an appear event when intersection-observer appears', () => { + it('renders highlighted content if appear event is emitted', async () => { + createComponent({ chunkIndex: 1, isHighlighted: false }); findIntersectionObserver().vm.$emit('appear'); - expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); - }); - - it('does not emit an appear event is isHighlighted is true', () => { - createComponent({ isHighlighted: true }); - findIntersectionObserver().vm.$emit('appear'); + await nextTick(); - expect(wrapper.emitted('appear')).toEqual(undefined); + expect(findContent().exists()).toBe(true); }); }); describe('rendering', () => { - it('does not register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => { + it('does not register window.requestIdleCallback for the first chunk, renders content immediately', () => { jest.clearAllMocks(); - createComponent({ isFirstChunk: true }); expect(window.requestIdleCallback).not.toHaveBeenCalled(); - expect(findContent().exists()).toBe(true); - }); - - it('does not render a Chunk Line component if isHighlighted is false', () => { - expect(findChunkLines().length).toBe(0); + expect(findContent().text()).toBe(CHUNK_1.highlightedContent); }); - it('does not render simplified line numbers and content if browser is not in idle state', () => { + it('does not render content if browser is not in idle state', () => { idleCallbackSpy.mockRestore(); - createComponent(); + createComponent({ chunkIndex: 1, ...CHUNK_2 }); expect(findLineNumbers()).toHaveLength(0); expect(findContent().exists()).toBe(false); }); - it('renders simplified line numbers and content if isHighlighted is false', () => { - expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); + describe('isHighlighted is false', () => { + beforeEach(() => createComponent(CHUNK_2)); - expect(findLineNumbers().at(0).attributes('id')).toBe(`L${DEFAULT_PROPS.startingFrom + 1}`); + it('does not render line numbers', () => { + expect(findLineNumbers()).toHaveLength(0); + }); - expect(findContent().text()).toBe(DEFAULT_PROPS.content); + it('renders raw content', () => { + expect(findContent().text()).toBe(CHUNK_2.rawContent); + }); }); - it('renders Chunk Line components if isHighlighted is true', () => { - const splitContent = DEFAULT_PROPS.content.split('\n'); - createComponent({ isHighlighted: true }); + describe('isHighlighted is true', () => { + beforeEach(() => createComponent({ ...CHUNK_2, isHighlighted: true })); - expect(findChunkLines().length).toBe(splitContent.length); + it('renders line numbers', () => { + expect(findLineNumbers()).toHaveLength(CHUNK_2.totalLines); - expect(findChunkLines().at(0).props()).toMatchObject({ - number: DEFAULT_PROPS.startingFrom + 1, - content: splitContent[0], - language: DEFAULT_PROPS.language, - blamePath: DEFAULT_PROPS.blamePath, + // Opted for a snapshot test here since the output is simple and verifies native HTML elements + expect(findLineNumbers().at(0).element).toMatchSnapshot(); }); - }); - it('does not scroll to route hash if last chunk is not loaded', () => { - expect(LineHighlighter).not.toHaveBeenCalled(); - }); - - it('scrolls to route hash if last chunk is loaded', async () => { - createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 }); - await nextTick(); - expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); - expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); + it('renders highlighted content', () => { + expect(findContent().text()).toBe(CHUNK_2.highlightedContent); + }); }); }); }); 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 index 4a995e2fde1..d2dd4afe09e 100644 --- a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js @@ -1,15 +1,10 @@ -import hljs from 'highlight.js/lib/core'; -import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import hljs from 'highlight.js'; import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; import { highlight } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; +import { LINES_PER_CHUNK, NEWLINE } from '~/vue_shared/components/source_viewer/constants'; -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('highlight.js', () => ({ + highlight: jest.fn().mockReturnValue({ value: 'highlighted content' }), })); jest.mock('~/vue_shared/components/source_viewer/plugins/index', () => ({ @@ -17,28 +12,61 @@ jest.mock('~/vue_shared/components/source_viewer/plugins/index', () => ({ })); const fileType = 'text'; -const content = 'function test() { return true };'; +const rawContent = 'function test() { return true }; \n // newline'; +const highlightedContent = 'highlighted content'; const language = 'javascript'; describe('Highlight utility', () => { - beforeEach(() => highlight(fileType, content, language)); - - it('loads the language', () => { - expect(languageLoader.javascript).toHaveBeenCalled(); - }); + beforeEach(() => highlight(fileType, rawContent, language)); 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(rawContent, { language }); + }); + + it('splits the content into chunks', () => { + const contentArray = Array.from({ length: 140 }, () => 'newline'); // simulate 140 lines of code + + const chunks = [ + { + language, + highlightedContent, + rawContent: contentArray.slice(0, 70).join(NEWLINE), // first 70 lines + startingFrom: 0, + totalLines: LINES_PER_CHUNK, + }, + { + language, + highlightedContent: '', + rawContent: contentArray.slice(70, 140).join(NEWLINE), // last 70 lines + startingFrom: 70, + totalLines: LINES_PER_CHUNK, + }, + ]; + + expect(highlight(fileType, contentArray.join(NEWLINE), language)).toEqual( + expect.arrayContaining(chunks), ); }); +}); - it('highlights the content', () => { - expect(hljs.highlight).toHaveBeenCalledWith(content, { language }); +describe('unsupported languages', () => { + const unsupportedLanguage = 'some_unsupported_language'; + + beforeEach(() => highlight(fileType, rawContent, unsupportedLanguage)); + + it('does not register plugins', () => { + expect(registerPlugins).not.toHaveBeenCalled(); + }); + + it('does not attempt to highlight the content', () => { + expect(hljs.highlight).not.toHaveBeenCalled(); + }); + + it('does not return a result', () => { + expect(highlight(fileType, rawContent, unsupportedLanguage)).toBe(undefined); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/mock_data.js new file mode 100644 index 00000000000..f35e9607d5c --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/mock_data.js @@ -0,0 +1,24 @@ +const path = 'some/path.js'; +const blamePath = 'some/blame/path.js'; + +export const LANGUAGE_MOCK = 'docker'; + +export const BLOB_DATA_MOCK = { language: LANGUAGE_MOCK, path, blamePath }; + +export const CHUNK_1 = { + isHighlighted: true, + rawContent: 'chunk 1 raw', + highlightedContent: 'chunk 1 highlighted', + totalLines: 70, + startingFrom: 0, + blamePath, +}; + +export const CHUNK_2 = { + isHighlighted: false, + rawContent: 'chunk 2 raw', + highlightedContent: 'chunk 2 highlighted', + totalLines: 40, + startingFrom: 70, + blamePath, +}; diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js new file mode 100644 index 00000000000..0beec8e9d3e --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js @@ -0,0 +1,177 @@ +import hljs from 'highlight.js/lib/core'; +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'; +import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk_deprecated.vue'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, +} from '~/vue_shared/components/source_viewer/constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +import Tracking from '~/tracking'; + +jest.mock('~/blob/line_highlighter'); +jest.mock('highlight.js/lib/core'); +jest.mock('~/vue_shared/components/source_viewer/plugins/index'); +Vue.use(VueRouter); +const router = new VueRouter(); + +const generateContent = (content, totalLines = 1, delimiter = '\n') => { + let generatedContent = ''; + for (let i = 0; i < totalLines; i += 1) { + generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; + } + return generatedContent; +}; + +const execImmediately = (callback) => callback(); + +describe('Source Viewer component', () => { + let wrapper; + const language = 'docker'; + 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 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'; + const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType }; + const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; + + const createComponent = async (blob = {}) => { + wrapper = shallowMountExtended(SourceViewer, { + router, + propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } }, + }); + await waitForPromises(); + }; + + const findChunks = () => wrapper.findAllComponents(Chunk); + + beforeEach(() => { + hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); + hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); + jest.spyOn(eventHub, '$emit'); + jest.spyOn(Tracking, 'event'); + + return createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('event tracking', () => { + it('fires a tracking event when the component is created', () => { + const eventData = { label: EVENT_LABEL_VIEWER, property: language }; + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + }); + + it('does not emit an error event when the language is supported', () => { + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('fires a tracking event and emits an error when the language is not supported', () => { + const unsupportedLanguage = 'apex'; + const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; + createComponent({ language: unsupportedLanguage }); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + + describe('legacy fallbacks', () => { + it('tracks a fallback event and emits an error when viewing python files', () => { + const fallbackLanguage = 'python'; + const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; + createComponent({ language: fallbackLanguage }); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + + describe('highlight.js', () => { + beforeEach(() => createComponent({ language: mappedLanguage })); + + it('registers our plugins for Highlight.js', () => { + expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); + }); + + it('registers the language definition', async () => { + const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith( + mappedLanguage, + languageDefinition.default, + ); + }); + + it('registers json language definition if fileType is package_json', async () => { + await createComponent({ language: 'json', fileType: 'package_json' }); + const languageDefinition = await import(`highlight.js/lib/languages/json`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); + }); + + it('correctly maps languages starting with uppercase', async () => { + await createComponent({ language: 'Ruby' }); + const languageDefinition = await import(`highlight.js/lib/languages/ruby`); + + expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); + }); + + it('highlights the first chunk', () => { + expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); + expect(findChunks().at(0).props('isFirstChunk')).toBe(true); + }); + + describe('auto-detects if a language cannot be loaded', () => { + beforeEach(() => createComponent({ language: 'some_unknown_language' })); + + it('highlights the content with auto-detection', () => { + expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); + }); + }); + }); + + describe('rendering', () => { + it.each` + chunkIndex | chunkContent | totalChunks + ${0} | ${chunk1} | ${0} + ${1} | ${chunk2} | ${3} + ${2} | ${chunk3Result} | ${3} + `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => { + const chunk = findChunks().at(chunkIndex); + + expect(chunk.props('content')).toContain(chunkContent.trim()); + + expect(chunk.props()).toMatchObject({ + totalLines: LINES_PER_CHUNK, + startingFrom: LINES_PER_CHUNK * chunkIndex, + totalChunks, + }); + }); + + it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { + findChunks().at(0).vm.$emit('appear'); + expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); + }); + }); + + describe('LineHighlighter', () => { + it('instantiates the lineHighlighter class', async () => { + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); + }); + }); +}); 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 5461d38599d..1c75442b4a8 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 @@ -1,70 +1,27 @@ -import hljs from 'highlight.js/lib/core'; -import Vue from 'vue'; -import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; -import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; -import { - EVENT_ACTION, - EVENT_LABEL_VIEWER, - EVENT_LABEL_FALLBACK, - ROUGE_TO_HLJS_LANGUAGE_MAP, - LINES_PER_CHUNK, -} from '~/vue_shared/components/source_viewer/constants'; -import waitForPromises from 'helpers/wait_for_promises'; -import LineHighlighter from '~/blob/line_highlighter'; -import eventHub from '~/notes/event_hub'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants'; import Tracking from '~/tracking'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data'; -jest.mock('~/blob/line_highlighter'); -jest.mock('highlight.js/lib/core'); -jest.mock('~/vue_shared/components/source_viewer/plugins/index'); -Vue.use(VueRouter); -const router = new VueRouter(); - -const generateContent = (content, totalLines = 1, delimiter = '\n') => { - let generatedContent = ''; - for (let i = 0; i < totalLines; i += 1) { - generatedContent += `Line: ${i + 1} = ${content}${delimiter}`; - } - return generatedContent; -}; - -const execImmediately = (callback) => callback(); +jest.mock('~/blob/blob_links_tracking'); describe('Source Viewer component', () => { let wrapper; - const language = 'docker'; - 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 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'; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType }; - const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; + const CHUNKS_MOCK = [CHUNK_1, CHUNK_2]; - const createComponent = async (blob = {}) => { + const createComponent = () => { wrapper = shallowMountExtended(SourceViewer, { - router, - propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } }, + propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK }, }); - await waitForPromises(); }; const findChunks = () => wrapper.findAllComponents(Chunk); beforeEach(() => { - hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); - hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); - jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); - jest.spyOn(eventHub, '$emit'); jest.spyOn(Tracking, 'event'); - return createComponent(); }); @@ -72,106 +29,19 @@ describe('Source Viewer component', () => { describe('event tracking', () => { it('fires a tracking event when the component is created', () => { - const eventData = { label: EVENT_LABEL_VIEWER, property: language }; + const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK }; expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); }); - it('does not emit an error event when the language is supported', () => { - expect(wrapper.emitted('error')).toBeUndefined(); - }); - - it('fires a tracking event and emits an error when the language is not supported', () => { - const unsupportedLanguage = 'apex'; - const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage }; - createComponent({ language: unsupportedLanguage }); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - expect(wrapper.emitted('error')).toHaveLength(1); - }); - }); - - describe('legacy fallbacks', () => { - it('tracks a fallback event and emits an error when viewing python files', () => { - const fallbackLanguage = 'python'; - const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; - createComponent({ language: fallbackLanguage }); - - expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); - expect(wrapper.emitted('error')).toHaveLength(1); - }); - }); - - describe('highlight.js', () => { - beforeEach(() => createComponent({ language: mappedLanguage })); - - it('registers our plugins for Highlight.js', () => { - expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content); - }); - - it('registers the language definition', async () => { - const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith( - mappedLanguage, - languageDefinition.default, - ); - }); - - it('registers json language definition if fileType is package_json', async () => { - await createComponent({ language: 'json', fileType: 'package_json' }); - const languageDefinition = await import(`highlight.js/lib/languages/json`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default); - }); - - it('correctly maps languages starting with uppercase', async () => { - await createComponent({ language: 'Ruby' }); - const languageDefinition = await import(`highlight.js/lib/languages/ruby`); - - expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); - }); - - it('highlights the first chunk', () => { - expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); - expect(findChunks().at(0).props('isFirstChunk')).toBe(true); - }); - - describe('auto-detects if a language cannot be loaded', () => { - beforeEach(() => createComponent({ language: 'some_unknown_language' })); - - it('highlights the content with auto-detection', () => { - expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); - }); + it('adds blob links tracking', () => { + expect(addBlobLinksTracking).toHaveBeenCalled(); }); }); describe('rendering', () => { - it.each` - chunkIndex | chunkContent | totalChunks - ${0} | ${chunk1} | ${0} - ${1} | ${chunk2} | ${3} - ${2} | ${chunk3Result} | ${3} - `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => { - const chunk = findChunks().at(chunkIndex); - - expect(chunk.props('content')).toContain(chunkContent.trim()); - - expect(chunk.props()).toMatchObject({ - totalLines: LINES_PER_CHUNK, - startingFrom: LINES_PER_CHUNK * chunkIndex, - totalChunks, - }); - }); - - it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { - findChunks().at(0).vm.$emit('appear'); - expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); - }); - }); - - describe('LineHighlighter', () => { - it('instantiates the lineHighlighter class', async () => { - expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); + it('renders a Chunk component for each chunk', () => { + expect(findChunks().at(0).props()).toMatchObject(CHUNK_1); + expect(findChunks().at(1).props()).toMatchObject(CHUNK_2); }); }); }); diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js index acda1a64a75..30a7439579f 100644 --- a/spec/frontend/vue_shared/components/url_sync_spec.js +++ b/spec/frontend/vue_shared/components/url_sync_spec.js @@ -1,7 +1,10 @@ import { shallowMount } from '@vue/test-utils'; -import { historyPushState } from '~/lib/utils/common_utils'; +import { historyPushState, historyReplaceState } from '~/lib/utils/common_utils'; import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility'; -import UrlSyncComponent, { URL_SET_PARAMS_STRATEGY } from '~/vue_shared/components/url_sync.vue'; +import UrlSyncComponent, { + URL_SET_PARAMS_STRATEGY, + HISTORY_REPLACE_UPDATE_METHOD, +} from '~/vue_shared/components/url_sync.vue'; jest.mock('~/lib/utils/url_utility', () => ({ mergeUrlParams: jest.fn((query, url) => `urlParams: ${JSON.stringify(query)} ${url}`), @@ -10,6 +13,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/common_utils', () => ({ historyPushState: jest.fn(), + historyReplaceState: jest.fn(), })); describe('url sync component', () => { @@ -18,14 +22,12 @@ describe('url sync component', () => { const findButton = () => wrapper.find('button'); - const createComponent = ({ - query = mockQuery, - scopedSlots, - slots, - urlParamsUpdateStrategy, - } = {}) => { + const createComponent = ({ props = {}, scopedSlots, slots } = {}) => { wrapper = shallowMount(UrlSyncComponent, { - propsData: { query, ...(urlParamsUpdateStrategy && { urlParamsUpdateStrategy }) }, + propsData: { + query: mockQuery, + ...props, + }, scopedSlots, slots, }); @@ -35,32 +37,27 @@ describe('url sync component', () => { wrapper.destroy(); }); - const expectUrlSyncFactory = ( + const expectUrlSyncWithMergeUrlParams = ( query, times, - urlParamsUpdateStrategy, - urlOptions, - urlReturnValue, + mergeUrlParamsReturnValue, + historyMethod = historyPushState, ) => { - expect(urlParamsUpdateStrategy).toHaveBeenCalledTimes(times); - expect(urlParamsUpdateStrategy).toHaveBeenCalledWith(query, window.location.href, urlOptions); - - expect(historyPushState).toHaveBeenCalledTimes(times); - expect(historyPushState).toHaveBeenCalledWith(urlReturnValue); - }; + expect(mergeUrlParams).toHaveBeenCalledTimes(times); + expect(mergeUrlParams).toHaveBeenCalledWith(query, window.location.href, { + spreadArrays: true, + }); - const expectUrlSyncWithMergeUrlParams = (query, times, mergeUrlParamsReturnValue) => { - expectUrlSyncFactory( - query, - times, - mergeUrlParams, - { spreadArrays: true }, - mergeUrlParamsReturnValue, - ); + expect(historyMethod).toHaveBeenCalledTimes(times); + expect(historyMethod).toHaveBeenCalledWith(mergeUrlParamsReturnValue); }; const expectUrlSyncWithSetUrlParams = (query, times, setUrlParamsReturnValue) => { - expectUrlSyncFactory(query, times, setUrlParams, true, setUrlParamsReturnValue); + expect(setUrlParams).toHaveBeenCalledTimes(times); + expect(setUrlParams).toHaveBeenCalledWith(query, window.location.href, true, true, true); + + expect(historyPushState).toHaveBeenCalledTimes(times); + expect(historyPushState).toHaveBeenCalledWith(setUrlParamsReturnValue); }; describe('with query as a props', () => { @@ -86,13 +83,32 @@ describe('url sync component', () => { describe('with url-params-update-strategy equals to URL_SET_PARAMS_STRATEGY', () => { it('uses setUrlParams to generate URL', () => { createComponent({ - urlParamsUpdateStrategy: URL_SET_PARAMS_STRATEGY, + props: { + urlParamsUpdateStrategy: URL_SET_PARAMS_STRATEGY, + }, }); expectUrlSyncWithSetUrlParams(mockQuery, 1, setUrlParams.mock.results[0].value); }); }); + describe('with history-update-method equals to HISTORY_REPLACE_UPDATE_METHOD', () => { + it('uses historyReplaceState to update the URL', () => { + createComponent({ + props: { + historyUpdateMethod: HISTORY_REPLACE_UPDATE_METHOD, + }, + }); + + expectUrlSyncWithMergeUrlParams( + mockQuery, + 1, + mergeUrlParams.mock.results[0].value, + historyReplaceState, + ); + }); + }); + describe('with scoped slot', () => { const scopedSlots = { default: ` @@ -101,13 +117,13 @@ describe('url sync component', () => { }; it('renders the scoped slot', () => { - createComponent({ query: null, scopedSlots }); + createComponent({ props: { query: null }, scopedSlots }); expect(findButton().exists()).toBe(true); }); it('syncs the url with the scoped slots function', () => { - createComponent({ query: null, scopedSlots }); + createComponent({ props: { query: null }, scopedSlots }); findButton().trigger('click'); @@ -121,7 +137,7 @@ describe('url sync component', () => { }; it('renders the default slot', () => { - createComponent({ query: null, slots }); + createComponent({ props: { query: null }, slots }); expect(findButton().exists()).toBe(true); }); 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 1ad6d043399..63371b1492b 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 @@ -4,6 +4,7 @@ import { nextTick } from 'vue'; import { TEST_HOST } from 'spec/test_constants'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; const TEST_IMAGE_SIZE = 7; const TEST_BREAKPOINT = 5; @@ -16,10 +17,13 @@ const createUser = (id) => ({ web_url: `${TEST_HOST}/${id}`, avatar_url: `${TEST_HOST}/${id}/avatar`, }); + const createList = (n) => Array(n) .fill(1) .map((x, id) => createUser(id)); +const createListCamelCase = (n) => + createList(n).map((user) => convertObjectPropsToCamelCase(user, { deep: true })); describe('UserAvatarList', () => { let props; @@ -75,14 +79,14 @@ describe('UserAvatarList', () => { props.breakpoint = 0; }); - it('renders avatars', () => { + const linkProps = () => + wrapper.findAllComponents(UserAvatarLink).wrappers.map((x) => x.props()); + + it('renders avatars when user has snake_case attributes', () => { const items = createList(20); factory({ propsData: { items } }); - const links = wrapper.findAllComponents(UserAvatarLink); - const linkProps = links.wrappers.map((x) => x.props()); - - expect(linkProps).toEqual( + expect(linkProps()).toEqual( items.map((x) => expect.objectContaining({ linkHref: x.web_url, @@ -94,6 +98,23 @@ describe('UserAvatarList', () => { ), ); }); + + it('renders avatars when user has camelCase attributes', () => { + const items = createListCamelCase(20); + factory({ propsData: { items } }); + + expect(linkProps()).toEqual( + items.map((x) => + expect.objectContaining({ + linkHref: x.webUrl, + imgSrc: x.avatarUrl, + imgAlt: x.name, + tooltipText: x.name, + imgSize: TEST_IMAGE_SIZE, + }), + ), + ); + }); }); describe('with breakpoint and length equal to breakpoint', () => { diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index 874796f653a..b0e9584a15b 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -285,6 +285,20 @@ describe('User select dropdown', () => { expect(wrapper.emitted('input')).toEqual([[[]]]); }); + it('hides the dropdown after clicking on `Unassigned`', async () => { + createComponent({ + props: { + value: [assignee], + }, + }); + wrapper.vm.$refs.dropdown.hide = jest.fn(); + await waitForPromises(); + + findUnassignLink().trigger('click'); + + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); + }); + it('emits an empty array after unselecting the only selected assignee', async () => { createComponent({ props: { diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 2a0d2089fe3..18afe049149 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlModal, GlPopover } from '@gitlab/ui'; +import { GlButton, GlLink, GlModal, GlPopover } from '@gitlab/ui'; import { nextTick } from 'vue'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; @@ -147,6 +147,11 @@ describe('Web IDE link component', () => { const findForkConfirmModal = () => wrapper.findComponent(ConfirmForkModal); const findUserCalloutDismisser = () => wrapper.findComponent(UserCalloutDismisser); const findNewWebIdeCalloutPopover = () => wrapper.findComponent(GlPopover); + const findTryItOutLink = () => + wrapper + .findAllComponents(GlLink) + .filter((link) => link.text().includes('Try it out')) + .at(0); it.each([ { @@ -516,6 +521,12 @@ describe('Web IDE link component', () => { expect(dismiss).toHaveBeenCalled(); }); + it('dismisses the callout when try it now link is clicked', () => { + findTryItOutLink().vm.$emit('click'); + + expect(dismiss).toHaveBeenCalled(); + }); + it('dismisses the callout when action button is clicked', () => { findActionsButton().vm.$emit('actionClicked'); diff --git a/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js index d59cbce6633..a0b1d64b97c 100644 --- a/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js +++ b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js @@ -8,6 +8,7 @@ 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 { TYPE_ISSUE } from '~/issues/constants'; import { truncate } from '~/lib/utils/text_utility'; import { mockIssue, @@ -57,7 +58,7 @@ describe('IssuableBlockedIcon', () => { item = mockBlockedIssue1, blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1), issuableItem = mockIssue, - issuableType = issuableTypes.issue, + issuableType = TYPE_ISSUE, } = {}) => { mockApollo = createMockApollo([ [blockingIssuablesQueries[issuableType].query, blockingIssuablesSpy], @@ -86,7 +87,7 @@ describe('IssuableBlockedIcon', () => { data = {}, loading = false, mockIssuable = mockIssue, - issuableType = issuableTypes.issue, + issuableType = TYPE_ISSUE, } = {}) => { wrapper = extendedWrapper( shallowMount(IssuableBlockedIcon, { @@ -120,9 +121,9 @@ describe('IssuableBlockedIcon', () => { }; it.each` - mockIssuable | issuableType | expectedIcon - ${mockIssue} | ${issuableTypes.issue} | ${'issue-block'} - ${mockEpic} | ${issuableTypes.epic} | ${'entity-blocked'} + mockIssuable | issuableType | expectedIcon + ${mockIssue} | ${TYPE_ISSUE} | ${'issue-block'} + ${mockEpic} | ${issuableTypes.epic} | ${'entity-blocked'} `( 'should render blocked icon for $issuableType', ({ mockIssuable, issuableType, expectedIcon }) => { @@ -152,9 +153,9 @@ describe('IssuableBlockedIcon', () => { 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)} + item | issuableType | mockBlockingIssuable | issuableItem | blockingIssuablesSpy + ${mockBlockedIssue1} | ${TYPE_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 }) => { 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 43ff68e30b5..221da35de3d 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 @@ -16,6 +16,7 @@ import { } from 'jest/vue_shared/security_reports/mock_data'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; import { @@ -187,7 +188,7 @@ describe('Security reports app', () => { describe('when loading', () => { beforeEach(() => { mock = new MockAdapter(axios, { delayResponse: 1 }); - mock.onGet(path).replyOnce(200, successResponse); + mock.onGet(path).replyOnce(HTTP_STATUS_OK, successResponse); createComponentWithFlagEnabled({ propsData: { @@ -209,7 +210,7 @@ describe('Security reports app', () => { describe('when successfully loaded', () => { beforeEach(() => { - mock.onGet(path).replyOnce(200, successResponse); + mock.onGet(path).replyOnce(HTTP_STATUS_OK, successResponse); createComponentWithFlagEnabled({ propsData: { @@ -231,7 +232,7 @@ describe('Security reports app', () => { describe('when an error occurs', () => { beforeEach(() => { - mock.onGet(path).replyOnce(500); + mock.onGet(path).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); createComponentWithFlagEnabled({ propsData: { @@ -253,7 +254,7 @@ describe('Security reports app', () => { describe('when the comparison endpoint is not provided', () => { beforeEach(() => { - mock.onGet(path).replyOnce(500); + mock.onGet(path).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); createComponentWithFlagEnabled(); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js index 46bfd7eceb1..0cab950cb77 100644 --- a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js +++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/security_reports/store/modules/sast/actions'; import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types'; import createState from '~/vue_shared/security_reports/store/modules/sast/state'; @@ -99,9 +100,9 @@ describe('sast report actions', () => { beforeEach(() => { mock .onGet(diffEndpoint) - .replyOnce(200, reports.diff) + .replyOnce(HTTP_STATUS_OK, reports.diff) .onGet(vulnerabilityFeedbackPath) - .replyOnce(200, reports.enrichData); + .replyOnce(HTTP_STATUS_OK, reports.enrichData); }); it('should dispatch the `receiveDiffSuccess` action', () => { @@ -128,7 +129,7 @@ describe('sast report actions', () => { describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => { beforeEach(() => { rootState.canReadVulnerabilityFeedback = false; - mock.onGet(diffEndpoint).replyOnce(200, reports.diff); + mock.onGet(diffEndpoint).replyOnce(HTTP_STATUS_OK, reports.diff); }); it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { @@ -157,9 +158,9 @@ describe('sast report actions', () => { beforeEach(() => { mock .onGet(diffEndpoint) - .replyOnce(200, reports.diff) + .replyOnce(HTTP_STATUS_OK, reports.diff) .onGet(vulnerabilityFeedbackPath) - .replyOnce(404); + .replyOnce(HTTP_STATUS_NOT_FOUND); }); it('should dispatch the `receiveError` action', () => { @@ -177,9 +178,9 @@ describe('sast report actions', () => { beforeEach(() => { mock .onGet(diffEndpoint) - .replyOnce(404) + .replyOnce(HTTP_STATUS_NOT_FOUND) .onGet(vulnerabilityFeedbackPath) - .replyOnce(200, reports.enrichData); + .replyOnce(HTTP_STATUS_OK, reports.enrichData); }); it('should dispatch the `receiveDiffError` action', () => { diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js index 4f4f653bb72..7197784c3e8 100644 --- a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js +++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/security_reports/store/modules/secret_detection/actions'; import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types'; import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; @@ -99,9 +100,9 @@ describe('secret detection report actions', () => { beforeEach(() => { mock .onGet(diffEndpoint) - .replyOnce(200, reports.diff) + .replyOnce(HTTP_STATUS_OK, reports.diff) .onGet(vulnerabilityFeedbackPath) - .replyOnce(200, reports.enrichData); + .replyOnce(HTTP_STATUS_OK, reports.enrichData); }); it('should dispatch the `receiveDiffSuccess` action', () => { @@ -129,7 +130,7 @@ describe('secret detection report actions', () => { describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => { beforeEach(() => { rootState.canReadVulnerabilityFeedback = false; - mock.onGet(diffEndpoint).replyOnce(200, reports.diff); + mock.onGet(diffEndpoint).replyOnce(HTTP_STATUS_OK, reports.diff); }); it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { @@ -158,9 +159,9 @@ describe('secret detection report actions', () => { beforeEach(() => { mock .onGet(diffEndpoint) - .replyOnce(200, reports.diff) + .replyOnce(HTTP_STATUS_OK, reports.diff) .onGet(vulnerabilityFeedbackPath) - .replyOnce(404); + .replyOnce(HTTP_STATUS_NOT_FOUND); }); it('should dispatch the `receiveDiffError` action', () => { @@ -178,9 +179,9 @@ describe('secret detection report actions', () => { beforeEach(() => { mock .onGet(diffEndpoint) - .replyOnce(404) + .replyOnce(HTTP_STATUS_NOT_FOUND) .onGet(vulnerabilityFeedbackPath) - .replyOnce(200, reports.enrichData); + .replyOnce(HTTP_STATUS_OK, reports.enrichData); }); it('should dispatch the `receiveDiffError` action', () => { diff --git a/spec/frontend/vue_shared/security_reports/store/utils_spec.js b/spec/frontend/vue_shared/security_reports/store/utils_spec.js new file mode 100644 index 00000000000..c8750cd58a0 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/utils_spec.js @@ -0,0 +1,63 @@ +import { enrichVulnerabilityWithFeedback } from '~/vue_shared/security_reports/store/utils'; +import { + FEEDBACK_TYPE_DISMISSAL, + FEEDBACK_TYPE_ISSUE, + FEEDBACK_TYPE_MERGE_REQUEST, +} from '~/vue_shared/security_reports/constants'; + +describe('security reports store utils', () => { + const vulnerability = { uuid: 1 }; + + describe('enrichVulnerabilityWithFeedback', () => { + const dismissalFeedback = { + feedback_type: FEEDBACK_TYPE_DISMISSAL, + finding_uuid: vulnerability.uuid, + }; + const dismissalVuln = { ...vulnerability, isDismissed: true, dismissalFeedback }; + + const issueFeedback = { + feedback_type: FEEDBACK_TYPE_ISSUE, + issue_iid: 1, + finding_uuid: vulnerability.uuid, + }; + const issueVuln = { ...vulnerability, hasIssue: true, issue_feedback: issueFeedback }; + const mrFeedback = { + feedback_type: FEEDBACK_TYPE_MERGE_REQUEST, + merge_request_iid: 1, + finding_uuid: vulnerability.uuid, + }; + const mrVuln = { + ...vulnerability, + hasMergeRequest: true, + merge_request_feedback: mrFeedback, + }; + + it.each` + feedbacks | expected + ${[dismissalFeedback]} | ${dismissalVuln} + ${[{ ...issueFeedback, issue_iid: null }]} | ${vulnerability} + ${[issueFeedback]} | ${issueVuln} + ${[{ ...mrFeedback, merge_request_iid: null }]} | ${vulnerability} + ${[mrFeedback]} | ${mrVuln} + ${[dismissalFeedback, issueFeedback, mrFeedback]} | ${{ ...dismissalVuln, ...issueVuln, ...mrVuln }} + `('returns expected enriched vulnerability: $expected', ({ feedbacks, expected }) => { + const enrichedVulnerability = enrichVulnerabilityWithFeedback(vulnerability, feedbacks); + + expect(enrichedVulnerability).toEqual(expected); + }); + + it('matches correct feedback objects to vulnerability', () => { + const feedbacks = [ + dismissalFeedback, + issueFeedback, + mrFeedback, + { ...dismissalFeedback, finding_uuid: 2 }, + { ...issueFeedback, finding_uuid: 2 }, + { ...mrFeedback, finding_uuid: 2 }, + ]; + const enrichedVulnerability = enrichVulnerabilityWithFeedback(vulnerability, feedbacks); + + expect(enrichedVulnerability).toEqual({ ...dismissalVuln, ...issueVuln, ...mrVuln }); + }); + }); +}); |