diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-20 14:43:17 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-20 14:43:17 +0300 |
commit | dfc94207fec2d84314b1a5410cface22e8b369bd (patch) | |
tree | c54022f61ced104305889a64de080998a0dc773b /spec/frontend | |
parent | b874efeff674f6bf0355d5d242ecf81c6f7155df (diff) |
Add latest changes from gitlab-org/gitlab@15-11-stable-eev15.11.0-rc42
Diffstat (limited to 'spec/frontend')
662 files changed, 15923 insertions, 8244 deletions
diff --git a/spec/frontend/__helpers__/assert_props.js b/spec/frontend/__helpers__/assert_props.js new file mode 100644 index 00000000000..3e372454bf5 --- /dev/null +++ b/spec/frontend/__helpers__/assert_props.js @@ -0,0 +1,24 @@ +import { mount } from '@vue/test-utils'; +import { ErrorWithStack } from 'jest-util'; + +export function assertProps(Component, props, extraMountArgs = {}) { + const originalConsoleError = global.console.error; + global.console.error = function error(...args) { + throw new ErrorWithStack( + `Unexpected call of console.error() with:\n\n${args.join(', ')}`, + this.error, + ); + }; + const ComponentWithoutRenderFn = { + ...Component, + render() { + return ''; + }, + }; + + try { + mount(ComponentWithoutRenderFn, { propsData: props, ...extraMountArgs }); + } finally { + global.console.error = originalConsoleError; + } +} diff --git a/spec/frontend/__helpers__/wait_for_text.js b/spec/frontend/__helpers__/wait_for_text.js index 6bed8a90a98..991adc5d6c0 100644 --- a/spec/frontend/__helpers__/wait_for_text.js +++ b/spec/frontend/__helpers__/wait_for_text.js @@ -1,3 +1,3 @@ import { findByText } from '@testing-library/dom'; -export const waitForText = async (text, container = document) => findByText(container, text); +export const waitForText = (text, container = document) => findByText(container, text); diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js index 1157e44f41a..c1158e0d124 100644 --- a/spec/frontend/access_tokens/index_spec.js +++ b/spec/frontend/access_tokens/index_spec.js @@ -112,7 +112,7 @@ describe('access tokens', () => { ); }); - it('mounts component and sets `inputAttrs` prop', async () => { + it('mounts component and sets `inputAttrs` prop', () => { wrapper = createWrapper(initExpiresAtField()); const component = wrapper.findComponent(ExpiresAtField); diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap index 2c2151bfb41..e379aba094c 100644 --- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap +++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap @@ -6,8 +6,9 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` body-class="add-review-item pt-0" cancel-variant="light" dismisslabel="Close" - modalclass="" + modalclass="add-review-item-modal" modalid="add-review-item" + nofocusonshow="true" ok-disabled="true" ok-title="Save changes" scrollable="true" @@ -27,9 +28,13 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` <div class="gl-mt-3" > - <gl-search-box-by-type-stub + <gl-filtered-search-stub + availabletokens="[object Object],[object Object],[object Object]" + class="flex-grow-1" clearbuttontitle="Clear" - placeholder="Search by commit title or SHA" + placeholder="Search or filter commits" + searchbuttonattributes="[object Object]" + searchinputattributes="[object Object]" value="" /> diff --git a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js index 5e96da9af7e..27fe010c354 100644 --- a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js +++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js @@ -1,4 +1,4 @@ -import { GlModal, GlSearchBoxByType } from '@gitlab/ui'; +import { GlModal, GlFilteredSearch } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -49,7 +49,7 @@ describe('AddContextCommitsModal', () => { }; const findModal = () => wrapper.findComponent(GlModal); - const findSearch = () => wrapper.findComponent(GlSearchBoxByType); + const findSearch = () => wrapper.findComponent(GlFilteredSearch); beforeEach(() => { wrapper = createWrapper(); @@ -68,12 +68,29 @@ describe('AddContextCommitsModal', () => { expect(findSearch().exists()).toBe(true); }); - it('when user starts entering text in search box, it calls action "searchCommits" after waiting for 500s', () => { - const searchText = 'abcd'; - findSearch().vm.$emit('input', searchText); - expect(searchCommits).not.toHaveBeenCalled(); - jest.advanceTimersByTime(500); - expect(searchCommits).toHaveBeenCalledWith(expect.anything(), searchText); + it('when user submits after entering filters in search box, then it calls action "searchCommits"', () => { + const search = [ + 'abcd', + { + type: 'author', + value: { operator: '=', data: 'abhi' }, + }, + { + type: 'committed-before-date', + value: { operator: '=', data: '2022-10-31' }, + }, + { + type: 'committed-after-date', + value: { operator: '=', data: '2022-10-28' }, + }, + ]; + findSearch().vm.$emit('submit', search); + expect(searchCommits).toHaveBeenCalledWith(expect.anything(), { + searchText: 'abcd', + author: 'abhi', + committed_before: '2022-10-31', + committed_after: '2022-10-28', + }); }); it('disabled ok button when no row is selected', () => { diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js new file mode 100644 index 00000000000..e72d0c24d5e --- /dev/null +++ b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js @@ -0,0 +1,166 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { GlButton, GlModal } from '@gitlab/ui'; +import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { sprintf } from '~/locale'; +import { ACTIONS_I18N } from '~/admin/abuse_reports/constants'; +import { mockAbuseReports } from '../mock_data'; + +jest.mock('~/alert'); + +describe('AbuseReportActions', () => { + let wrapper; + + const findRemoveUserAndReportButton = () => wrapper.findAllComponents(GlButton).at(0); + const findBlockUserButton = () => wrapper.findAllComponents(GlButton).at(1); + const findRemoveReportButton = () => wrapper.findAllComponents(GlButton).at(2); + const findConfirmationModal = () => wrapper.findComponent(GlModal); + + const report = mockAbuseReports[0]; + + const createComponent = ({ props, mountFn } = { props: {}, mountFn: mount }) => { + wrapper = mountFn(AbuseReportActions, { + propsData: { + report, + ...props, + }, + }); + }; + const createShallowComponent = (props) => createComponent({ props, mountFn: shallowMount }); + + describe('default', () => { + beforeEach(() => { + createShallowComponent(); + }); + + it('displays "Block user", "Remove user & report", and "Remove report" buttons', () => { + expect(findRemoveUserAndReportButton().text()).toBe(ACTIONS_I18N.removeUserAndReport); + + const blockButton = findBlockUserButton(); + expect(blockButton.text()).toBe(ACTIONS_I18N.blockUser); + expect(blockButton.attributes('disabled')).toBeUndefined(); + + expect(findRemoveReportButton().text()).toBe(ACTIONS_I18N.removeReport); + }); + + it('does not show the confirmation modal initially', () => { + expect(findConfirmationModal().props('visible')).toBe(false); + }); + }); + + describe('block button when user is already blocked', () => { + it('is disabled and has the correct text', () => { + createShallowComponent({ report: { ...report, userBlocked: true } }); + + const button = findBlockUserButton(); + expect(button.text()).toBe(ACTIONS_I18N.alreadyBlocked); + expect(button.attributes('disabled')).toBe('true'); + }); + }); + + describe('actions', () => { + let axiosMock; + + useMockLocationHelper(); + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + + createComponent(); + }); + + afterEach(() => { + axiosMock.restore(); + createAlert.mockClear(); + }); + + describe('on remove user and report', () => { + it('shows confirmation modal and reloads the page on success', async () => { + findRemoveUserAndReportButton().trigger('click'); + await nextTick(); + + expect(findConfirmationModal().props()).toMatchObject({ + visible: true, + title: sprintf(ACTIONS_I18N.removeUserAndReportConfirm, { + user: report.reportedUser.name, + }), + }); + + axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK); + + findConfirmationModal().vm.$emit('primary'); + await axios.waitForAll(); + + expect(window.location.reload).toHaveBeenCalled(); + }); + }); + + describe('on block user', () => { + beforeEach(async () => { + findBlockUserButton().trigger('click'); + await nextTick(); + }); + + it('shows confirmation modal', () => { + expect(findConfirmationModal().props()).toMatchObject({ + visible: true, + title: ACTIONS_I18N.blockUserConfirm, + }); + }); + + describe.each([ + { + responseData: { notice: 'Notice' }, + createAlertArgs: { message: 'Notice', variant: VARIANT_SUCCESS }, + blockButtonText: ACTIONS_I18N.alreadyBlocked, + blockButtonDisabled: 'disabled', + }, + { + responseData: { error: 'Error' }, + createAlertArgs: { message: 'Error' }, + blockButtonText: ACTIONS_I18N.blockUser, + blockButtonDisabled: undefined, + }, + ])( + 'when reponse JSON is $responseData', + ({ responseData, createAlertArgs, blockButtonText, blockButtonDisabled }) => { + beforeEach(async () => { + axiosMock.onPut(report.blockUserPath).reply(HTTP_STATUS_OK, responseData); + + findConfirmationModal().vm.$emit('primary'); + await axios.waitForAll(); + }); + + it('updates the block button correctly', () => { + const button = findBlockUserButton(); + expect(button.text()).toBe(blockButtonText); + expect(button.attributes('disabled')).toBe(blockButtonDisabled); + }); + + it('displays the returned message', () => { + expect(createAlert).toHaveBeenCalledWith(createAlertArgs); + }); + }, + ); + }); + + describe('on remove report', () => { + it('reloads the page on success', async () => { + axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK); + + findRemoveReportButton().trigger('click'); + + expect(findConfirmationModal().props('visible')).toBe(false); + + await axios.waitForAll(); + + expect(window.location.reload).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_details_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_details_spec.js new file mode 100644 index 00000000000..b89bbac0196 --- /dev/null +++ b/spec/frontend/admin/abuse_reports/components/abuse_report_details_spec.js @@ -0,0 +1,53 @@ +import { GlButton, GlCollapse } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import AbuseReportDetails from '~/admin/abuse_reports/components/abuse_report_details.vue'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import { mockAbuseReports } from '../mock_data'; + +describe('AbuseReportDetails', () => { + let wrapper; + const report = mockAbuseReports[0]; + + const findToggleButton = () => wrapper.findComponent(GlButton); + const findCollapsible = () => wrapper.findComponent(GlCollapse); + + const createComponent = () => { + wrapper = shallowMount(AbuseReportDetails, { + propsData: { + report, + }, + }); + }; + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders toggle button with the correct text', () => { + expect(findToggleButton().text()).toEqual('Show details'); + }); + + it('renders collapsed GlCollapse containing the report details', () => { + const collapsible = findCollapsible(); + expect(collapsible.attributes('visible')).toBeUndefined(); + + const userJoinedText = `User joined ${getTimeago().format(report.reportedUser.createdAt)}`; + expect(collapsible.text()).toMatch(userJoinedText); + expect(collapsible.text()).toMatch(report.message); + }); + }); + + describe('when toggled', () => { + it('expands GlCollapse and updates toggle text', async () => { + createComponent(); + + findToggleButton().vm.$emit('click'); + await nextTick(); + + expect(findToggleButton().text()).toEqual('Hide details'); + expect(findCollapsible().attributes('visible')).toBe('true'); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js index d32fa25d238..9876ee70e5e 100644 --- a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js +++ b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js @@ -1,22 +1,31 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import AbuseReportDetails from '~/admin/abuse_reports/components/abuse_report_details.vue'; import AbuseReportRow from '~/admin/abuse_reports/components/abuse_report_row.vue'; +import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { getTimeago } from '~/lib/utils/datetime_utility'; +import { SORT_UPDATED_AT } from '~/admin/abuse_reports/constants'; import { mockAbuseReports } from '../mock_data'; describe('AbuseReportRow', () => { let wrapper; const mockAbuseReport = mockAbuseReports[0]; + const findLinks = () => wrapper.findAllComponents(GlLink); + const findAbuseReportActions = () => wrapper.findComponent(AbuseReportActions); const findListItem = () => wrapper.findComponent(ListItem); const findTitle = () => wrapper.findByTestId('title'); - const findUpdatedAt = () => wrapper.findByTestId('updated-at'); + const findDisplayedDate = () => wrapper.findByTestId('abuse-report-date'); + const findAbuseReportDetails = () => wrapper.findComponent(AbuseReportDetails); const createComponent = () => { wrapper = shallowMountExtended(AbuseReportRow, { propsData: { report: mockAbuseReport, }, + stubs: { GlSprintf }, }); }; @@ -29,15 +38,49 @@ describe('AbuseReportRow', () => { }); it('displays correctly formatted title', () => { - const { reporter, reportedUser, category } = mockAbuseReport; + const { reporter, reportedUser, category, reportedUserPath, reporterPath } = mockAbuseReport; expect(findTitle().text()).toMatchInterpolatedText( `${reportedUser.name} reported for ${category} by ${reporter.name}`, ); + + const userLink = findLinks().at(0); + expect(userLink.text()).toEqual(reportedUser.name); + expect(userLink.attributes('href')).toEqual(reportedUserPath); + + const reporterLink = findLinks().at(1); + expect(reporterLink.text()).toEqual(reporter.name); + expect(reporterLink.attributes('href')).toEqual(reporterPath); }); - it('displays correctly formatted updated at', () => { - expect(findUpdatedAt().text()).toMatchInterpolatedText( - `Updated ${getTimeago().format(mockAbuseReport.updatedAt)}`, - ); + describe('displayed date', () => { + it('displays correctly formatted created at', () => { + expect(findDisplayedDate().text()).toMatchInterpolatedText( + `Created ${getTimeago().format(mockAbuseReport.createdAt)}`, + ); + }); + + describe('when sorted by updated_at', () => { + it('displays correctly formatted updated at', () => { + setWindowLocation(`?sort=${SORT_UPDATED_AT.sortDirection.ascending}`); + + createComponent(); + + expect(findDisplayedDate().text()).toMatchInterpolatedText( + `Updated ${getTimeago().format(mockAbuseReport.updatedAt)}`, + ); + }); + }); + }); + + it('renders AbuseReportDetails', () => { + expect(findAbuseReportDetails().exists()).toBe(true); + expect(findAbuseReportDetails().props('report')).toEqual(mockAbuseReport); + }); + + it('renders AbuseReportRowActions with the correct props', () => { + const actions = findAbuseReportActions(); + + expect(actions.exists()).toBe(true); + expect(actions.props('report')).toMatchObject(mockAbuseReport); }); }); diff --git a/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js index 9efab8403a0..990503c453d 100644 --- a/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js +++ b/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js @@ -58,21 +58,28 @@ describe('AbuseReportsFilteredSearchBar', () => { }); }); - it('sets status=open query when there is no initial status query', () => { - createComponent(); + it.each([undefined, 'invalid'])( + 'sets status=open query when initial status query is %s', + (status) => { + if (status) { + setWindowLocation(`?status=${status}`); + } - expect(updateHistory).toHaveBeenCalledWith({ - url: 'https://localhost/?status=open', - replace: true, - }); + createComponent(); - expect(findFilteredSearchBar().props('initialFilterValue')).toMatchObject([ - { - type: FILTERED_SEARCH_TOKEN_STATUS.type, - value: { data: 'open', operator: '=' }, - }, - ]); - }); + expect(updateHistory).toHaveBeenCalledWith({ + url: 'https://localhost/?status=open', + replace: true, + }); + + expect(findFilteredSearchBar().props('initialFilterValue')).toMatchObject([ + { + type: FILTERED_SEARCH_TOKEN_STATUS.type, + value: { data: 'open', operator: '=' }, + }, + ]); + }, + ); it('parses and passes search param to `FilteredSearchBar` component as `initialFilterValue` prop', () => { setWindowLocation('?status=closed&user=mr_abuser&reporter=ms_nitch'); diff --git a/spec/frontend/admin/abuse_reports/mock_data.js b/spec/frontend/admin/abuse_reports/mock_data.js index 778f055eb82..90289757a74 100644 --- a/spec/frontend/admin/abuse_reports/mock_data.js +++ b/spec/frontend/admin/abuse_reports/mock_data.js @@ -1,14 +1,30 @@ export const mockAbuseReports = [ { category: 'spam', + createdAt: '2018-10-03T05:46:38.977Z', updatedAt: '2022-12-07T06:45:39.977Z', reporter: { name: 'Ms. Admin' }, - reportedUser: { name: 'Mr. Abuser' }, + reportedUser: { name: 'Mr. Abuser', createdAt: '2017-09-01T05:46:38.977Z' }, + reportedUserPath: '/mr_abuser', + reporterPath: '/admin', + userBlocked: false, + blockUserPath: '/block/user/mr_abuser/path', + removeUserAndReportPath: '/remove/user/mr_abuser/and/report/path', + removeReportPath: '/remove/report/path', + message: 'message 1', }, { category: 'phishing', + createdAt: '2018-10-03T05:46:38.977Z', updatedAt: '2022-12-07T06:45:39.977Z', reporter: { name: 'Ms. Reporter' }, - reportedUser: { name: 'Mr. Phisher' }, + reportedUser: { name: 'Mr. Phisher', createdAt: '2016-09-01T05:46:38.977Z' }, + reportedUserPath: '/mr_phisher', + reporterPath: '/admin', + userBlocked: false, + blockUserPath: '/block/user/mr_phisher/path', + removeUserAndReportPath: '/remove/user/mr_phisher/and/report/path', + removeReportPath: '/remove/report/path', + message: 'message 2', }, ]; diff --git a/spec/frontend/admin/abuse_reports/utils_spec.js b/spec/frontend/admin/abuse_reports/utils_spec.js index 17f0b9acb26..3908edd3fdd 100644 --- a/spec/frontend/admin/abuse_reports/utils_spec.js +++ b/spec/frontend/admin/abuse_reports/utils_spec.js @@ -1,5 +1,8 @@ -import { FILTERED_SEARCH_TOKEN_CATEGORY } from '~/admin/abuse_reports/constants'; -import { buildFilteredSearchCategoryToken } from '~/admin/abuse_reports/utils'; +import { + FILTERED_SEARCH_TOKEN_CATEGORY, + FILTERED_SEARCH_TOKEN_STATUS, +} from '~/admin/abuse_reports/constants'; +import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils'; describe('buildFilteredSearchCategoryToken', () => { it('adds correctly formatted options to FILTERED_SEARCH_TOKEN_CATEGORY', () => { @@ -11,3 +14,18 @@ describe('buildFilteredSearchCategoryToken', () => { }); }); }); + +describe('isValidStatus', () => { + const validStatuses = FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value); + + it.each(validStatuses)( + 'returns true when status is an option value of FILTERED_SEARCH_TOKEN_STATUS', + (status) => { + expect(isValidStatus(status)).toBe(true); + }, + ); + + it('return false when status is not an option value of FILTERED_SEARCH_TOKEN_STATUS', () => { + expect(isValidStatus('invalid')).toBe(false); + }); +}); diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index 4aeaa5356b4..a5e7c6ebe21 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Actions from '~/admin/users/components/actions'; import Delete from '~/admin/users/components/actions/delete.vue'; @@ -12,7 +12,7 @@ import { paths, userDeletionObstacles } from '../../mock_data'; describe('Action components', () => { let wrapper; - const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findDisclosureDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); const initComponent = ({ component, props } = {}) => { wrapper = shallowMount(component, { @@ -32,7 +32,7 @@ describe('Action components', () => { }, }); - expect(findDropdownItem().exists()).toBe(true); + expect(findDisclosureDropdownItem().exists()).toBe(true); }); }); @@ -52,7 +52,7 @@ describe('Action components', () => { }, }); - await findDropdownItem().vm.$emit('click'); + await findDisclosureDropdownItem().vm.$emit('action'); expect(eventHub.$emit).toHaveBeenCalledWith( EVENT_OPEN_DELETE_USER_MODAL, diff --git a/spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js b/spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js index 64a88aab2c2..606a5c779fb 100644 --- a/spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js +++ b/spec/frontend/admin/users/components/actions/delete_with_contributions_spec.js @@ -1,5 +1,5 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import DeleteWithContributions from '~/admin/users/components/actions/delete_with_contributions.vue'; import eventHub, { @@ -35,7 +35,7 @@ describe('DeleteWithContributions', () => { }; const createComponent = () => { - wrapper = mountExtended(DeleteWithContributions, { propsData: defaultPropsData }); + wrapper = mount(DeleteWithContributions, { propsData: defaultPropsData }); }; describe('when action is clicked', () => { @@ -47,10 +47,10 @@ describe('DeleteWithContributions', () => { }); it('displays loading icon and disables button', async () => { - await wrapper.trigger('click'); + await wrapper.find('button').trigger('click'); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.findByRole('menuitem').attributes()).toMatchObject({ + expect(wrapper.attributes()).toMatchObject({ disabled: 'disabled', 'aria-busy': 'true', }); @@ -67,7 +67,7 @@ describe('DeleteWithContributions', () => { }); it('emits event with association counts', async () => { - await wrapper.trigger('click'); + await wrapper.find('button').trigger('click'); await waitForPromises(); expect(associationsCount).toHaveBeenCalledWith(defaultPropsData.userId); @@ -92,7 +92,7 @@ describe('DeleteWithContributions', () => { }); it('emits event with error', async () => { - await wrapper.trigger('click'); + await wrapper.find('button').trigger('click'); await waitForPromises(); expect(eventHub.$emit).toHaveBeenCalledWith( diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js index 1a2cc3e5c34..73d8c082bb9 100644 --- a/spec/frontend/admin/users/components/user_actions_spec.js +++ b/spec/frontend/admin/users/components/user_actions_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownDivider } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Actions from '~/admin/users/components/actions'; @@ -19,7 +19,7 @@ describe('AdminUserActions component', () => { const findEditButton = (id = user.id) => findUserActions(id).find('[data-testid="edit"]'); const findActionsDropdown = (id = user.id) => findUserActions(id).find('[data-testid="dropdown-toggle"]'); - const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); + const findDisclosureGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup); const initComponent = ({ actions = [], showButtonLabels } = {}) => { wrapper = shallowMountExtended(AdminUserActions, { @@ -104,8 +104,8 @@ describe('AdminUserActions component', () => { initComponent({ actions: [LDAP, ...DELETE_ACTIONS] }); }); - it('renders a dropdown divider', () => { - expect(findDropdownDivider().exists()).toBe(true); + it('renders a disclosure group', () => { + expect(findDisclosureGroup().exists()).toBe(true); }); it('only renders delete dropdown items for actions containing the word "delete"', () => { @@ -126,8 +126,8 @@ describe('AdminUserActions component', () => { }); describe('when there are no delete actions', () => { - it('does not render a dropdown divider', () => { - expect(findDropdownDivider().exists()).toBe(false); + it('does not render a disclosure group', () => { + expect(findDisclosureGroup().exists()).toBe(false); }); it('does not render a delete dropdown item', () => { diff --git a/spec/frontend/admin/users/new_spec.js b/spec/frontend/admin/users/new_spec.js index 5e5763822a8..eba5c87f470 100644 --- a/spec/frontend/admin/users/new_spec.js +++ b/spec/frontend/admin/users/new_spec.js @@ -1,20 +1,19 @@ +import newWithInternalUserRegex from 'test_fixtures/admin/users/new_with_internal_user_regex.html'; import { setupInternalUserRegexHandler, ID_USER_EMAIL, ID_USER_EXTERNAL, ID_WARNING, } from '~/admin/users/new'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('admin/users/new', () => { - const FIXTURE = 'admin/users/new_with_internal_user_regex.html'; - let elExternal; let elUserEmail; let elWarningMessage; beforeEach(() => { - loadHTMLFixture(FIXTURE); + setHTMLFixture(newWithInternalUserRegex); setupInternalUserRegexHandler(); elExternal = document.getElementById(ID_USER_EXTERNAL); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index 4a60d605cae..202a0a04192 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -61,6 +61,7 @@ exports[`Alert integration settings form default state should match the default items="[object Object]" noresultstext="No results found" placement="left" + popperoptions="[object Object]" resetbuttonlabel="" searchplaceholder="Search" selected="selecte_tmpl" diff --git a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js index 1e125bdfd3a..2b8479eab6d 100644 --- a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js +++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js @@ -49,7 +49,7 @@ describe('AlertMappingBuilder', () => { const fallbackColumnIcon = findColumnInRow(0, 3).findComponent(GlIcon); expect(fallbackColumnIcon.exists()).toBe(true); - expect(fallbackColumnIcon.attributes('name')).toBe('question'); + expect(fallbackColumnIcon.attributes('name')).toBe('question-o'); expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip); }); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index e0075aa71d9..b8575d8ab26 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -97,7 +97,7 @@ describe('AlertsSettingsForm', () => { expect(findFormFields().at(0).isVisible()).toBe(true); }); - it('disables the dropdown and shows help text when multi integrations are not supported', async () => { + it('disables the dropdown and shows help text when multi integrations are not supported', () => { createComponent({ props: { canAddIntegration: false } }); expect(findSelect().attributes('disabled')).toBe('disabled'); expect(findMultiSupportText().exists()).toBe(true); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 67d8619f157..8c5df06042c 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -429,7 +429,7 @@ describe('AlertsSettingsWrapper', () => { }); describe('Test alert', () => { - it('makes `updateTestAlert` service call', async () => { + it('makes `updateTestAlert` service call', () => { jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce(); const testPayload = '{"title":"test"}'; findAlertsSettingsForm().vm.$emit('test-alert-payload', testPayload); diff --git a/spec/frontend/analytics/cycle_analytics/base_spec.js b/spec/frontend/analytics/cycle_analytics/base_spec.js index 033916eabcd..1a1d22626ea 100644 --- a/spec/frontend/analytics/cycle_analytics/base_spec.js +++ b/spec/frontend/analytics/cycle_analytics/base_spec.js @@ -137,6 +137,7 @@ describe('Value stream analytics component', () => { it('passes the paths to the filter bar', () => { expect(findFilters().props()).toEqual({ groupPath, + namespacePath: groupPath, endDate: createdBefore, hasDateRangeFilter: true, hasProjectFilter: false, diff --git a/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js b/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js index da7824adbf9..f1b3af39199 100644 --- a/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js +++ b/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js @@ -85,7 +85,7 @@ describe('Filter bar', () => { return shallowMount(FilterBar, { store: initialStore, propsData: { - groupPath: 'foo', + namespacePath: 'foo', }, stubs: { UrlSync, diff --git a/spec/frontend/analytics/cycle_analytics/mock_data.js b/spec/frontend/analytics/cycle_analytics/mock_data.js index 216e07844b8..f9587bf1967 100644 --- a/spec/frontend/analytics/cycle_analytics/mock_data.js +++ b/spec/frontend/analytics/cycle_analytics/mock_data.js @@ -214,7 +214,7 @@ export const group = { id: 1, name: 'foo', path: 'foo', - full_path: 'foo', + full_path: 'groups/foo', avatar_url: `${TEST_HOST}/images/home/nasa.svg`, }; diff --git a/spec/frontend/analytics/cycle_analytics/utils_spec.js b/spec/frontend/analytics/cycle_analytics/utils_spec.js index e6d17edcadc..ab5d78bde51 100644 --- a/spec/frontend/analytics/cycle_analytics/utils_spec.js +++ b/spec/frontend/analytics/cycle_analytics/utils_spec.js @@ -91,7 +91,7 @@ describe('Value stream analytics utils', () => { const projectId = '5'; const createdAfter = '2021-09-01'; const createdBefore = '2021-11-06'; - const groupPath = 'fake-group'; + const groupPath = 'groups/fake-group'; const namespaceName = 'Fake project'; const namespaceFullPath = 'fake-group/fake-project'; const labelsPath = '/fake-group/fake-project/-/labels.json'; @@ -130,7 +130,7 @@ describe('Value stream analytics utils', () => { }); it('sets the endpoints', () => { - expect(res.groupPath).toBe(`groups/${groupPath}`); + expect(res.groupPath).toBe(groupPath); }); it('returns null when there is no stage', () => { @@ -158,10 +158,13 @@ describe('Value stream analytics utils', () => { describe('with features set', () => { const fakeFeatures = { cycleAnalyticsForGroups: true }; + beforeEach(() => { + window.gon = { licensed_features: fakeFeatures }; + }); + it('sets the feature flags', () => { res = buildCycleAnalyticsInitialData({ ...rawData, - gon: { licensed_features: fakeFeatures }, }); expect(res.features).toMatchObject(fakeFeatures); }); diff --git a/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js b/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js index 160f6ce0563..c6915c9054c 100644 --- a/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js +++ b/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js @@ -10,12 +10,15 @@ import { selectedProjects, } from './mock_data'; +const { path } = currentGroup; +const groupPath = `groups/${path}`; + function createComponent(props = {}) { return shallowMount(ValueStreamFilters, { propsData: { selectedProjects, - groupId: currentGroup.id, - groupPath: currentGroup.fullPath, + groupPath, + namespacePath: currentGroup.fullPath, startDate, endDate, ...props, diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index d2cbe0d39e4..33801fb8552 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlTruncate } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlTruncate, GlSearchBoxByType } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; @@ -31,6 +31,7 @@ const projects = [ const MockGlDropdown = stubComponent(GlDropdown, { template: ` <div> + <slot name="header"></slot> <div data-testid="vsa-highlighted-items"> <slot name="highlighted-items"></slot> </div> @@ -112,6 +113,8 @@ describe('ProjectsDropdownFilter component', () => { const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id); + const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); + describe('queryParams are applied when fetching data', () => { beforeEach(() => { createComponent({ @@ -123,9 +126,7 @@ describe('ProjectsDropdownFilter component', () => { }); it('applies the correct queryParams when making an api call', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ searchTerm: 'gitlab' }); + findSearchBoxByType().vm.$emit('input', 'gitlab'); expect(spyQuery).toHaveBeenCalledTimes(1); @@ -144,6 +145,7 @@ describe('ProjectsDropdownFilter component', () => { describe('highlighted items', () => { const blockDefaultProps = { multiSelect: true }; + beforeEach(() => { createComponent(blockDefaultProps); }); @@ -151,6 +153,7 @@ describe('ProjectsDropdownFilter component', () => { describe('with no project selected', () => { it('does not render the highlighted items', async () => { await createWithMockDropdown(blockDefaultProps); + expect(findSelectedDropdownItems().length).toBe(0); }); @@ -188,8 +191,7 @@ describe('ProjectsDropdownFilter component', () => { expect(findSelectedProjectsLabel().text()).toBe('2 projects selected'); - findClearAllButton().trigger('click'); - await nextTick(); + await findClearAllButton().trigger('click'); expect(findSelectedProjectsLabel().text()).toBe('Select projects'); }); @@ -201,16 +203,14 @@ describe('ProjectsDropdownFilter component', () => { await createWithMockDropdown({ multiSelect: true }); selectDropdownItemAtIndex(0); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ searchTerm: 'this is a very long search string' }); + findSearchBoxByType().vm.$emit('input', 'this is a very long search string'); }); - it('renders the highlighted items', async () => { + it('renders the highlighted items', () => { expect(findUnhighlightedItems().findAll('li').length).toBe(1); }); - it('hides the unhighlighted items that do not match the string', async () => { + it('hides the unhighlighted items that do not match the string', () => { expect(findUnhighlightedItems().findAll('li').length).toBe(1); expect(findUnhighlightedItems().text()).toContain('No matching results'); }); @@ -351,17 +351,19 @@ describe('ProjectsDropdownFilter component', () => { it('should remove from selection when clicked again', () => { selectDropdownItemAtIndex(0); + expect(selectedIds()).toEqual([projects[0].id]); selectDropdownItemAtIndex(0); + expect(selectedIds()).toEqual([]); }); it('renders the correct placeholder text when multiple projects are selected', async () => { selectDropdownItemAtIndex(0); selectDropdownItemAtIndex(1); - await nextTick(); + expect(findDropdownButton().text()).toBe('2 projects selected'); }); }); diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js index 2de56fae0c2..4ceed885e6e 100644 --- a/spec/frontend/api/projects_api_spec.js +++ b/spec/frontend/api/projects_api_spec.js @@ -9,14 +9,11 @@ describe('~/api/projects_api.js', () => { let mock; const projectId = 1; - const setfullPathProjectSearch = (value) => { - window.gon.features.fullPathProjectSearch = value; - }; beforeEach(() => { mock = new MockAdapter(axios); - window.gon = { api_version: 'v7', features: { fullPathProjectSearch: true } }; + window.gon = { api_version: 'v7' }; }); afterEach(() => { @@ -68,17 +65,18 @@ describe('~/api/projects_api.js', () => { expect(data.data).toEqual(expectedProjects); }); }); + }); - it('does not search namespaces if fullPathProjectSearch is disabled', () => { - setfullPathProjectSearch(false); - const expectedParams = { params: { per_page: 20, search: 'group/project1', simple: true } }; - const query = 'group/project1'; + describe('createProject', () => { + it('posts to the correct URL and returns the data', () => { + const body = { name: 'test project' }; + const expectedUrl = '/api/v7/projects.json'; + const expectedRes = { id: 999, name: 'test project' }; - mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { data: expectedProjects }); + mock.onPost(expectedUrl, body).replyOnce(HTTP_STATUS_OK, { data: expectedRes }); - return projectsApi.getProjects(query, options).then(({ data }) => { - expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedParams); - expect(data.data).toEqual(expectedProjects); + return projectsApi.createProject(body).then(({ data }) => { + expect(data).toStrictEqual(expectedRes); }); }); }); diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js index 6636d77a09b..a879c229581 100644 --- a/spec/frontend/api/user_api_spec.js +++ b/spec/frontend/api/user_api_spec.js @@ -1,6 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; -import { followUser, unfollowUser, associationsCount, updateUserStatus } from '~/api/user_api'; +import projects from 'test_fixtures/api/users/projects/get.json'; +import { + followUser, + unfollowUser, + associationsCount, + updateUserStatus, + getUserProjects, +} from '~/api/user_api'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { @@ -91,4 +98,18 @@ describe('~/api/user_api', () => { expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual(expectedData); }); }); + + describe('getUserProjects', () => { + it('calls correct URL and returns expected response', async () => { + const expectedUrl = '/api/v4/users/1/projects'; + const expectedResponse = { data: projects }; + + axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedResponse); + + await expect(getUserProjects(1)).resolves.toEqual( + expect.objectContaining({ data: expectedResponse }), + ); + expect(axiosMock.history.get[0].url).toBe(expectedUrl); + }); + }); }); diff --git a/spec/frontend/artifacts/components/artifacts_bulk_delete_spec.js b/spec/frontend/artifacts/components/artifacts_bulk_delete_spec.js deleted file mode 100644 index 876906b2c3c..00000000000 --- a/spec/frontend/artifacts/components/artifacts_bulk_delete_spec.js +++ /dev/null @@ -1,96 +0,0 @@ -import { GlSprintf, GlModal } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import mockGetJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import ArtifactsBulkDelete from '~/artifacts/components/artifacts_bulk_delete.vue'; -import bulkDestroyArtifactsMutation from '~/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql'; - -Vue.use(VueApollo); - -describe('ArtifactsBulkDelete component', () => { - let wrapper; - let requestHandlers; - - const projectId = '123'; - const selectedArtifacts = [ - mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[0].id, - mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[1].id, - ]; - - const findText = () => wrapper.findComponent(GlSprintf).text(); - const findDeleteButton = () => wrapper.findByTestId('bulk-delete-delete-button'); - const findClearButton = () => wrapper.findByTestId('bulk-delete-clear-button'); - const findModal = () => wrapper.findComponent(GlModal); - - const createComponent = ({ - handlers = { - bulkDestroyArtifactsMutation: jest.fn(), - }, - } = {}) => { - requestHandlers = handlers; - wrapper = mountExtended(ArtifactsBulkDelete, { - apolloProvider: createMockApollo([ - [bulkDestroyArtifactsMutation, requestHandlers.bulkDestroyArtifactsMutation], - ]), - propsData: { - selectedArtifacts, - queryVariables: {}, - isLoading: false, - isLastRow: false, - }, - provide: { projectId }, - }); - }; - - describe('selected artifacts box', () => { - beforeEach(async () => { - createComponent(); - await waitForPromises(); - }); - - it('displays selected artifacts count', () => { - expect(findText()).toContain(String(selectedArtifacts.length)); - }); - - it('opens the confirmation modal when the delete button is clicked', async () => { - expect(findModal().props('visible')).toBe(false); - - findDeleteButton().trigger('click'); - await waitForPromises(); - - expect(findModal().props('visible')).toBe(true); - }); - - it('emits clearSelectedArtifacts event when the clear button is clicked', () => { - findClearButton().trigger('click'); - - expect(wrapper.emitted('clearSelectedArtifacts')).toBeDefined(); - }); - }); - - describe('bulk delete confirmation modal', () => { - beforeEach(async () => { - createComponent(); - findDeleteButton().trigger('click'); - await waitForPromises(); - }); - - it('calls the bulk delete mutation with the selected artifacts on confirm', () => { - findModal().vm.$emit('primary'); - - expect(requestHandlers.bulkDestroyArtifactsMutation).toHaveBeenCalledWith({ - projectId: `gid://gitlab/Project/${projectId}`, - ids: selectedArtifacts, - }); - }); - - it('does not call the bulk delete mutation on cancel', () => { - findModal().vm.$emit('cancel'); - - expect(requestHandlers.bulkDestroyArtifactsMutation).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js index efdebe5f3b0..50ac7be9ae3 100644 --- a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js +++ b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js @@ -1,7 +1,8 @@ import { GlFormCheckbox, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import UpdateKeepLatestArtifactProjectSetting from '~/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql'; import GetKeepLatestArtifactApplicationSetting from '~/artifacts_settings/graphql/queries/get_keep_latest_artifact_application_setting.query.graphql'; @@ -102,19 +103,16 @@ describe('Keep latest artifact checkbox', () => { }); describe('when application keep latest artifact setting is enabled', () => { - beforeEach(() => { + beforeEach(async () => { createComponent(); + await waitForPromises(); }); - it('sets correct setting value in checkbox with query result', async () => { - await nextTick(); - + it('sets correct setting value in checkbox with query result', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('checkbox is enabled when application setting is enabled', async () => { - await nextTick(); - + it('checkbox is enabled when application setting is enabled', () => { expect(findCheckbox().attributes('disabled')).toBeUndefined(); }); }); diff --git a/spec/frontend/authentication/password/components/password_input_spec.js b/spec/frontend/authentication/password/components/password_input_spec.js new file mode 100644 index 00000000000..623d986b36e --- /dev/null +++ b/spec/frontend/authentication/password/components/password_input_spec.js @@ -0,0 +1,49 @@ +import { GlFormInput, GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PasswordInput from '~/authentication/password/components/password_input.vue'; +import { SHOW_PASSWORD, HIDE_PASSWORD } from '~/authentication/password/constants'; + +describe('PasswordInput', () => { + let wrapper; + + const findPasswordInput = () => wrapper.findComponent(GlFormInput); + const findToggleButton = () => wrapper.findComponent(GlButton); + + const createComponent = () => { + return shallowMount(PasswordInput, { + propsData: { + resourceName: 'new_user', + minimumPasswordLength: '8', + qaSelector: 'new_user_password_field', + }, + }); + }; + + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('when the show password button is clicked', () => { + beforeEach(() => { + findToggleButton().vm.$emit('click'); + }); + + it('displays hide password button', () => { + expect(findPasswordInput().attributes('type')).toBe('text'); + expect(findToggleButton().attributes('icon')).toBe('eye-slash'); + expect(findToggleButton().attributes('aria-label')).toBe(HIDE_PASSWORD); + }); + + describe('when the hide password button is clicked', () => { + beforeEach(() => { + findToggleButton().vm.$emit('click'); + }); + + it('displays show password button', () => { + expect(findPasswordInput().attributes('type')).toBe('password'); + expect(findToggleButton().attributes('icon')).toBe('eye'); + expect(findToggleButton().attributes('aria-label')).toBe(SHOW_PASSWORD); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js index 694c16a85c4..8ecef710e03 100644 --- a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js @@ -19,7 +19,6 @@ describe('ManageTwoFactorForm', () => { wrapper = mountExtended(ManageTwoFactorForm, { provide: { ...defaultProvide, - webauthnEnabled: options?.webauthnEnabled ?? false, isCurrentPasswordRequired: options?.currentPasswordRequired ?? true, }, stubs: { @@ -41,16 +40,6 @@ describe('ManageTwoFactorForm', () => { const findRegenerateCodesButton = () => wrapper.findByTestId('test-2fa-regenerate-codes-button'); const findConfirmationModal = () => wrapper.findComponent(GlModal); - const itShowsConfirmationModal = (confirmText) => { - it('shows confirmation modal', async () => { - await wrapper.findByLabelText('Current password').setValue('foo bar'); - await findDisableButton().trigger('click'); - - expect(findConfirmationModal().props('visible')).toBe(true); - expect(findConfirmationModal().html()).toContain(confirmText); - }); - }; - const itShowsValidationMessageIfCurrentPasswordFieldIsEmpty = (findButtonFunction) => { it('shows validation message if `Current password` is empty', async () => { await findButtonFunction().trigger('click'); @@ -91,16 +80,12 @@ describe('ManageTwoFactorForm', () => { describe('when clicked', () => { itShowsValidationMessageIfCurrentPasswordFieldIsEmpty(findDisableButton); - itShowsConfirmationModal(i18n.confirm); - - describe('when webauthnEnabled', () => { - beforeEach(() => { - createComponent({ - webauthnEnabled: true, - }); - }); + it('shows confirmation modal', async () => { + await wrapper.findByLabelText('Current password').setValue('foo bar'); + await findDisableButton().trigger('click'); - itShowsConfirmationModal(i18n.confirmWebAuthn); + expect(findConfirmationModal().props('visible')).toBe(true); + expect(findConfirmationModal().html()).toContain(i18n.confirmWebAuthn); }); it('modifies the form action and method when submitted through the button', async () => { diff --git a/spec/frontend/authentication/webauthn/components/registration_spec.js b/spec/frontend/authentication/webauthn/components/registration_spec.js index 1221626db7d..e4ca1ac8c38 100644 --- a/spec/frontend/authentication/webauthn/components/registration_spec.js +++ b/spec/frontend/authentication/webauthn/components/registration_spec.js @@ -211,7 +211,7 @@ describe('Registration', () => { }); describe(`when ${STATE_ERROR} state`, () => { - it('shows an initial error message and a retry button', async () => { + it('shows an initial error message and a retry button', () => { const myError = 'my error'; createComponent({ initialError: myError }); diff --git a/spec/frontend/batch_comments/components/review_bar_spec.js b/spec/frontend/batch_comments/components/review_bar_spec.js index 923e86a7e64..ea4b015ea39 100644 --- a/spec/frontend/batch_comments/components/review_bar_spec.js +++ b/spec/frontend/batch_comments/components/review_bar_spec.js @@ -20,7 +20,7 @@ describe('Batch comments review bar component', () => { document.body.className = ''; }); - it('adds review-bar-visible class to body when review bar is mounted', async () => { + it('adds review-bar-visible class to body when review bar is mounted', () => { expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(false); createComponent(); @@ -28,7 +28,7 @@ describe('Batch comments review bar component', () => { expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(true); }); - it('removes review-bar-visible class to body when review bar is destroyed', async () => { + it('removes review-bar-visible class to body when review bar is destroyed', () => { createComponent(); wrapper.destroy(); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index 722327e94ba..995e4219ae3 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -51,13 +51,13 @@ describe('gl_emoji', () => { 'bomb emoji just with name attribute', '<gl-emoji data-name="bomb"></gl-emoji>', '<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', - `<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>`, + `<gl-emoji data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>`, ], [ 'bomb emoji with name attribute and unicode version', '<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>', '<gl-emoji data-name="bomb" data-unicode-version="6.0">💣</gl-emoji>', - `<gl-emoji data-name="bomb" data-unicode-version="6.0"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>`, + `<gl-emoji data-name="bomb" data-unicode-version="6.0"><img class="emoji" title=":bomb:" alt=":bomb:" src="/-/emojis/${EMOJI_VERSION}/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>`, ], [ 'bomb emoji with sprite fallback', @@ -69,19 +69,19 @@ describe('gl_emoji', () => { 'bomb emoji with image fallback', '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb"></gl-emoji>', '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb">💣</gl-emoji>', - '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/bomb.png" width="20" height="20" align="absmiddle"></gl-emoji>', + '<gl-emoji data-fallback-src="/bomb.png" data-name="bomb" data-unicode-version="6.0" title="bomb"><img class="emoji" title=":bomb:" alt=":bomb:" src="/bomb.png" width="16" height="16" align="absmiddle"></gl-emoji>', ], [ 'invalid emoji', '<gl-emoji data-name="invalid_emoji"></gl-emoji>', '<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>', - `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>`, + `<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="16" height="16" align="absmiddle"></gl-emoji>`, ], [ 'custom emoji with image fallback', '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>', - '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>', - '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="16" height="16" align="absmiddle"></gl-emoji>', + '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="16" height="16" align="absmiddle"></gl-emoji>', ], ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { it(`renders correctly with emoji support`, async () => { @@ -111,7 +111,7 @@ describe('gl_emoji', () => { await waitForPromises(); expect(glEmojiElement.outerHTML).toBe( - '<gl-emoji data-name=""x="y" onload="alert(document.location.href)"" data-unicode-version="x"><img class="emoji" title=":"x="y" onload="alert(document.location.href)":" alt=":"x="y" onload="alert(document.location.href)":" src="/-/emojis/2/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>', + '<gl-emoji data-name=""x="y" onload="alert(document.location.href)"" data-unicode-version="x"><img class="emoji" title=":"x="y" onload="alert(document.location.href)":" alt=":"x="y" onload="alert(document.location.href)":" src="/-/emojis/2/grey_question.png" width="16" height="16" align="absmiddle"></gl-emoji>', ); }); diff --git a/spec/frontend/behaviors/quick_submit_spec.js b/spec/frontend/behaviors/quick_submit_spec.js index 317c671cd2b..81eeb3f153e 100644 --- a/spec/frontend/behaviors/quick_submit_spec.js +++ b/spec/frontend/behaviors/quick_submit_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlSnippetsShow from 'test_fixtures/snippets/show.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import '~/behaviors/quick_submit'; describe('Quick Submit behavior', () => { @@ -8,7 +9,7 @@ describe('Quick Submit behavior', () => { const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options); beforeEach(() => { - loadHTMLFixture('snippets/show.html'); + setHTMLFixture(htmlSnippetsShow); testContext = {}; @@ -60,22 +61,15 @@ describe('Quick Submit behavior', () => { expect(testContext.spies.submit).not.toHaveBeenCalled(); }); - it('disables input of type submit', () => { - const submitButton = $('.js-quick-submit input[type=submit]'); - testContext.textarea.trigger(keydownEvent()); - - expect(submitButton).toBeDisabled(); - }); - - it('disables button of type submit', () => { - const submitButton = $('.js-quick-submit input[type=submit]'); + it('disables submit', () => { + const submitButton = $('.js-quick-submit [type=submit]'); testContext.textarea.trigger(keydownEvent()); expect(submitButton).toBeDisabled(); }); it('only clicks one submit', () => { - const existingSubmit = $('.js-quick-submit input[type=submit]'); + const existingSubmit = $('.js-quick-submit [type=submit]'); // Add an extra submit button const newSubmit = $('<button type="submit">Submit it</button>'); newSubmit.insertAfter(testContext.textarea); diff --git a/spec/frontend/behaviors/requires_input_spec.js b/spec/frontend/behaviors/requires_input_spec.js index f2f68f17d1c..68fa980216a 100644 --- a/spec/frontend/behaviors/requires_input_spec.js +++ b/spec/frontend/behaviors/requires_input_spec.js @@ -1,12 +1,13 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlNewBranch from 'test_fixtures/branches/new_branch.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import '~/behaviors/requires_input'; describe('requiresInput', () => { let submitButton; beforeEach(() => { - loadHTMLFixture('branches/new_branch.html'); + setHTMLFixture(htmlNewBranch); submitButton = $('button[type="submit"]'); }); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index e6e587ff44b..ae7f5416c0c 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlSnippetsShow from 'test_fixtures/snippets/show.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; @@ -11,8 +12,6 @@ jest.mock('~/lib/utils/common_utils', () => ({ })); describe('ShortcutsIssuable', () => { - const snippetShowFixtureName = 'snippets/show.html'; - beforeAll(() => { initCopyAsGFM(); @@ -24,7 +23,7 @@ describe('ShortcutsIssuable', () => { const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form'; beforeEach(() => { - loadHTMLFixture(snippetShowFixtureName); + setHTMLFixture(htmlSnippetsShow); $('body').append( `<div class="js-main-target-form"> <textarea class="js-vue-comment-form"></textarea> diff --git a/spec/frontend/blob/components/blob_edit_header_spec.js b/spec/frontend/blob/components/blob_edit_header_spec.js index 2b1bd1ac4ad..b0ce5f40d95 100644 --- a/spec/frontend/blob/components/blob_edit_header_spec.js +++ b/spec/frontend/blob/components/blob_edit_header_spec.js @@ -1,6 +1,5 @@ import { GlFormInput, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import BlobEditHeader from '~/blob/components/blob_edit_header.vue'; describe('Blob Header Editing', () => { @@ -15,8 +14,10 @@ describe('Blob Header Editing', () => { }, }); }; + const findDeleteButton = () => wrapper.findAllComponents(GlButton).wrappers.find((x) => x.text() === 'Delete file'); + const findFormInput = () => wrapper.findComponent(GlFormInput); beforeEach(() => { createComponent(); @@ -28,7 +29,7 @@ describe('Blob Header Editing', () => { }); it('contains a form input field', () => { - expect(wrapper.findComponent(GlFormInput).exists()).toBe(true); + expect(findFormInput().exists()).toBe(true); }); it('does not show delete button', () => { @@ -37,19 +38,16 @@ describe('Blob Header Editing', () => { }); describe('functionality', () => { - it('emits input event when the blob name is changed', async () => { - const inputComponent = wrapper.findComponent(GlFormInput); + it('emits input event when the blob name is changed', () => { + const inputComponent = findFormInput(); const newValue = 'bar.txt'; - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - name: newValue, - }); + // update `name` with `newValue` + inputComponent.vm.$emit('input', newValue); + // trigger change event which emits input event on wrapper inputComponent.vm.$emit('change'); - await nextTick(); - expect(wrapper.emitted().input[0]).toEqual([newValue]); + expect(wrapper.emitted().input).toEqual([[newValue]]); }); }); diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js index e12021a48d2..6e001846bb6 100644 --- a/spec/frontend/blob/components/blob_header_default_actions_spec.js +++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js @@ -45,7 +45,7 @@ describe('Blob Header Default Actions', () => { it('exactly 3 buttons with predefined actions', () => { expect(buttons.length).toBe(3); [BTN_COPY_CONTENTS_TITLE, BTN_RAW_TITLE, BTN_DOWNLOAD_TITLE].forEach((title, i) => { - expect(buttons.at(i).vm.$el.title).toBe(title); + expect(buttons.at(i).attributes('title')).toBe(title); }); }); @@ -87,10 +87,9 @@ describe('Blob Header Default Actions', () => { it('emits a copy event if overrideCopy is set to true', () => { createComponent({ overrideCopy: true }); - jest.spyOn(wrapper.vm, '$emit'); findCopyButton().vm.$emit('click'); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('copy'); + expect(wrapper.emitted('copy')).toHaveLength(1); }); }); diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js index b5803bf0cbc..6ecf5091591 100644 --- a/spec/frontend/blob/components/mock_data.js +++ b/spec/frontend/blob/components/mock_data.js @@ -47,11 +47,13 @@ export const BinaryBlob = { }; export const RichBlobContentMock = { + __typename: 'Blob', path: 'foo.md', richData: '<h1>Rich</h1>', }; export const SimpleBlobContentMock = { + __typename: 'Blob', path: 'foo.js', plainData: 'Plain', }; diff --git a/spec/frontend/blob/file_template_selector_spec.js b/spec/frontend/blob/file_template_selector_spec.js index 65444e86efd..123475f8d62 100644 --- a/spec/frontend/blob/file_template_selector_spec.js +++ b/spec/frontend/blob/file_template_selector_spec.js @@ -53,7 +53,7 @@ describe('FileTemplateSelector', () => { expect(subject.wrapper.classList.contains('hidden')).toBe(false); }); - it('sets the focus on the dropdown', async () => { + it('sets the focus on the dropdown', () => { subject.show(); jest.spyOn(subject.dropdown, 'focus'); jest.runAllTimers(); diff --git a/spec/frontend/blob/sketch/index_spec.js b/spec/frontend/blob/sketch/index_spec.js index 4b6cb79791c..64b6152a07d 100644 --- a/spec/frontend/blob/sketch/index_spec.js +++ b/spec/frontend/blob/sketch/index_spec.js @@ -1,10 +1,11 @@ import SketchLoader from '~/blob/sketch'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; +import htmlSketchViewer from 'test_fixtures_static/sketch_viewer.html'; describe('Sketch viewer', () => { beforeEach(() => { - loadHTMLFixture('static/sketch_viewer.html'); + setHTMLFixture(htmlSketchViewer); }); afterEach(() => { diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index a612e863d46..a925f752f5e 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -168,7 +168,7 @@ describe('Board card component', () => { }); describe('blocked', () => { - it('renders blocked icon if issue is blocked', async () => { + it('renders blocked icon if issue is blocked', () => { createWrapper({ props: { item: { @@ -487,7 +487,7 @@ describe('Board card component', () => { }); describe('loading', () => { - it('renders loading icon', async () => { + it('renders loading icon', () => { createWrapper({ props: { item: { diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 9ec43c6e892..e0a110678b1 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -1,3 +1,4 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import { nextTick } from 'vue'; import { DraggableItemTypes, ListType } from 'ee_else_ce/boards/constants'; @@ -8,15 +9,18 @@ import BoardCard from '~/boards/components/board_card.vue'; import eventHub from '~/boards/eventhub'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; -import { mockIssues } from './mock_data'; +import { mockIssues, mockList, mockIssuesMore } from './mock_data'; describe('Board list component', () => { let wrapper; const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); - const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]'); const findDraggable = () => wrapper.findComponent(Draggable); const findMoveToPositionComponent = () => wrapper.findComponent(BoardCardMoveToPosition); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findBoardListCount = () => wrapper.find('.board-list-count'); + + const triggerInfiniteScroll = () => findIntersectionObserver().vm.$emit('appear'); const startDrag = ( params = { @@ -61,41 +65,25 @@ describe('Board list component', () => { expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1'); }); - it('shows new issue form', async () => { - wrapper.vm.toggleForm(); - - await nextTick(); - expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); - }); - it('shows new issue form after eventhub event', async () => { - eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`); + eventHub.$emit(`toggle-issue-form-${mockList.id}`); await nextTick(); expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); }); - it('does not show new issue form for closed list', () => { - wrapper.setProps({ list: { type: 'closed' } }); - wrapper.vm.toggleForm(); - - expect(wrapper.find('.board-new-issue-form').exists()).toBe(false); - }); - - it('shows count list item', async () => { - wrapper.vm.showCount = true; - - await nextTick(); - expect(wrapper.find('.board-list-count').exists()).toBe(true); - - expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues'); - }); + it('does not show new issue form for closed list', async () => { + wrapper = createComponent({ + listProps: { + listType: ListType.closed, + }, + }); + await waitForPromises(); - it('sets data attribute with invalid id', async () => { - wrapper.vm.showCount = true; + eventHub.$emit(`toggle-issue-form-${mockList.id}`); await nextTick(); - expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1'); + expect(wrapper.find('.board-new-issue-form').exists()).toBe(false); }); it('renders the move to position icon', () => { @@ -118,61 +106,41 @@ describe('Board list component', () => { }); describe('load more issues', () => { - const actions = { - fetchItemsForList: jest.fn(), - }; - - it('does not load issues if already loading', () => { - wrapper = createComponent({ - actions, - state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, + describe('when loading is not in progress', () => { + beforeEach(() => { + wrapper = createComponent({ + listProps: { + id: 'gid://gitlab/List/1', + }, + componentProps: { + boardItems: mockIssuesMore, + }, + actions: { + fetchItemsForList: jest.fn(), + }, + state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: false } } }, + }); }); - wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - expect(actions.fetchItemsForList).not.toHaveBeenCalled(); - }); - - it('shows loading more spinner', async () => { - wrapper = createComponent({ - state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, - data: { - showCount: true, - }, + it('has intersection observer when the number of board list items are more than 5', () => { + expect(findIntersectionObserver().exists()).toBe(true); }); - await nextTick(); - - expect(findIssueCountLoadingIcon().exists()).toBe(true); - }); - - it('shows how many more issues to load', async () => { - wrapper = createComponent({ - data: { - showCount: true, - }, + it('shows count when loaded more items and correct data attribute', async () => { + triggerInfiniteScroll(); + await waitForPromises(); + expect(findBoardListCount().exists()).toBe(true); + expect(findBoardListCount().attributes('data-issue-id')).toBe('-1'); }); - - await nextTick(); - await waitForPromises(); - await nextTick(); - await nextTick(); - - expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues'); }); }); describe('max issue count warning', () => { - beforeEach(() => { - wrapper = createComponent({ - listProps: { issuesCount: 50 }, - }); - }); - describe('when issue count exceeds max issue count', () => { it('sets background to gl-bg-red-100', async () => { - wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } }); + wrapper = createComponent({ listProps: { issuesCount: 4, maxIssueCount: 3 } }); - await nextTick(); + await waitForPromises(); const block = wrapper.find('.gl-bg-red-100'); expect(block.exists()).toBe(true); @@ -183,16 +151,18 @@ describe('Board list component', () => { }); describe('when list issue count does NOT exceed list max issue count', () => { - it('does not sets background to gl-bg-red-100', () => { - wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } }); + it('does not sets background to gl-bg-red-100', async () => { + wrapper = createComponent({ list: { issuesCount: 2, maxIssueCount: 3 } }); + await waitForPromises(); expect(wrapper.find('.gl-bg-red-100').exists()).toBe(false); }); }); describe('when list max issue count is 0', () => { - it('does not sets background to gl-bg-red-100', () => { - wrapper.setProps({ list: { maxIssueCount: 0 } }); + it('does not sets background to gl-bg-red-100', async () => { + wrapper = createComponent({ list: { maxIssueCount: 0 } }); + await waitForPromises(); expect(wrapper.find('.gl-bg-red-100').exists()).toBe(false); }); diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js index 148e696b57b..77ba6cdc9c0 100644 --- a/spec/frontend/boards/components/board_app_spec.js +++ b/spec/frontend/boards/components/board_app_spec.js @@ -1,14 +1,20 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; +import createMockApollo from 'helpers/mock_apollo_helper'; import BoardApp from '~/boards/components/board_app.vue'; +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; +import { rawIssue } from '../mock_data'; describe('BoardApp', () => { let wrapper; let store; + const mockApollo = createMockApollo(); Vue.use(Vuex); + Vue.use(VueApollo); const createStore = ({ mockGetters = {} } = {}) => { store = new Vuex.Store({ @@ -23,12 +29,22 @@ describe('BoardApp', () => { }); }; - const createComponent = () => { + const createComponent = ({ isApolloBoard = false, issue = rawIssue } = {}) => { + mockApollo.clients.defaultClient.cache.writeQuery({ + query: activeBoardItemQuery, + data: { + activeBoardItem: issue, + }, + }); + wrapper = shallowMount(BoardApp, { + apolloProvider: mockApollo, store, provide: { initialBoardId: 'gid://gitlab/Board/1', initialFilterParams: {}, + isIssueBoard: true, + isApolloBoard, }, }); }; @@ -50,4 +66,22 @@ describe('BoardApp', () => { expect(wrapper.classes()).not.toContain('is-compact'); }); + + describe('Apollo boards', () => { + beforeEach(async () => { + createComponent({ isApolloBoard: true }); + await nextTick(); + }); + + it('should have is-compact class when a card is selected', () => { + expect(wrapper.classes()).toContain('is-compact'); + }); + + it('should not have is-compact class when no card is selected', async () => { + createComponent({ isApolloBoard: true, issue: {} }); + await nextTick(); + + expect(wrapper.classes()).not.toContain('is-compact'); + }); + }); }); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 46116bed4cf..897219303b5 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -1,8 +1,10 @@ import { GlLabel } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import BoardCard from '~/boards/components/board_card.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; import { inactiveId } from '~/boards/constants'; @@ -14,6 +16,14 @@ describe('Board card', () => { let mockActions; Vue.use(Vuex); + Vue.use(VueApollo); + + const mockSetActiveBoardItemResolver = jest.fn(); + const mockApollo = createMockApollo([], { + Mutation: { + setActiveBoardItem: mockSetActiveBoardItemResolver, + }, + }); const createStore = ({ initialState = {} } = {}) => { mockActions = { @@ -36,11 +46,11 @@ describe('Board card', () => { const mountComponent = ({ propsData = {}, provide = {}, - mountFn = shallowMount, stubs = { BoardCardInner }, item = mockIssue, } = {}) => { - wrapper = mountFn(BoardCard, { + wrapper = shallowMountExtended(BoardCard, { + apolloProvider: mockApollo, stubs: { ...stubs, BoardCardInner, @@ -56,9 +66,9 @@ describe('Board card', () => { groupId: null, rootPath: '/', scopedLabelsAvailable: false, + isIssueBoard: true, isEpicBoard: false, issuableType: 'issue', - isProjectBoard: false, isGroupBoard: true, disabled: false, isApolloBoard: false, @@ -96,7 +106,7 @@ describe('Board card', () => { }); }); - it('should not highlight the card by default', async () => { + it('should not highlight the card by default', () => { createStore(); mountComponent(); @@ -104,7 +114,7 @@ describe('Board card', () => { expect(wrapper.classes()).not.toContain('multi-select'); }); - it('should highlight the card with a correct style when selected', async () => { + it('should highlight the card with a correct style when selected', () => { createStore({ initialState: { activeId: mockIssue.id, @@ -116,7 +126,7 @@ describe('Board card', () => { expect(wrapper.classes()).not.toContain('multi-select'); }); - it('should highlight the card with a correct style when multi-selected', async () => { + it('should highlight the card with a correct style when multi-selected', () => { createStore({ initialState: { activeId: inactiveId, @@ -218,4 +228,25 @@ describe('Board card', () => { expect(wrapper.attributes('style')).toBeUndefined(); }); }); + + describe('Apollo boards', () => { + beforeEach(async () => { + createStore(); + mountComponent({ provide: { isApolloBoard: true } }); + await nextTick(); + }); + + it('set active board item on client when clicking on card', async () => { + await selectCard(); + + expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith( + {}, + { + boardItem: mockIssue, + }, + expect.anything(), + expect.anything(), + ); + }); + }); }); diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index 011665eee68..5717031be20 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -81,7 +81,7 @@ describe('Board Column Component', () => { }); describe('on mount', () => { - beforeEach(async () => { + beforeEach(() => { initStore(); jest.spyOn(store, 'dispatch').mockImplementation(); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 90376a4a553..9be2696de56 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -1,10 +1,15 @@ import { GlDrawer } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { MountingPortal } from 'portal-vue'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; @@ -14,13 +19,21 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; -import { mockActiveIssue, mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; +import { mockActiveIssue, mockIssue, rawIssue } from '../mock_data'; Vue.use(Vuex); +Vue.use(VueApollo); describe('BoardContentSidebar', () => { let wrapper; let store; + const mockSetActiveBoardItemResolver = jest.fn(); + const mockApollo = createMockApollo([], { + Mutation: { + setActiveBoardItem: mockSetActiveBoardItemResolver, + }, + }); + const createStore = ({ mockGetters = {}, mockActions = {} } = {}) => { store = new Vuex.Store({ state: { @@ -32,30 +45,29 @@ describe('BoardContentSidebar', () => { activeBoardItem: () => { return { ...mockActiveIssue, epic: null }; }, - groupPathForActiveIssue: () => mockIssueGroupPath, - projectPathForActiveIssue: () => mockIssueProjectPath, - isSidebarOpen: () => true, ...mockGetters, }, actions: mockActions, }); }; - const createComponent = () => { - /* - Dynamically imported components (in our case ee imports) - aren't stubbed automatically in VTU v1: - https://github.com/vuejs/vue-test-utils/issues/1279. + const createComponent = ({ isApolloBoard = false } = {}) => { + mockApollo.clients.defaultClient.cache.writeQuery({ + query: activeBoardItemQuery, + data: { + activeBoardItem: rawIssue, + }, + }); - This requires us to additionally mock apollo or vuex stores. - */ - wrapper = shallowMount(BoardContentSidebar, { + wrapper = shallowMountExtended(BoardContentSidebar, { + apolloProvider: mockApollo, provide: { canUpdate: true, rootPath: '/', groupId: 1, issuableType: TYPE_ISSUE, isGroupBoard: false, + isApolloBoard, }, store, stubs: { @@ -63,24 +75,6 @@ describe('BoardContentSidebar', () => { template: '<div><slot name="header"></slot><slot></slot></div>', }), }, - mocks: { - $apollo: { - queries: { - participants: { - loading: false, - }, - currentIteration: { - loading: false, - }, - iterations: { - loading: false, - }, - attributesList: { - loading: false, - }, - }, - }, - }, }); }; @@ -101,10 +95,12 @@ describe('BoardContentSidebar', () => { }); }); - it('does not render GlDrawer when isSidebarOpen is false', () => { - createStore({ mockGetters: { isSidebarOpen: () => false } }); + it('does not render GlDrawer when no active item is set', async () => { + createStore({ mockGetters: { activeBoardItem: () => ({ id: '', iid: '' }) } }); createComponent(); + await nextTick(); + expect(wrapper.findComponent(GlDrawer).props('open')).toBe(false); }); @@ -166,7 +162,7 @@ describe('BoardContentSidebar', () => { createComponent(); }); - it('calls toggleBoardItem with correct parameters', async () => { + it('calls toggleBoardItem with correct parameters', () => { wrapper.findComponent(GlDrawer).vm.$emit('close'); expect(toggleBoardItem).toHaveBeenCalledTimes(1); @@ -189,4 +185,27 @@ describe('BoardContentSidebar', () => { expect(wrapper.findComponent(SidebarSeverityWidget).exists()).toBe(true); }); }); + + describe('Apollo boards', () => { + beforeEach(async () => { + createStore(); + createComponent({ isApolloBoard: true }); + await nextTick(); + }); + + it('calls setActiveBoardItemMutation on close', async () => { + wrapper.findComponent(GlDrawer).vm.$emit('close'); + + await waitForPromises(); + + expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith( + {}, + { + boardItem: null, + }, + expect.anything(), + expect.anything(), + ); + }); + }); }); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 33351bf8efd..ab51f477966 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -6,6 +6,7 @@ import Draggable from 'vuedraggable'; import Vuex from 'vuex'; import eventHub from '~/boards/eventhub'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; @@ -78,6 +79,11 @@ describe('BoardContent', () => { isApolloBoard, }, store, + stubs: { + BoardContentSidebar: stubComponent(BoardContentSidebar, { + template: '<div></div>', + }), + }, }); }; diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index d8bc7f95f18..64111cfb01a 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -123,7 +123,7 @@ describe('BoardFilteredSearch', () => { jest.spyOn(wrapper.vm, 'performSearch').mockImplementation(); }); - it('sets the url params to the correct results', async () => { + it('sets the url params to the correct results', () => { const mockFilters = [ { type: TOKEN_TYPE_AUTHOR, value: { data: 'root', operator: '=' } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'root', operator: '=' } }, diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 62db59f8f57..f340dfab359 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -115,7 +115,7 @@ describe('BoardForm', () => { expect(findForm().exists()).toBe(true); }); - it('focuses an input field', async () => { + it('focuses an input field', () => { expect(document.activeElement).toBe(wrapper.vm.$refs.name); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 466321cf1cc..0f91d2315cf 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -147,7 +147,7 @@ describe('Board List Header Component', () => { }); describe('expanding / collapsing the column', () => { - it('should display collapse icon when column is expanded', async () => { + it('should display collapse icon when column is expanded', () => { createComponent(); const icon = findCaret(); @@ -155,7 +155,7 @@ describe('Board List Header Component', () => { expect(icon.props('icon')).toBe('chevron-lg-down'); }); - it('should display expand icon when column is collapsed', async () => { + it('should display expand icon when column is collapsed', () => { createComponent({ collapsed: true }); const icon = findCaret(); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index aa146eb4609..13c017706ef 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -250,7 +250,7 @@ describe('BoardsSelector', () => { describe('dropdown visibility', () => { describe('when multipleIssueBoardsAvailable is enabled', () => { - it('show dropdown', async () => { + it('show dropdown', () => { createStore(); createComponent({ provide: { multipleIssueBoardsAvailable: true } }); expect(findDropdown().exists()).toBe(true); @@ -258,7 +258,7 @@ describe('BoardsSelector', () => { }); describe('when multipleIssueBoardsAvailable is disabled but it hasMissingBoards', () => { - it('show dropdown', async () => { + it('show dropdown', () => { createStore(); createComponent({ provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: true }, @@ -268,7 +268,7 @@ describe('BoardsSelector', () => { }); describe("when multipleIssueBoardsAvailable is disabled and it dosn't hasMissingBoards", () => { - it('hide dropdown', async () => { + it('hide dropdown', () => { createStore(); createComponent({ provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: false }, diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js index a20884baf3b..fae3b0c5d1a 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js @@ -1,9 +1,17 @@ import { GlAlert, GlFormInput, GlForm, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +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 BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { createStore } from '~/boards/stores'; +import issueSetTitleMutation from '~/boards/graphql/issue_set_title.mutation.graphql'; +import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; +import { updateIssueTitleResponse, updateEpicTitleResponse } from '../../mock_data'; + +Vue.use(VueApollo); const TEST_TITLE = 'New item title'; const TEST_ISSUE_A = { @@ -21,24 +29,43 @@ const TEST_ISSUE_B = { webUrl: 'webUrl', }; -describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { +describe('BoardSidebarTitle', () => { let wrapper; let store; + let mockApollo; + + const issueSetTitleMutationHandlerSuccess = jest.fn().mockResolvedValue(updateIssueTitleResponse); + const updateEpicTitleMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(updateEpicTitleResponse); afterEach(() => { localStorage.clear(); store = null; }); - const createWrapper = (item = TEST_ISSUE_A) => { + const createWrapper = ({ item = TEST_ISSUE_A, provide = {} } = {}) => { store = createStore(); store.state.boardItems = { [item.id]: { ...item } }; store.dispatch('setActiveId', { id: item.id }); + mockApollo = createMockApollo([ + [issueSetTitleMutation, issueSetTitleMutationHandlerSuccess], + [updateEpicTitleMutation, updateEpicTitleMutationHandlerSuccess], + ]); wrapper = shallowMount(BoardSidebarTitle, { store, + apolloProvider: mockApollo, provide: { canUpdate: true, + fullPath: 'gitlab-org', + issuableType: 'issue', + isEpicBoard: false, + isApolloBoard: false, + ...provide, + }, + propsData: { + activeItem: item, }, stubs: { 'board-editable-item': BoardEditableItem, @@ -86,7 +113,8 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { await nextTick(); }); - it('collapses sidebar and renders new title', () => { + it('collapses sidebar and renders new title', async () => { + await waitForPromises(); expect(findCollapsed().isVisible()).toBe(true); expect(findTitle().text()).toContain(TEST_TITLE); }); @@ -140,7 +168,7 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { createWrapper(); }); - it('sets title, expands item and shows alert', async () => { + it('sets title, expands item and shows alert', () => { expect(wrapper.vm.title).toBe(TEST_TITLE); expect(findCollapsed().isVisible()).toBe(false); expect(findAlert().exists()).toBe(true); @@ -149,7 +177,7 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { describe('when cancel button is clicked', () => { beforeEach(async () => { - createWrapper(TEST_ISSUE_B); + createWrapper({ item: TEST_ISSUE_B }); jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => { store.state.boardItems[TEST_ISSUE_B.id].title = TEST_TITLE; @@ -168,7 +196,7 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { describe('when the mutation fails', () => { beforeEach(async () => { - createWrapper(TEST_ISSUE_B); + createWrapper({ item: TEST_ISSUE_B }); jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => { throw new Error(['failed mutation']); @@ -185,4 +213,32 @@ describe('~/boards/components/sidebar/board_sidebar_title.vue', () => { expect(wrapper.vm.setError).toHaveBeenCalled(); }); }); + + describe('Apollo boards', () => { + it.each` + issuableType | isEpicBoard | queryHandler | notCalledHandler + ${'issue'} | ${false} | ${issueSetTitleMutationHandlerSuccess} | ${updateEpicTitleMutationHandlerSuccess} + ${'epic'} | ${true} | ${updateEpicTitleMutationHandlerSuccess} | ${issueSetTitleMutationHandlerSuccess} + `( + 'updates $issuableType title', + async ({ issuableType, isEpicBoard, queryHandler, notCalledHandler }) => { + createWrapper({ + provide: { + issuableType, + isEpicBoard, + isApolloBoard: true, + }, + }); + + await nextTick(); + + findFormInput().vm.$emit('input', TEST_TITLE); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await nextTick(); + + expect(queryHandler).toHaveBeenCalled(); + expect(notCalledHandler).not.toHaveBeenCalled(); + }, + ); + }); }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index e5167120542..74733d1fd95 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -277,6 +277,9 @@ export const labels = [ }, ]; +export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test'; +export const mockEpicFullPath = 'gitlab-org/test-subgroup'; + export const rawIssue = { title: 'Issue 1', id: 'gid://gitlab/Issue/436', @@ -302,12 +305,24 @@ export const rawIssue = { epic: { id: 'gid://gitlab/Epic/41', }, + totalTimeSpent: 0, + humanTimeEstimate: null, + humanTotalTimeSpent: null, + emailsDisabled: false, + hidden: false, + webUrl: `${mockIssueFullPath}/-/issue/27`, + relativePosition: null, + severity: null, + milestone: null, + weight: null, + blocked: false, + blockedByCount: 0, + iteration: null, + healthStatus: null, type: 'ISSUE', + __typename: 'Issue', }; -export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test'; -export const mockEpicFullPath = 'gitlab-org/test-subgroup'; - export const mockIssue = { id: 'gid://gitlab/Issue/436', iid: '27', @@ -329,7 +344,22 @@ export const mockIssue = { epic: { id: 'gid://gitlab/Epic/41', }, + totalTimeSpent: 0, + humanTimeEstimate: null, + humanTotalTimeSpent: null, + emailsDisabled: false, + hidden: false, + webUrl: `${mockIssueFullPath}/-/issue/27`, + relativePosition: null, + severity: null, + milestone: null, + weight: null, + blocked: false, + blockedByCount: 0, + iteration: null, + healthStatus: null, type: 'ISSUE', + __typename: 'Issue', }; export const mockEpic = { @@ -425,7 +455,58 @@ export const mockIssue4 = { epic: null, }; +export const mockIssue5 = { + id: 'gid://gitlab/Issue/440', + iid: 40, + title: 'Issue 5', + referencePath: '#40', + dueDate: null, + timeEstimate: 0, + confidential: false, + path: '/gitlab-org/gitlab-test/-/issues/40', + assignees, + labels, + epic: null, +}; + +export const mockIssue6 = { + id: 'gid://gitlab/Issue/441', + iid: 41, + title: 'Issue 6', + referencePath: '#41', + dueDate: null, + timeEstimate: 0, + confidential: false, + path: '/gitlab-org/gitlab-test/-/issues/41', + assignees, + labels, + epic: null, +}; + +export const mockIssue7 = { + id: 'gid://gitlab/Issue/442', + iid: 42, + title: 'Issue 6', + referencePath: '#42', + dueDate: null, + timeEstimate: 0, + confidential: false, + path: '/gitlab-org/gitlab-test/-/issues/42', + assignees, + labels, + epic: null, +}; + export const mockIssues = [mockIssue, mockIssue2]; +export const mockIssuesMore = [ + mockIssue, + mockIssue2, + mockIssue3, + mockIssue4, + mockIssue5, + mockIssue6, + mockIssue7, +]; export const BoardsMockData = { GET: { @@ -925,4 +1006,26 @@ export const epicBoardListQueryResponse = (totalWeight = 5) => ({ }, }); +export const updateIssueTitleResponse = { + data: { + updateIssuableTitle: { + issue: { + id: 'gid://gitlab/Issue/436', + title: 'Issue 1 edit', + }, + }, + }, +}; + +export const updateEpicTitleResponse = { + data: { + updateIssuableTitle: { + epic: { + id: 'gid://gitlab/Epic/426', + title: 'Epic 1 edit', + }, + }, + }, +}; + export const DEFAULT_COLOR = '#1068bf'; diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index f430062bb73..b8d3be28ca6 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -401,7 +401,7 @@ describe('fetchMilestones', () => { }, ); - it('sets milestonesLoading to true', async () => { + it('sets milestonesLoading to true', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); const store = createStore(); diff --git a/spec/frontend/branches/components/delete_branch_modal_spec.js b/spec/frontend/branches/components/delete_branch_modal_spec.js index dd5b7fca564..7851d86466f 100644 --- a/spec/frontend/branches/components/delete_branch_modal_spec.js +++ b/spec/frontend/branches/components/delete_branch_modal_spec.js @@ -7,6 +7,8 @@ import DeleteBranchModal from '~/branches/components/delete_branch_modal.vue'; import eventHub from '~/branches/event_hub'; let wrapper; +let showMock; +let hideMock; const branchName = 'test_modal'; const defaultBranchName = 'default'; @@ -14,23 +16,20 @@ const deletePath = '/path/to/branch'; const merged = false; const isProtectedBranch = false; -const createComponent = (data = {}) => { +const createComponent = () => { + showMock = jest.fn(); + hideMock = jest.fn(); + wrapper = extendedWrapper( shallowMount(DeleteBranchModal, { - data() { - return { - branchName, - deletePath, - defaultBranchName, - merged, - isProtectedBranch, - ...data, - }; - }, stubs: { GlModal: stubComponent(GlModal, { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + methods: { + show: showMock, + hide: hideMock, + }, }), GlButton, GlFormInput, @@ -46,21 +45,36 @@ const findDeleteButton = () => wrapper.findByTestId('delete-branch-confirmation- const findCancelButton = () => wrapper.findByTestId('delete-branch-cancel-button'); const findFormInput = () => wrapper.findComponent(GlFormInput); const findForm = () => wrapper.find('form'); -const submitFormSpy = () => jest.spyOn(wrapper.vm.$refs.form, 'submit'); +const createSubmitFormSpy = () => jest.spyOn(findForm().element, 'submit'); + +const emitOpenModal = (data = {}) => + eventHub.$emit('openModal', { + isProtectedBranch, + branchName, + defaultBranchName, + deletePath, + merged, + ...data, + }); describe('Delete branch modal', () => { const expectedUnmergedWarning = "This branch hasn't been merged into default. To avoid data loss, consider merging this branch before deleting it."; + beforeEach(() => { + createComponent(); + + emitOpenModal(); + + showMock.mockClear(); + hideMock.mockClear(); + }); + describe('Deleting a regular branch', () => { const expectedTitle = 'Delete branch. Are you ABSOLUTELY SURE?'; const expectedWarning = "You're about to permanently delete the branch test_modal."; const expectedMessage = `${expectedWarning} ${expectedUnmergedWarning}`; - beforeEach(() => { - createComponent(); - }); - it('renders the modal correctly', () => { expect(findModal().props('title')).toBe(expectedTitle); expect(findModalMessage().text()).toMatchInterpolatedText(expectedMessage); @@ -70,32 +84,30 @@ describe('Delete branch modal', () => { }); it('submits the form when the delete button is clicked', () => { + const submitSpy = createSubmitFormSpy(); + + expect(submitSpy).not.toHaveBeenCalled(); + findDeleteButton().trigger('click'); expect(findForm().attributes('action')).toBe(deletePath); - expect(submitFormSpy()).toHaveBeenCalled(); + expect(submitSpy).toHaveBeenCalled(); }); - it('calls show on the modal when a `openModal` event is received through the event hub', async () => { - const showSpy = jest.spyOn(wrapper.vm.$refs.modal, 'show'); + it('calls show on the modal when a `openModal` event is received through the event hub', () => { + expect(showMock).not.toHaveBeenCalled(); - eventHub.$emit('openModal', { - isProtectedBranch, - branchName, - defaultBranchName, - deletePath, - merged, - }); + emitOpenModal(); - expect(showSpy).toHaveBeenCalled(); + expect(showMock).toHaveBeenCalled(); }); it('calls hide on the modal when cancel button is clicked', () => { - const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); + expect(hideMock).not.toHaveBeenCalled(); findCancelButton().trigger('click'); - expect(closeModalSpy).toHaveBeenCalled(); + expect(hideMock).toHaveBeenCalled(); }); }); @@ -108,7 +120,9 @@ describe('Delete branch modal', () => { 'After you confirm and select Yes, delete protected branch, you cannot recover this branch. Please type the following to confirm: test_modal'; beforeEach(() => { - createComponent({ isProtectedBranch: true }); + emitOpenModal({ + isProtectedBranch: true, + }); }); describe('rendering the modal correctly for a protected branch', () => { @@ -138,8 +152,11 @@ describe('Delete branch modal', () => { await waitForPromises(); + const submitSpy = createSubmitFormSpy(); + findDeleteButton().trigger('click'); - expect(submitFormSpy()).not.toHaveBeenCalled(); + + expect(submitSpy).not.toHaveBeenCalled(); }); it('opens with the delete button disabled and enables it when branch name is confirmed and fires submit', async () => { @@ -151,16 +168,23 @@ describe('Delete branch modal', () => { expect(findDeleteButton().props('disabled')).not.toBe(true); + const submitSpy = createSubmitFormSpy(); + + expect(submitSpy).not.toHaveBeenCalled(); + findDeleteButton().trigger('click'); - expect(submitFormSpy()).toHaveBeenCalled(); + + expect(submitSpy).toHaveBeenCalled(); }); }); describe('Deleting a merged branch', () => { - it('does not include the unmerged branch warning when merged is true', () => { - createComponent({ merged: true }); + beforeEach(() => { + emitOpenModal({ merged: true }); + }); - expect(findModalMessage().html()).not.toContain(expectedUnmergedWarning); + it('does not include the unmerged branch warning when merged is true', () => { + expect(findModalMessage().text()).not.toContain(expectedUnmergedWarning); }); }); }); diff --git a/spec/frontend/branches/components/delete_merged_branches_spec.js b/spec/frontend/branches/components/delete_merged_branches_spec.js index 75a669c78f2..1e4d7082ccd 100644 --- a/spec/frontend/branches/components/delete_merged_branches_spec.js +++ b/spec/frontend/branches/components/delete_merged_branches_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlModal, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlModal, GlSprintf } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; @@ -10,11 +10,17 @@ import { formPath, propsDataMock } from '../mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); let wrapper; +const modalShowSpy = jest.fn(); +const modalHideSpy = jest.fn(); const stubsData = { GlModal: stubComponent(GlModal, { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + methods: { + show: modalShowSpy, + hide: modalHideSpy, + }, }), GlButton, GlFormInput, @@ -65,11 +71,10 @@ describe('Delete merged branches component', () => { }); it('opens modal when clicked', () => { - createComponent(mount); - jest.spyOn(wrapper.vm.$refs.modal, 'show'); + createComponent(mount, stubsData); findDeleteButton().trigger('click'); - expect(wrapper.vm.$refs.modal.show).toHaveBeenCalled(); + expect(modalShowSpy).toHaveBeenCalled(); }); }); @@ -131,9 +136,8 @@ describe('Delete merged branches component', () => { }); it('calls hide on the modal when cancel button is clicked', () => { - const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); findCancelButton().trigger('click'); - expect(closeModalSpy).toHaveBeenCalled(); + expect(modalHideSpy).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/captcha/captcha_modal_spec.js b/spec/frontend/captcha/captcha_modal_spec.js index 6d6d8043797..4bbed8ab3bb 100644 --- a/spec/frontend/captcha/captcha_modal_spec.js +++ b/spec/frontend/captcha/captcha_modal_spec.js @@ -61,12 +61,12 @@ describe('Captcha Modal', () => { describe('functionality', () => { describe('when modal is shown', () => { describe('when initRecaptchaScript promise resolves successfully', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ props: { needsCaptchaResponse: true } }); findGlModal().vm.$emit('shown'); }); - it('shows modal', async () => { + it('shows modal', () => { expect(showSpy).toHaveBeenCalled(); }); @@ -90,7 +90,7 @@ describe('Captcha Modal', () => { expect(wrapper.emitted('receivedCaptchaResponse')).toEqual([[captchaResponse]]); }); - it('hides modal with null trigger', async () => { + it('hides modal with null trigger', () => { // Assert that hide is called with zero args, so that we don't trigger the logic // for hiding the modal via cancel, esc, headerclose, etc, without a captcha response expect(hideSpy).toHaveBeenCalledWith(); diff --git a/spec/frontend/captcha/init_recaptcha_script_spec.js b/spec/frontend/captcha/init_recaptcha_script_spec.js index 78480821d95..3e2d7ba00ee 100644 --- a/spec/frontend/captcha/init_recaptcha_script_spec.js +++ b/spec/frontend/captcha/init_recaptcha_script_spec.js @@ -50,7 +50,7 @@ describe('initRecaptchaScript', () => { await expect(result).resolves.toBe(window.grecaptcha); }); - it('sets window[RECAPTCHA_ONLOAD_CALLBACK_NAME] to undefined', async () => { + it('sets window[RECAPTCHA_ONLOAD_CALLBACK_NAME] to undefined', () => { expect(getScriptOnload()).toBeUndefined(); }); }); diff --git a/spec/frontend/artifacts/components/app_spec.js b/spec/frontend/ci/artifacts/components/app_spec.js index 931c4703e95..435b03e82ab 100644 --- a/spec/frontend/artifacts/components/app_spec.js +++ b/spec/frontend/ci/artifacts/components/app_spec.js @@ -2,13 +2,13 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import Vue from 'vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import ArtifactsApp from '~/artifacts/components/app.vue'; -import JobArtifactsTable from '~/artifacts/components/job_artifacts_table.vue'; -import getBuildArtifactsSizeQuery from '~/artifacts/graphql/queries/get_build_artifacts_size.query.graphql'; +import ArtifactsApp from '~/ci/artifacts/components/app.vue'; +import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue'; +import getBuildArtifactsSizeQuery from '~/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { PAGE_TITLE, TOTAL_ARTIFACTS_SIZE, SIZE_UNKNOWN } from '~/artifacts/constants'; +import { PAGE_TITLE, TOTAL_ARTIFACTS_SIZE, SIZE_UNKNOWN } from '~/ci/artifacts/constants'; const TEST_BUILD_ARTIFACTS_SIZE = 1024; const TEST_PROJECT_PATH = 'project/path'; diff --git a/spec/frontend/artifacts/components/artifact_row_spec.js b/spec/frontend/ci/artifacts/components/artifact_row_spec.js index 268772ed4c0..f64d32410ed 100644 --- a/spec/frontend/artifacts/components/artifact_row_spec.js +++ b/spec/frontend/ci/artifacts/components/artifact_row_spec.js @@ -1,10 +1,10 @@ -import { GlBadge, GlButton, GlFriendlyWrap, GlFormCheckbox } from '@gitlab/ui'; -import mockGetJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import { GlBadge, GlFriendlyWrap, GlFormCheckbox } from '@gitlab/ui'; +import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import ArtifactRow from '~/artifacts/components/artifact_row.vue'; -import { BULK_DELETE_FEATURE_FLAG } from '~/artifacts/constants'; +import ArtifactRow from '~/ci/artifacts/components/artifact_row.vue'; +import { BULK_DELETE_FEATURE_FLAG } from '~/ci/artifacts/constants'; describe('ArtifactRow component', () => { let wrapper; @@ -27,7 +27,7 @@ describe('ArtifactRow component', () => { isLastRow: false, }, provide: { canDestroyArtifacts, glFeatures }, - stubs: { GlBadge, GlButton, GlFriendlyWrap }, + stubs: { GlBadge, GlFriendlyWrap }, }); }; @@ -70,7 +70,7 @@ describe('ArtifactRow component', () => { expect(wrapper.emitted('delete')).toBeUndefined(); - findDeleteButton().trigger('click'); + findDeleteButton().vm.$emit('click'); await waitForPromises(); expect(wrapper.emitted('delete')).toBeDefined(); diff --git a/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js b/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js new file mode 100644 index 00000000000..9e4fa6b9c6f --- /dev/null +++ b/spec/frontend/ci/artifacts/components/artifacts_bulk_delete_spec.js @@ -0,0 +1,48 @@ +import { GlSprintf } from '@gitlab/ui'; +import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ArtifactsBulkDelete from '~/ci/artifacts/components/artifacts_bulk_delete.vue'; + +describe('ArtifactsBulkDelete component', () => { + let wrapper; + + const selectedArtifacts = [ + mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[0].id, + mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[1].id, + ]; + + const findText = () => wrapper.findComponent(GlSprintf).text(); + const findDeleteButton = () => wrapper.findByTestId('bulk-delete-delete-button'); + const findClearButton = () => wrapper.findByTestId('bulk-delete-clear-button'); + + const createComponent = () => { + wrapper = shallowMountExtended(ArtifactsBulkDelete, { + propsData: { + selectedArtifacts, + }, + stubs: { GlSprintf }, + }); + }; + + describe('selected artifacts box', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays selected artifacts count', () => { + expect(findText()).toContain(String(selectedArtifacts.length)); + }); + + it('emits showBulkDeleteModal event when the delete button is clicked', () => { + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('showBulkDeleteModal')).toBeDefined(); + }); + + it('emits clearSelectedArtifacts event when the clear button is clicked', () => { + findClearButton().vm.$emit('click'); + + expect(wrapper.emitted('clearSelectedArtifacts')).toBeDefined(); + }); + }); +}); diff --git a/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js b/spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js index 6bf3498f9b0..ebdb7e25c45 100644 --- a/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js +++ b/spec/frontend/ci/artifacts/components/artifacts_table_row_details_spec.js @@ -1,15 +1,15 @@ import { GlModal } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import getJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; import waitForPromises from 'helpers/wait_for_promises'; -import ArtifactsTableRowDetails from '~/artifacts/components/artifacts_table_row_details.vue'; -import ArtifactRow from '~/artifacts/components/artifact_row.vue'; -import ArtifactDeleteModal from '~/artifacts/components/artifact_delete_modal.vue'; +import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue'; +import ArtifactRow from '~/ci/artifacts/components/artifact_row.vue'; +import ArtifactDeleteModal from '~/ci/artifacts/components/artifact_delete_modal.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import destroyArtifactMutation from '~/artifacts/graphql/mutations/destroy_artifact.mutation.graphql'; -import { I18N_DESTROY_ERROR, I18N_MODAL_TITLE } from '~/artifacts/constants'; +import destroyArtifactMutation from '~/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql'; +import { I18N_DESTROY_ERROR, I18N_MODAL_TITLE } from '~/ci/artifacts/constants'; import { createAlert } from '~/alert'; jest.mock('~/alert'); diff --git a/spec/frontend/artifacts/components/feedback_banner_spec.js b/spec/frontend/ci/artifacts/components/feedback_banner_spec.js index af9599daefa..53e0fdac6f6 100644 --- a/spec/frontend/artifacts/components/feedback_banner_spec.js +++ b/spec/frontend/ci/artifacts/components/feedback_banner_spec.js @@ -1,12 +1,12 @@ import { GlBanner } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import FeedbackBanner from '~/artifacts/components/feedback_banner.vue'; +import FeedbackBanner from '~/ci/artifacts/components/feedback_banner.vue'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import { I18N_FEEDBACK_BANNER_TITLE, I18N_FEEDBACK_BANNER_BUTTON, FEEDBACK_URL, -} from '~/artifacts/constants'; +} from '~/ci/artifacts/constants'; const mockBannerImagePath = 'banner/image/path'; diff --git a/spec/frontend/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js index 40f3c9633ab..74d0d683662 100644 --- a/spec/frontend/artifacts/components/job_artifacts_table_spec.js +++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js @@ -9,26 +9,30 @@ import { } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import getJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import waitForPromises from 'helpers/wait_for_promises'; -import JobArtifactsTable from '~/artifacts/components/job_artifacts_table.vue'; -import FeedbackBanner from '~/artifacts/components/feedback_banner.vue'; -import ArtifactsTableRowDetails from '~/artifacts/components/artifacts_table_row_details.vue'; -import ArtifactDeleteModal from '~/artifacts/components/artifact_delete_modal.vue'; -import ArtifactsBulkDelete from '~/artifacts/components/artifacts_bulk_delete.vue'; +import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue'; +import FeedbackBanner from '~/ci/artifacts/components/feedback_banner.vue'; +import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue'; +import ArtifactDeleteModal from '~/ci/artifacts/components/artifact_delete_modal.vue'; +import ArtifactsBulkDelete from '~/ci/artifacts/components/artifacts_bulk_delete.vue'; +import BulkDeleteModal from '~/ci/artifacts/components/bulk_delete_modal.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import getJobArtifactsQuery from '~/artifacts/graphql/queries/get_job_artifacts.query.graphql'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import getJobArtifactsQuery from '~/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql'; +import bulkDestroyArtifactsMutation from '~/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { ARCHIVE_FILE_TYPE, JOBS_PER_PAGE, I18N_FETCH_ERROR, INITIAL_CURRENT_PAGE, BULK_DELETE_FEATURE_FLAG, -} from '~/artifacts/constants'; -import { totalArtifactsSizeForJob } from '~/artifacts/utils'; + I18N_BULK_DELETE_ERROR, +} from '~/ci/artifacts/constants'; +import { totalArtifactsSizeForJob } from '~/ci/artifacts/utils'; import { createAlert } from '~/alert'; jest.mock('~/alert'); @@ -52,7 +56,8 @@ describe('JobArtifactsTable component', () => { const findCount = () => wrapper.findByTestId('job-artifacts-count'); const findCountAt = (i) => wrapper.findAllByTestId('job-artifacts-count').at(i); - const findModal = () => wrapper.findComponent(GlModal); + const findDeleteModal = () => wrapper.findComponent(ArtifactDeleteModal); + const findBulkDeleteModal = () => wrapper.findComponent(BulkDeleteModal); const findStatuses = () => wrapper.findAllByTestId('job-artifacts-job-status'); const findSuccessfulJobStatus = () => findStatuses().at(0); @@ -73,9 +78,10 @@ describe('JobArtifactsTable component', () => { const findArtifactDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button'); // first checkbox is a "select all", this finder should get the first job checkbox - const findJobCheckbox = () => wrapper.findAllComponents(GlFormCheckbox).at(1); + const findJobCheckbox = (i = 1) => wrapper.findAllComponents(GlFormCheckbox).at(i); const findAnyCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findBulkDelete = () => wrapper.findComponent(ArtifactsBulkDelete); + const findBulkDeleteContainer = () => wrapper.findByTestId('bulk-delete-container'); const findPagination = () => wrapper.findComponent(GlPagination); const setPage = async (page) => { @@ -83,6 +89,8 @@ describe('JobArtifactsTable component', () => { await waitForPromises(); }; + const projectId = 'some/project/id'; + let enoughJobsToPaginate = [...getJobArtifactsResponse.data.project.jobs.nodes]; while (enoughJobsToPaginate.length <= JOBS_PER_PAGE) { enoughJobsToPaginate = [ @@ -105,10 +113,20 @@ describe('JobArtifactsTable component', () => { const archiveArtifact = job.artifacts.nodes.find( (artifact) => artifact.fileType === ARCHIVE_FILE_TYPE, ); + const job2 = getJobArtifactsResponse.data.project.jobs.nodes[1]; + + const destroyedCount = job.artifacts.nodes.length; + const destroyedIds = job.artifacts.nodes.map((node) => node.id); + const bulkDestroyMutationHandler = jest.fn().mockResolvedValue({ + data: { + bulkDestroyJobArtifacts: { errors: [], destroyedCount, destroyedIds }, + }, + }); const createComponent = ({ handlers = { getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), + bulkDestroyArtifactsMutation: bulkDestroyMutationHandler, }, data = {}, canDestroyArtifacts = true, @@ -118,10 +136,11 @@ describe('JobArtifactsTable component', () => { wrapper = mountExtended(JobArtifactsTable, { apolloProvider: createMockApollo([ [getJobArtifactsQuery, requestHandlers.getJobArtifactsQuery], + [bulkDestroyArtifactsMutation, requestHandlers.bulkDestroyArtifactsMutation], ]), provide: { projectPath: 'project/path', - projectId: 'gid://projects/id', + projectId, canDestroyArtifacts, artifactsManagementFeedbackImagePath: 'banner/image/path', glFeatures, @@ -265,12 +284,12 @@ describe('JobArtifactsTable component', () => { expect(findDetailsInRow(0).exists()).toBe(false); expect(findDetailsInRow(1).exists()).toBe(true); - findArtifactDeleteButton().trigger('click'); + findArtifactDeleteButton().vm.$emit('click'); await waitForPromises(); - expect(findModal().props('visible')).toBe(true); + expect(findDeleteModal().findComponent(GlModal).props('visible')).toBe(true); - wrapper.findComponent(ArtifactDeleteModal).vm.$emit('primary'); + findDeleteModal().vm.$emit('primary'); await waitForPromises(); expect(findDetailsInRow(0).exists()).toBe(false); @@ -332,24 +351,132 @@ describe('JobArtifactsTable component', () => { }); describe('delete button', () => { - it('does not show when user does not have permission', async () => { - createComponent({ canDestroyArtifacts: false }); + const artifactsFromJob = job.artifacts.nodes.map((node) => node.id); + + describe('with delete permission and bulk delete feature flag enabled', () => { + beforeEach(async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + }); + + await waitForPromises(); + }); + + it('opens the confirmation modal with the artifacts from the job', async () => { + await findDeleteButton().vm.$emit('click'); + + expect(findBulkDeleteModal().props()).toMatchObject({ + visible: true, + artifactsToDelete: artifactsFromJob, + }); + }); + + it('on confirm, deletes the artifacts from the job and shows a toast', async () => { + findDeleteButton().vm.$emit('click'); + findBulkDeleteModal().vm.$emit('primary'); + + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: artifactsFromJob, + }); + + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith( + `${artifactsFromJob.length} selected artifacts deleted`, + ); + }); + it('does not clear selected artifacts on success', async () => { + // select job 2 via checkbox + findJobCheckbox(2).vm.$emit('input', true); + + // click delete button job 1 + findDeleteButton().vm.$emit('click'); + + // job 2's artifacts should still be selected + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( + job2.artifacts.nodes.map((node) => node.id), + ); + + // confirm delete + findBulkDeleteModal().vm.$emit('primary'); + + // job 1's artifacts should be deleted + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: artifactsFromJob, + }); + + await waitForPromises(); + + // job 2's artifacts should still be selected + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( + job2.artifacts.nodes.map((node) => node.id), + ); + }); + }); + + it('shows an alert and does not clear selected artifacts on error', async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + handlers: { + getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), + bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(), + }, + }); await waitForPromises(); - expect(findDeleteButton().exists()).toBe(false); + // select job 2 via checkbox + findJobCheckbox(2).vm.$emit('input', true); + + // click delete button job 1 + findDeleteButton().vm.$emit('click'); + + // confirm delete + findBulkDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + // job 2's artifacts should still be selected + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( + job2.artifacts.nodes.map((node) => node.id), + ); + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.any(Error), + message: I18N_BULK_DELETE_ERROR, + }); }); - it('shows a disabled delete button for now (coming soon)', async () => { - createComponent(); + it('is disabled when bulk delete feature flag is disabled', async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false }, + }); await waitForPromises(); expect(findDeleteButton().attributes('disabled')).toBe('disabled'); }); + + it('is hidden when user does not have delete permission', async () => { + createComponent({ + canDestroyArtifacts: false, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false }, + }); + + await waitForPromises(); + + expect(findDeleteButton().exists()).toBe(false); + }); }); describe('bulk delete', () => { + const selectedArtifacts = job.artifacts.nodes.map((node) => node.id); + describe('with permission and feature flag enabled', () => { beforeEach(async () => { createComponent({ @@ -361,33 +488,84 @@ describe('JobArtifactsTable component', () => { }); it('shows selected artifacts when a job is checked', async () => { - expect(findBulkDelete().exists()).toBe(false); + expect(findBulkDeleteContainer().exists()).toBe(false); await findJobCheckbox().vm.$emit('input', true); - expect(findBulkDelete().exists()).toBe(true); - expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual( - job.artifacts.nodes.map((node) => node.id), - ); + expect(findBulkDeleteContainer().exists()).toBe(true); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts); }); it('disappears when selected artifacts are cleared', async () => { await findJobCheckbox().vm.$emit('input', true); - expect(findBulkDelete().exists()).toBe(true); + expect(findBulkDeleteContainer().exists()).toBe(true); await findBulkDelete().vm.$emit('clearSelectedArtifacts'); - expect(findBulkDelete().exists()).toBe(false); + expect(findBulkDeleteContainer().exists()).toBe(false); }); - it('shows a toast when artifacts are deleted', async () => { - const count = job.artifacts.nodes.length; + it('shows a modal to confirm bulk delete', async () => { + findJobCheckbox().vm.$emit('input', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); - await findJobCheckbox().vm.$emit('input', true); - findBulkDelete().vm.$emit('deleted', count); + await waitForPromises(); + + expect(findBulkDeleteModal().props('visible')).toBe(true); + }); + + it('deletes the selected artifacts and shows a toast', async () => { + findJobCheckbox().vm.$emit('input', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + findBulkDeleteModal().vm.$emit('primary'); + + expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({ + projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId), + ids: selectedArtifacts, + }); + + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith( + `${selectedArtifacts.length} selected artifacts deleted`, + ); + }); + + it('clears selected artifacts on success', async () => { + findJobCheckbox().vm.$emit('input', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + findBulkDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]); + }); + }); + + it('shows an alert and does not clear selected artifacts on error', async () => { + createComponent({ + canDestroyArtifacts: true, + glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true }, + handlers: { + getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), + bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(), + }, + }); + + await waitForPromises(); + + findJobCheckbox().vm.$emit('input', true); + findBulkDelete().vm.$emit('showBulkDeleteModal'); + findBulkDeleteModal().vm.$emit('primary'); + + await waitForPromises(); - expect(mockToastShow).toHaveBeenCalledWith(`${count} selected artifacts deleted`); + expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts); + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.any(Error), + message: I18N_BULK_DELETE_ERROR, }); }); diff --git a/spec/frontend/artifacts/components/job_checkbox_spec.js b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js index 95cc548b8c8..ae70bb4b17b 100644 --- a/spec/frontend/artifacts/components/job_checkbox_spec.js +++ b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js @@ -1,7 +1,7 @@ import { GlFormCheckbox } from '@gitlab/ui'; -import mockGetJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; +import mockGetJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import JobCheckbox from '~/artifacts/components/job_checkbox.vue'; +import JobCheckbox from '~/ci/artifacts/components/job_checkbox.vue'; describe('JobCheckbox component', () => { let wrapper; diff --git a/spec/frontend/artifacts/graphql/cache_update_spec.js b/spec/frontend/ci/artifacts/graphql/cache_update_spec.js index 4d610328298..3c415534c7c 100644 --- a/spec/frontend/artifacts/graphql/cache_update_spec.js +++ b/spec/frontend/ci/artifacts/graphql/cache_update_spec.js @@ -1,5 +1,5 @@ -import getJobArtifactsQuery from '~/artifacts/graphql/queries/get_job_artifacts.query.graphql'; -import { removeArtifactFromStore } from '~/artifacts/graphql/cache_update'; +import getJobArtifactsQuery from '~/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql'; +import { removeArtifactFromStore } from '~/ci/artifacts/graphql/cache_update'; describe('Artifact table cache updates', () => { let store; diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js index e4abedb412f..8990a70d4ef 100644 --- a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js +++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -1,5 +1,7 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlPipelineSchedulesEdit from 'test_fixtures/pipeline_schedules/edit.html'; +import htmlPipelineSchedulesEditWithVariables from 'test_fixtures/pipeline_schedules/edit_with_variables.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import VariableList from '~/ci/ci_variable_list/ci_variable_list'; const HIDE_CLASS = 'hide'; @@ -11,7 +13,7 @@ describe('VariableList', () => { describe('with only key/value inputs', () => { describe('with no variables', () => { beforeEach(() => { - loadHTMLFixture('pipeline_schedules/edit.html'); + setHTMLFixture(htmlPipelineSchedulesEdit); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ @@ -69,7 +71,7 @@ describe('VariableList', () => { describe('with persisted variables', () => { beforeEach(() => { - loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); + setHTMLFixture(htmlPipelineSchedulesEditWithVariables); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ @@ -106,7 +108,7 @@ describe('VariableList', () => { describe('toggleEnableRow method', () => { beforeEach(() => { - loadHTMLFixture('pipeline_schedules/edit_with_variables.html'); + setHTMLFixture(htmlPipelineSchedulesEditWithVariables); $wrapper = $('.js-ci-variable-list-section'); variableList = new VariableList({ diff --git a/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js index 8e012883f09..1d0dcf242a4 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js @@ -1,7 +1,16 @@ import { shallowMount } from '@vue/test-utils'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, +} from '~/ci/ci_variable_list/constants'; import ciAdminVariables from '~/ci/ci_variable_list/components/ci_admin_variables.vue'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import addAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql'; +import deleteAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql'; +import updateAdminVariable from '~/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql'; +import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql'; describe('Ci Project Variable wrapper', () => { let wrapper; @@ -22,8 +31,17 @@ describe('Ci Project Variable wrapper', () => { componentName: 'InstanceVariables', entity: '', hideEnvironmentScope: true, - mutationData: wrapper.vm.$options.mutationData, - queryData: wrapper.vm.$options.queryData, + mutationData: { + [ADD_MUTATION_ACTION]: addAdminVariable, + [UPDATE_MUTATION_ACTION]: updateAdminVariable, + [DELETE_MUTATION_ACTION]: deleteAdminVariable, + }, + queryData: { + ciVariables: { + lookup: expect.any(Function), + query: getAdminVariables, + }, + }, refetchAfterMutation: true, fullPath: null, id: null, diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js index 7181398c2a6..1937e3b34b7 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -1,13 +1,17 @@ import { GlListboxItem, GlCollapsibleListbox, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { allEnvironments } from '~/ci/ci_variable_list/constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { allEnvironments, ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_variable_list/constants'; import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; describe('Ci environments dropdown', () => { let wrapper; const envs = ['dev', 'prod', 'staging']; - const defaultProps = { environments: envs, selectedEnvironmentScope: '' }; + const defaultProps = { + areEnvironmentsLoading: false, + environments: envs, + selectedEnvironmentScope: '', + }; const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); const findListboxItemByIndex = (index) => wrapper.findAllComponents(GlListboxItem).at(index); @@ -15,13 +19,19 @@ describe('Ci environments dropdown', () => { const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findListboxText = () => findListbox().props('toggleText'); const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem); + const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice'); - const createComponent = ({ props = {}, searchTerm = '' } = {}) => { - wrapper = mount(CiEnvironmentsDropdown, { + const createComponent = ({ props = {}, searchTerm = '', enableFeatureFlag = false } = {}) => { + wrapper = mountExtended(CiEnvironmentsDropdown, { propsData: { ...defaultProps, ...props, }, + provide: { + glFeatures: { + ciLimitEnvironmentScope: enableFeatureFlag, + }, + }, }); findListbox().vm.$emit('search', searchTerm); @@ -40,19 +50,32 @@ describe('Ci environments dropdown', () => { }); describe('Search term is empty', () => { - beforeEach(() => { - createComponent({ props: { environments: envs } }); - }); + describe.each` + featureFlag | flagStatus | defaultEnvStatus | firstItemValue | envIndices + ${true} | ${'enabled'} | ${'prepends'} | ${'*'} | ${[1, 2, 3]} + ${false} | ${'disabled'} | ${'does not prepend'} | ${envs[0]} | ${[0, 1, 2]} + `( + 'when ciLimitEnvironmentScope feature flag is $flagStatus', + ({ featureFlag, defaultEnvStatus, firstItemValue, envIndices }) => { + beforeEach(() => { + createComponent({ props: { environments: envs }, enableFeatureFlag: featureFlag }); + }); - it('renders all environments when search term is empty', () => { - expect(findListboxItemByIndex(0).text()).toBe(envs[0]); - expect(findListboxItemByIndex(1).text()).toBe(envs[1]); - expect(findListboxItemByIndex(2).text()).toBe(envs[2]); - }); + it(`${defaultEnvStatus} * in listbox`, () => { + expect(findListboxItemByIndex(0).text()).toBe(firstItemValue); + }); - it('does not display active checkmark on the inactive stage', () => { - expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); - }); + it('renders all environments', () => { + expect(findListboxItemByIndex(envIndices[0]).text()).toBe(envs[0]); + expect(findListboxItemByIndex(envIndices[1]).text()).toBe(envs[1]); + expect(findListboxItemByIndex(envIndices[2]).text()).toBe(envs[2]); + }); + + it('does not display active checkmark', () => { + expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); + }); + }, + ); }); describe('when `*` is the value of selectedEnvironmentScope props', () => { @@ -68,46 +91,92 @@ describe('Ci environments dropdown', () => { }); }); - describe('Environments found', () => { + describe('When ciLimitEnvironmentScope feature flag is disabled', () => { const currentEnv = envs[2]; beforeEach(() => { - createComponent({ searchTerm: currentEnv }); + createComponent(); }); - it('renders only the environment searched for', () => { + it('filters on the frontend and renders only the environment searched for', async () => { + await findListbox().vm.$emit('search', currentEnv); + expect(findAllListboxItems()).toHaveLength(1); expect(findListboxItemByIndex(0).text()).toBe(currentEnv); }); - it('does not display create button', () => { - expect(findCreateWildcardButton().exists()).toBe(false); + it('does not emit event when searching', async () => { + expect(wrapper.emitted('search-environment-scope')).toBeUndefined(); + + await findListbox().vm.$emit('search', currentEnv); + + expect(wrapper.emitted('search-environment-scope')).toBeUndefined(); }); - describe('Custom events', () => { - describe('when selecting an environment', () => { - const itemIndex = 0; + it('does not display note about max environments shown', () => { + expect(findMaxEnvNote().exists()).toBe(false); + }); + }); - beforeEach(() => { - createComponent(); - }); + describe('When ciLimitEnvironmentScope feature flag is enabled', () => { + const currentEnv = envs[2]; - it('emits `select-environment` when an environment is clicked', () => { - findListbox().vm.$emit('select', envs[itemIndex]); - expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]); - }); + beforeEach(() => { + createComponent({ enableFeatureFlag: true }); + }); + + it('renders environments passed down to it', async () => { + await findListbox().vm.$emit('search', currentEnv); + + expect(findAllListboxItems()).toHaveLength(envs.length); + }); + + it('emits event when searching', async () => { + expect(wrapper.emitted('search-environment-scope')).toHaveLength(1); + + await findListbox().vm.$emit('search', currentEnv); + + expect(wrapper.emitted('search-environment-scope')).toHaveLength(2); + expect(wrapper.emitted('search-environment-scope')[1]).toEqual([currentEnv]); + }); + + it('renders loading icon while search query is loading', () => { + createComponent({ enableFeatureFlag: true, props: { areEnvironmentsLoading: true } }); + + expect(findListbox().props('searching')).toBe(true); + }); + + it('displays note about max environments shown', () => { + expect(findMaxEnvNote().exists()).toBe(true); + expect(findMaxEnvNote().text()).toContain(String(ENVIRONMENT_QUERY_LIMIT)); + }); + }); + + describe('Custom events', () => { + describe('when selecting an environment', () => { + const itemIndex = 0; + + beforeEach(() => { + createComponent(); }); - describe('when creating a new environment from a search term', () => { - const search = 'new-env'; - beforeEach(() => { - createComponent({ searchTerm: search }); - }); + it('emits `select-environment` when an environment is clicked', () => { + findListbox().vm.$emit('select', envs[itemIndex]); - it('emits create-environment-scope', () => { - findCreateWildcardButton().vm.$emit('click'); - expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); - }); + expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]); + }); + }); + + describe('when creating a new environment from a search term', () => { + const search = 'new-env'; + beforeEach(() => { + createComponent({ searchTerm: search }); + }); + + it('emits create-environment-scope', () => { + findCreateWildcardButton().vm.$emit('click'); + + expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js index 77d90a7667d..7436210fe70 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js @@ -4,6 +4,15 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciGroupVariables from '~/ci/ci_variable_list/components/ci_group_variables.vue'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, +} from '~/ci/ci_variable_list/constants'; +import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; +import addGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql'; +import deleteGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql'; +import updateGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql'; const mockProvide = { glFeatures: { @@ -37,8 +46,17 @@ describe('Ci Group Variable wrapper', () => { entity: 'group', fullPath: mockProvide.groupPath, hideEnvironmentScope: false, - mutationData: wrapper.vm.$options.mutationData, - queryData: wrapper.vm.$options.queryData, + mutationData: { + [ADD_MUTATION_ACTION]: addGroupVariable, + [UPDATE_MUTATION_ACTION]: updateGroupVariable, + [DELETE_MUTATION_ACTION]: deleteGroupVariable, + }, + queryData: { + ciVariables: { + lookup: expect.any(Function), + query: getGroupVariables, + }, + }, refetchAfterMutation: false, }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js index ce5237a84f7..69b0d4261b2 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js @@ -4,6 +4,16 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciProjectVariables from '~/ci/ci_variable_list/components/ci_project_variables.vue'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, +} from '~/ci/ci_variable_list/constants'; +import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql'; +import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; +import addProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql'; +import deleteProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql'; +import updateProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql'; const mockProvide = { projectFullPath: '/namespace/project', @@ -33,8 +43,21 @@ describe('Ci Project Variable wrapper', () => { entity: 'project', fullPath: mockProvide.projectFullPath, hideEnvironmentScope: false, - mutationData: wrapper.vm.$options.mutationData, - queryData: wrapper.vm.$options.queryData, + mutationData: { + [ADD_MUTATION_ACTION]: addProjectVariable, + [UPDATE_MUTATION_ACTION]: updateProjectVariable, + [DELETE_MUTATION_ACTION]: deleteProjectVariable, + }, + queryData: { + ciVariables: { + lookup: expect.any(Function), + query: getProjectVariables, + }, + environments: { + lookup: expect.any(Function), + query: getProjectEnvironments, + }, + }, refetchAfterMutation: false, }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index 8f3fccc2804..e8bfb370fb4 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -10,10 +10,12 @@ import { EVENT_LABEL, EVENT_ACTION, ENVIRONMENT_SCOPE_LINK_TITLE, + groupString, instanceString, + projectString, variableOptions, } from '~/ci/ci_variable_list/constants'; -import { mockVariablesWithScopes } from '../mocks'; +import { mockEnvs, mockVariablesWithScopes, mockVariablesWithUniqueScopes } from '../mocks'; import ModalStub from '../stubs'; describe('Ci variable modal', () => { @@ -42,12 +44,13 @@ describe('Ci variable modal', () => { }; const defaultProps = { + areEnvironmentsLoading: false, areScopedVariablesAvailable: true, environments: [], hideEnvironmentScope: false, mode: ADD_VARIABLE_ACTION, selectedVariable: {}, - variable: [], + variables: [], }; const createComponent = ({ mountFn = shallowMountExtended, props = {}, provide = {} } = {}) => { @@ -111,7 +114,6 @@ describe('Ci variable modal', () => { beforeEach(() => { createComponent({ props: { selectedVariable: currentVariable } }); - jest.spyOn(wrapper.vm, '$emit'); }); it('Dispatches `add-variable` action on submit', () => { @@ -152,7 +154,7 @@ describe('Ci variable modal', () => { findModal().vm.$emit('shown'); }); - it('keeps the value as false', async () => { + it('keeps the value as false', () => { expect( findProtectedVariableCheckbox().attributes('data-is-protected-checked'), ).toBeUndefined(); @@ -237,7 +239,6 @@ describe('Ci variable modal', () => { it('defaults to expanded and raw:false when adding a variable', () => { createComponent({ props: { selectedVariable: variable } }); - jest.spyOn(wrapper.vm, '$emit'); findModal().vm.$emit('shown'); @@ -262,7 +263,6 @@ describe('Ci variable modal', () => { mode: EDIT_VARIABLE_ACTION, }, }); - jest.spyOn(wrapper.vm, '$emit'); findModal().vm.$emit('shown'); await findExpandedVariableCheckbox().vm.$emit('change'); @@ -301,7 +301,6 @@ describe('Ci variable modal', () => { beforeEach(() => { createComponent({ props: { selectedVariable: variable, mode: EDIT_VARIABLE_ACTION } }); - jest.spyOn(wrapper.vm, '$emit'); }); it('button text is Update variable when updating', () => { @@ -349,6 +348,42 @@ describe('Ci variable modal', () => { expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE); expect(link.attributes('href')).toBe(defaultProvide.environmentScopeLink); }); + + describe('when feature flag is enabled', () => { + beforeEach(() => { + createComponent({ + props: { + environments: mockEnvs, + variables: mockVariablesWithUniqueScopes(projectString), + }, + provide: { glFeatures: { ciLimitEnvironmentScope: true } }, + }); + }); + + it('does not merge environment scope sources', () => { + const expectedLength = mockEnvs.length; + + expect(findCiEnvironmentsDropdown().props('environments')).toHaveLength(expectedLength); + }); + }); + + describe('when feature flag is disabled', () => { + const mockGroupVariables = mockVariablesWithUniqueScopes(groupString); + beforeEach(() => { + createComponent({ + props: { + environments: mockEnvs, + variables: mockGroupVariables, + }, + }); + }); + + it('merges environment scope sources', () => { + const expectedLength = mockGroupVariables.length + mockEnvs.length; + + expect(findCiEnvironmentsDropdown().props('environments')).toHaveLength(expectedLength); + }); + }); }); describe('and section is hidden', () => { diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js index 0141232a299..12ca9a78369 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js @@ -1,4 +1,3 @@ -import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import CiVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; import ciVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue'; @@ -16,6 +15,7 @@ describe('Ci variable table', () => { let wrapper; const defaultProps = { + areEnvironmentsLoading: false, areScopedVariablesAvailable: true, entity: 'project', environments: mapEnvironmentNames(mockEnvs), @@ -54,10 +54,10 @@ describe('Ci variable table', () => { it('passes props down correctly to the ci modal', async () => { createComponent(); - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); expect(findCiVariableModal().props()).toEqual({ + areEnvironmentsLoading: defaultProps.areEnvironmentsLoading, areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable, environments: defaultProps.environments, hideEnvironmentScope: defaultProps.hideEnvironmentScope, @@ -74,15 +74,13 @@ describe('Ci variable table', () => { }); it('passes down ADD mode when receiving an empty variable', async () => { - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); expect(findCiVariableModal().props('mode')).toBe(ADD_VARIABLE_ACTION); }); it('passes down EDIT mode when receiving a variable', async () => { - findCiVariableTable().vm.$emit('set-selected-variable', newVariable); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable', newVariable); expect(findCiVariableModal().props('mode')).toBe(EDIT_VARIABLE_ACTION); }); @@ -98,25 +96,21 @@ describe('Ci variable table', () => { }); it('shows modal when adding a new variable', async () => { - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); expect(findCiVariableModal().exists()).toBe(true); }); it('shows modal when updating a variable', async () => { - findCiVariableTable().vm.$emit('set-selected-variable', newVariable); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable', newVariable); expect(findCiVariableModal().exists()).toBe(true); }); it('hides modal when receiving the event from the modal', async () => { - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); - findCiVariableModal().vm.$emit('hideModal'); - await nextTick(); + await findCiVariableModal().vm.$emit('hideModal'); expect(findCiVariableModal().exists()).toBe(false); }); @@ -133,11 +127,9 @@ describe('Ci variable table', () => { ${'update-variable'} ${'delete-variable'} `('bubbles up the $eventName event', async ({ eventName }) => { - findCiVariableTable().vm.$emit('set-selected-variable'); - await nextTick(); + await findCiVariableTable().vm.$emit('set-selected-variable'); - findCiVariableModal().vm.$emit(eventName, newVariable); - await nextTick(); + await findCiVariableModal().vm.$emit(eventName, newVariable); expect(wrapper.emitted(eventName)).toEqual([[newVariable]]); }); @@ -154,10 +146,23 @@ describe('Ci variable table', () => { ${'handle-next-page'} | ${undefined} ${'sort-changed'} | ${{ sortDesc: true }} `('bubbles up the $eventName event', async ({ args, eventName }) => { - findCiVariableTable().vm.$emit(eventName, args); - await nextTick(); + await findCiVariableTable().vm.$emit(eventName, args); expect(wrapper.emitted(eventName)).toEqual([[args]]); }); }); + + describe('environment events', () => { + beforeEach(() => { + createComponent(); + }); + + it('bubbles up the search event', async () => { + await findCiVariableTable().vm.$emit('set-selected-variable'); + + await findCiVariableModal().vm.$emit('search-environment-scope', 'staging'); + + expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]); + }); + }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js index 87192006efc..a25d325f7a1 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -1,13 +1,12 @@ -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { assertProps } from 'helpers/assert_props'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import { resolvers } from '~/ci/ci_variable_list/graphql/settings'; -import { TYPENAME_GROUP } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; import ciVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; @@ -18,12 +17,11 @@ import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_varia import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; import { - ADD_MUTATION_ACTION, - DELETE_MUTATION_ACTION, - UPDATE_MUTATION_ACTION, + ENVIRONMENT_QUERY_LIMIT, environmentFetchErrorText, genericMutationErrorText, variableFetchErrorText, + mapMutationActionToToast, } from '~/ci/ci_variable_list/constants'; import { @@ -63,15 +61,22 @@ describe('Ci Variable Shared Component', () => { let mockApollo; let mockEnvironments; + let mockMutation; + let mockAddMutation; + let mockUpdateMutation; + let mockDeleteMutation; let mockVariables; + const mockToastShow = jest.fn(); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findCiTable = () => wrapper.findComponent(GlTable); const findCiSettings = () => wrapper.findComponent(ciVariableSettings); // eslint-disable-next-line consistent-return - async function createComponentWithApollo({ + function createComponentWithApollo({ customHandlers = null, + customResolvers = null, isLoading = false, props = { ...createProjectProps() }, provide = {}, @@ -81,7 +86,9 @@ describe('Ci Variable Shared Component', () => { [getProjectVariables, mockVariables], ]; - mockApollo = createMockApollo(handlers, resolvers); + const mutationResolvers = customResolvers || resolvers; + + mockApollo = createMockApollo(handlers, mutationResolvers); wrapper = shallowMount(ciVariableShared, { propsData: { @@ -94,6 +101,11 @@ describe('Ci Variable Shared Component', () => { }, apolloProvider: mockApollo, stubs: { ciVariableSettings, ciVariableTable }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, }); if (!isLoading) { @@ -104,6 +116,10 @@ describe('Ci Variable Shared Component', () => { beforeEach(() => { mockEnvironments = jest.fn(); mockVariables = jest.fn(); + mockMutation = jest.fn(); + mockAddMutation = jest.fn(); + mockUpdateMutation = jest.fn(); + mockDeleteMutation = jest.fn(); }); describe.each` @@ -111,11 +127,11 @@ describe('Ci Variable Shared Component', () => { ${true} | ${'enabled'} ${false} | ${'disabled'} `('When Pages FF is $text', ({ isVariablePagesEnabled }) => { - const featureFlagProvide = isVariablePagesEnabled + const pagesFeatureFlagProvide = isVariablePagesEnabled ? { glFeatures: { ciVariablesPages: true } } : {}; - describe('while queries are being fetch', () => { + describe('while queries are being fetched', () => { beforeEach(() => { createComponentWithApollo({ isLoading: true }); }); @@ -133,7 +149,7 @@ describe('Ci Variable Shared Component', () => { mockVariables.mockResolvedValue(mockProjectVariables); await createComponentWithApollo({ - provide: { ...createProjectProvide(), ...featureFlagProvide }, + provide: { ...createProjectProvide(), ...pagesFeatureFlagProvide }, }); }); @@ -163,7 +179,7 @@ describe('Ci Variable Shared Component', () => { mockEnvironments.mockResolvedValue(mockProjectEnvironments); mockVariables.mockRejectedValue(); - await createComponentWithApollo({ provide: featureFlagProvide }); + await createComponentWithApollo({ provide: pagesFeatureFlagProvide }); }); it('calls createAlert with the expected error message', () => { @@ -176,7 +192,7 @@ describe('Ci Variable Shared Component', () => { mockEnvironments.mockRejectedValue(); mockVariables.mockResolvedValue(mockProjectVariables); - await createComponentWithApollo({ provide: featureFlagProvide }); + await createComponentWithApollo({ provide: pagesFeatureFlagProvide }); }); it('calls createAlert with the expected error message', () => { @@ -187,134 +203,283 @@ describe('Ci Variable Shared Component', () => { describe('environment query', () => { describe('when there is an environment key in queryData', () => { - beforeEach(async () => { + beforeEach(() => { mockEnvironments.mockResolvedValue(mockProjectEnvironments); + mockVariables.mockResolvedValue(mockProjectVariables); + }); + it('environments are fetched', async () => { await createComponentWithApollo({ props: { ...createProjectProps() }, - provide: featureFlagProvide, + provide: pagesFeatureFlagProvide, }); + + expect(mockEnvironments).toHaveBeenCalled(); }); - it('is executed', () => { - expect(mockVariables).toHaveBeenCalled(); + describe('when Limit Environment Scope FF is enabled', () => { + beforeEach(async () => { + await createComponentWithApollo({ + props: { ...createProjectProps() }, + provide: { + glFeatures: { + ciLimitEnvironmentScope: true, + ciVariablesPages: isVariablePagesEnabled, + }, + }, + }); + }); + + it('initial query is called with the correct variables', () => { + expect(mockEnvironments).toHaveBeenCalledWith({ + first: ENVIRONMENT_QUERY_LIMIT, + fullPath: '/namespace/project/', + search: '', + }); + }); + + it(`refetches environments when search term is present`, async () => { + expect(mockEnvironments).toHaveBeenCalledTimes(1); + expect(mockEnvironments).toHaveBeenCalledWith(expect.objectContaining({ search: '' })); + + await findCiSettings().vm.$emit('search-environment-scope', 'staging'); + + expect(mockEnvironments).toHaveBeenCalledTimes(2); + expect(mockEnvironments).toHaveBeenCalledWith( + expect.objectContaining({ search: 'staging' }), + ); + }); + }); + + describe('when Limit Environment Scope FF is disabled', () => { + beforeEach(async () => { + await createComponentWithApollo({ + props: { ...createProjectProps() }, + provide: pagesFeatureFlagProvide, + }); + }); + + it('initial query is called with the correct variables', () => { + expect(mockEnvironments).toHaveBeenCalledWith({ fullPath: '/namespace/project/' }); + }); + + it(`does not refetch environments when search term is present`, async () => { + expect(mockEnvironments).toHaveBeenCalledTimes(1); + + await findCiSettings().vm.$emit('search-environment-scope', 'staging'); + + expect(mockEnvironments).toHaveBeenCalledTimes(1); + }); }); }); - describe('when there isnt an environment key in queryData', () => { + describe("when there isn't an environment key in queryData", () => { beforeEach(async () => { mockVariables.mockResolvedValue(mockGroupVariables); await createComponentWithApollo({ props: { ...createGroupProps() }, - provide: featureFlagProvide, + provide: pagesFeatureFlagProvide, }); }); - it('is skipped', () => { - expect(mockVariables).not.toHaveBeenCalled(); + it('fetching environments is skipped', () => { + expect(mockEnvironments).not.toHaveBeenCalled(); }); }); }); describe('mutations', () => { const groupProps = createGroupProps(); + const instanceProps = createInstanceProps(); + const projectProps = createProjectProps(); - beforeEach(async () => { - mockVariables.mockResolvedValue(mockGroupVariables); + let mockMutationMap; - await createComponentWithApollo({ - customHandlers: [[getGroupVariables, mockVariables]], - props: groupProps, - provide: featureFlagProvide, - }); - }); - it.each` - actionName | mutation | event - ${'add'} | ${groupProps.mutationData[ADD_MUTATION_ACTION]} | ${'add-variable'} - ${'update'} | ${groupProps.mutationData[UPDATE_MUTATION_ACTION]} | ${'update-variable'} - ${'delete'} | ${groupProps.mutationData[DELETE_MUTATION_ACTION]} | ${'delete-variable'} - `( - 'calls the right mutation from propsData when user performs $actionName variable', - async ({ event, mutation }) => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); - - await findCiSettings().vm.$emit(event, newVariable); + describe('error handling and feedback', () => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockGroupVariables); + mockMutation.mockResolvedValue({ ...mockGroupVariables.data, errors: [] }); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation, - variables: { - endpoint: mockProvide.endpoint, - fullPath: groupProps.fullPath, - id: convertToGraphQLId(TYPENAME_GROUP, groupProps.id), - variable: newVariable, + await createComponentWithApollo({ + customHandlers: [[getGroupVariables, mockVariables]], + customResolvers: { + Mutation: { + ...resolvers.Mutation, + addGroupVariable: mockMutation, + updateGroupVariable: mockMutation, + deleteGroupVariable: mockMutation, + }, }, + props: groupProps, + provide: pagesFeatureFlagProvide, }); - }, - ); + }); - it.each` - actionName | event - ${'add'} | ${'add-variable'} - ${'update'} | ${'update-variable'} - ${'delete'} | ${'delete-variable'} - `( - 'throws with the specific graphql error if present when user performs $actionName variable', - async ({ event }) => { - const graphQLErrorMessage = 'There is a problem with this graphQL action'; - jest - .spyOn(wrapper.vm.$apollo, 'mutate') - .mockResolvedValue({ data: { ciVariableMutation: { errors: [graphQLErrorMessage] } } }); - await findCiSettings().vm.$emit(event, newVariable); - await nextTick(); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); - }, - ); + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'throws the specific graphql error if present when user performs $actionName variable', + async ({ event }) => { + const graphQLErrorMessage = 'There is a problem with this graphQL action'; + mockMutation.mockResolvedValue({ + ...mockGroupVariables.data, + errors: [graphQLErrorMessage], + }); + + await findCiSettings().vm.$emit(event, newVariable); + await waitForPromises(); - it.each` - actionName | event - ${'add'} | ${'add-variable'} - ${'update'} | ${'update-variable'} - ${'delete'} | ${'delete-variable'} + expect(mockMutation).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: graphQLErrorMessage }); + }, + ); + + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'throws generic error on failure with no graphql errors and user performs $actionName variable', + async ({ event }) => { + mockMutation.mockRejectedValue(); + + await findCiSettings().vm.$emit(event, newVariable); + await waitForPromises(); + + expect(mockMutation).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); + }, + ); + + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'displays toast message after user performs $actionName variable', + async ({ actionName, event }) => { + await findCiSettings().vm.$emit(event, newVariable); + await waitForPromises(); + + expect(mockMutation).toHaveBeenCalled(); + expect(mockToastShow).toHaveBeenCalledWith( + mapMutationActionToToast[actionName](newVariable.key), + ); + }, + ); + }); + + const setupMockMutations = (mockResolvedMutation) => { + mockAddMutation.mockResolvedValue(mockResolvedMutation); + mockUpdateMutation.mockResolvedValue(mockResolvedMutation); + mockDeleteMutation.mockResolvedValue(mockResolvedMutation); + + return { + add: mockAddMutation, + update: mockUpdateMutation, + delete: mockDeleteMutation, + }; + }; + + describe.each` + scope | mockVariablesResolvedValue | getVariablesHandler | addMutationName | updateMutationName | deleteMutationName | props + ${'instance'} | ${mockVariables} | ${getAdminVariables} | ${'addAdminVariable'} | ${'updateAdminVariable'} | ${'deleteAdminVariable'} | ${instanceProps} + ${'group'} | ${mockGroupVariables} | ${getGroupVariables} | ${'addGroupVariable'} | ${'updateGroupVariable'} | ${'deleteGroupVariable'} | ${groupProps} + ${'project'} | ${mockProjectVariables} | ${getProjectVariables} | ${'addProjectVariable'} | ${'updateProjectVariable'} | ${'deleteProjectVariable'} | ${projectProps} `( - 'throws generic error on failure with no graphql errors and user performs $actionName variable', - async ({ event }) => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => { - throw new Error(); + '$scope variable mutations', + ({ + addMutationName, + deleteMutationName, + getVariablesHandler, + mockVariablesResolvedValue, + updateMutationName, + props, + }) => { + beforeEach(async () => { + mockVariables.mockResolvedValue(mockVariablesResolvedValue); + mockMutationMap = setupMockMutations({ ...mockVariables.data, errors: [] }); + + await createComponentWithApollo({ + customHandlers: [[getVariablesHandler, mockVariables]], + customResolvers: { + Mutation: { + ...resolvers.Mutation, + [addMutationName]: mockAddMutation, + [updateMutationName]: mockUpdateMutation, + [deleteMutationName]: mockDeleteMutation, + }, + }, + props, + provide: pagesFeatureFlagProvide, + }); }); - await findCiSettings().vm.$emit(event, newVariable); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); - expect(createAlert).toHaveBeenCalledWith({ message: genericMutationErrorText }); + it.each` + actionName | event + ${'add'} | ${'add-variable'} + ${'update'} | ${'update-variable'} + ${'delete'} | ${'delete-variable'} + `( + 'calls the right mutation when user performs $actionName variable', + async ({ event, actionName }) => { + await findCiSettings().vm.$emit(event, newVariable); + await waitForPromises(); + + expect(mockMutationMap[actionName]).toHaveBeenCalledWith( + expect.anything(), + { + endpoint: mockProvide.endpoint, + fullPath: props.fullPath, + id: props.id, + variable: newVariable, + }, + expect.anything(), + expect.anything(), + ); + }, + ); }, ); describe('without fullpath and ID props', () => { beforeEach(async () => { + mockMutation.mockResolvedValue({ ...mockAdminVariables.data, errors: [] }); mockVariables.mockResolvedValue(mockAdminVariables); await createComponentWithApollo({ customHandlers: [[getAdminVariables, mockVariables]], + customResolvers: { + Mutation: { + ...resolvers.Mutation, + addAdminVariable: mockMutation, + }, + }, props: createInstanceProps(), - provide: featureFlagProvide, + provide: pagesFeatureFlagProvide, }); }); it('does not pass fullPath and ID to the mutation', async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); - await findCiSettings().vm.$emit('add-variable', newVariable); + await waitForPromises(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: wrapper.props().mutationData[ADD_MUTATION_ACTION], - variables: { + expect(mockMutation).toHaveBeenCalledWith( + expect.anything(), + { endpoint: mockProvide.endpoint, variable: newVariable, }, - }); + expect.anything(), + expect.anything(), + ); }); }); }); @@ -359,10 +524,11 @@ describe('Ci Variable Shared Component', () => { await createComponentWithApollo({ customHandlers, props, - provide: { ...provide, ...featureFlagProvide }, + provide: { ...provide, ...pagesFeatureFlagProvide }, }); expect(findCiSettings().props()).toEqual({ + areEnvironmentsLoading: false, areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, hideEnvironmentScope: defaultProps.hideEnvironmentScope, pageInfo: defaultProps.pageInfo, @@ -379,29 +545,29 @@ describe('Ci Variable Shared Component', () => { describe('refetchAfterMutation', () => { it.each` - bool | text - ${true} | ${'refetches the variables'} - ${false} | ${'does not refetch the variables'} - `('when $bool it $text', async ({ bool }) => { + bool | text | timesQueryCalled + ${true} | ${'refetches the variables'} | ${2} + ${false} | ${'does not refetch the variables'} | ${1} + `('when $bool it $text', async ({ bool, timesQueryCalled }) => { + mockMutation.mockResolvedValue({ ...mockAdminVariables.data, errors: [] }); + mockVariables.mockResolvedValue(mockAdminVariables); + await createComponentWithApollo({ + customHandlers: [[getAdminVariables, mockVariables]], + customResolvers: { + Mutation: { + ...resolvers.Mutation, + addAdminVariable: mockMutation, + }, + }, props: { ...createInstanceProps(), refetchAfterMutation: bool }, - provide: featureFlagProvide, + provide: pagesFeatureFlagProvide, }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: {} }); - jest - .spyOn(wrapper.vm.$apollo.queries.ciVariables, 'refetch') - .mockImplementation(jest.fn()); - await findCiSettings().vm.$emit('add-variable', newVariable); + await waitForPromises(); - await nextTick(); - - if (bool) { - expect(wrapper.vm.$apollo.queries.ciVariables.refetch).toHaveBeenCalled(); - } else { - expect(wrapper.vm.$apollo.queries.ciVariables.refetch).not.toHaveBeenCalled(); - } + expect(mockVariables).toHaveBeenCalledTimes(timesQueryCalled); }); }); @@ -409,7 +575,7 @@ describe('Ci Variable Shared Component', () => { describe('queryData', () => { let error; - beforeEach(async () => { + beforeEach(() => { mockVariables.mockResolvedValue(mockGroupVariables); }); @@ -418,7 +584,7 @@ describe('Ci Variable Shared Component', () => { await createComponentWithApollo({ customHandlers: [[getGroupVariables, mockVariables]], props: { ...createGroupProps() }, - provide: featureFlagProvide, + provide: pagesFeatureFlagProvide, }); } catch (e) { error = e; @@ -428,26 +594,21 @@ describe('Ci Variable Shared Component', () => { } }); - it('will not mount component with wrong data', async () => { - try { - await createComponentWithApollo({ - customHandlers: [[getGroupVariables, mockVariables]], - props: { ...createGroupProps(), queryData: { wrongKey: {} } }, - provide: featureFlagProvide, - }); - } catch (e) { - error = e; - } finally { - expect(wrapper.exists()).toBe(false); - expect(error.toString()).toContain('custom validator check failed for prop'); - } + it('report custom validator error on wrong data', () => { + expect(() => + assertProps( + ciVariableShared, + { ...defaultProps, ...createGroupProps(), queryData: { wrongKey: {} } }, + { provide: mockProvide }, + ), + ).toThrow('custom validator check failed for prop'); }); }); describe('mutationData', () => { let error; - beforeEach(async () => { + beforeEach(() => { mockVariables.mockResolvedValue(mockGroupVariables); }); @@ -455,7 +616,7 @@ describe('Ci Variable Shared Component', () => { try { await createComponentWithApollo({ props: { ...createGroupProps() }, - provide: featureFlagProvide, + provide: pagesFeatureFlagProvide, }); } catch (e) { error = e; @@ -465,18 +626,14 @@ describe('Ci Variable Shared Component', () => { } }); - it('will not mount component with wrong data', async () => { - try { - await createComponentWithApollo({ - props: { ...createGroupProps(), mutationData: { wrongKey: {} } }, - provide: featureFlagProvide, - }); - } catch (e) { - error = e; - } finally { - expect(wrapper.exists()).toBe(false); - expect(error.toString()).toContain('custom validator check failed for prop'); - } + it('report custom validator error on wrong data', () => { + expect(() => + assertProps( + ciVariableShared, + { ...defaultProps, ...createGroupProps(), mutationData: { wrongKey: {} } }, + { provide: { ...mockProvide, ...pagesFeatureFlagProvide } }, + ), + ).toThrow('custom validator check failed for prop'); }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js index 2ef789e89c3..0b28cb06cec 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js @@ -82,11 +82,11 @@ describe('Ci variable table', () => { expect(findRevealButton().exists()).toBe(true); }); - it('displays the correct amount of variables', async () => { + it('displays the correct amount of variables', () => { expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length); }); - it('displays the correct variable options', async () => { + it('displays the correct variable options', () => { expect(findOptionsValues(0)).toBe('Protected, Expanded'); expect(findOptionsValues(1)).toBe('Masked'); }); diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js index 4da4f53f69f..f9450803308 100644 --- a/spec/frontend/ci/ci_variable_list/mocks.js +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -56,6 +56,11 @@ export const mockVariablesWithScopes = (kind) => return { ...variable, environmentScope: '*' }; }); +export const mockVariablesWithUniqueScopes = (kind) => + mockVariables(kind).map((variable) => { + return { ...variable, environmentScope: variable.value }; + }); + const createDefaultVars = ({ withScope = true, kind } = {}) => { let base = mockVariables(kind); diff --git a/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js index b2dfa900b1d..03f346181e4 100644 --- a/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js @@ -34,7 +34,7 @@ describe('Pipeline Editor | Commit Form', () => { const findCancelBtn = () => wrapper.find('[type="reset"]'); describe('when the form is displayed', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); }); @@ -57,7 +57,7 @@ describe('Pipeline Editor | Commit Form', () => { }); describe('when buttons are clicked', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({}, mount); }); diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js index 5399924b462..0296ab5a65c 100644 --- a/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js @@ -68,7 +68,7 @@ describe('Pipeline config reference card', () => { }); }; - it('tracks help page links', async () => { + it('tracks help page links', () => { const { CI_EXAMPLES_LINK, CI_HELP_LINK, diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js index 560e8840d57..2861fc35342 100644 --- a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -58,7 +58,7 @@ describe('CI Editor Header', () => { expect(findLinkBtn().props('icon')).toBe('external-link'); }); - it('tracks the click on the browse button', async () => { + it('tracks the click on the browse button', () => { const { browseTemplates } = pipelineEditorTrackingOptions.actions; testTracker(findLinkBtn(), browseTemplates); @@ -91,7 +91,7 @@ describe('CI Editor Header', () => { expect(wrapper.emitted('open-drawer')).toHaveLength(1); }); - it('tracks open help drawer action', async () => { + it('tracks open help drawer action', () => { const { actions } = pipelineEditorTrackingOptions; testTracker(findHelpBtn(), actions.openHelpDrawer); diff --git a/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js index bf14f4c4cd6..3a99949413b 100644 --- a/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -288,7 +288,7 @@ describe('Pipeline editor branch switcher', () => { }); describe('with a search term', () => { - beforeEach(async () => { + beforeEach(() => { mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches); }); diff --git a/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js index 306dd78d395..f2effcb2966 100644 --- a/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js @@ -60,11 +60,11 @@ describe('Pipeline editor file nav', () => { expect(fileTreeItems().exists()).toBe(false); }); - it('renders alert tip', async () => { + it('renders alert tip', () => { expect(findTip().exists()).toBe(true); }); - it('renders learn more link', async () => { + it('renders learn more link', () => { expect(findTip().props('secondaryButtonLink')).toBe(mockIncludesHelpPagePath); }); @@ -87,7 +87,7 @@ describe('Pipeline editor file nav', () => { }); }); - it('does not render alert tip', async () => { + it('does not render alert tip', () => { expect(findTip().exists()).toBe(false); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js index 7bf955012c7..b8526e569ec 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js @@ -96,7 +96,7 @@ describe('Pipeline Status', () => { await waitForPromises(); }); - it('should emit an error event when query fails', async () => { + it('should emit an error event when query fails', () => { expect(wrapper.emitted('showError')).toHaveLength(1); expect(wrapper.emitted('showError')[0]).toEqual([ { diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js index 3faa2890254..8ca88472bf1 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js @@ -77,7 +77,7 @@ describe('Pipeline Status', () => { await waitForPromises(); }); - it('query is called with correct variables', async () => { + it('query is called with correct variables', () => { expect(mockPipelineQuery).toHaveBeenCalledTimes(1); expect(mockPipelineQuery).toHaveBeenCalledWith({ fullPath: mockProjectFullPath, diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js new file mode 100644 index 00000000000..9046be4a45e --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js @@ -0,0 +1,127 @@ +import ArtifactsAndCacheItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; + +describe('Artifacts and cache item', () => { + let wrapper; + + const findArtifactsPathsInputByIndex = (index) => + wrapper.findByTestId(`artifacts-paths-input-${index}`); + const findArtifactsExcludeInputByIndex = (index) => + wrapper.findByTestId(`artifacts-exclude-input-${index}`); + const findCachePathsInputByIndex = (index) => wrapper.findByTestId(`cache-paths-input-${index}`); + const findCacheKeyInput = () => wrapper.findByTestId('cache-key-input'); + const findDeleteArtifactsPathsButtonByIndex = (index) => + wrapper.findByTestId(`delete-artifacts-paths-button-${index}`); + const findDeleteArtifactsExcludeButtonByIndex = (index) => + wrapper.findByTestId(`delete-artifacts-exclude-button-${index}`); + const findDeleteCachePathsButtonByIndex = (index) => + wrapper.findByTestId(`delete-cache-paths-button-${index}`); + const findAddArtifactsPathsButton = () => wrapper.findByTestId('add-artifacts-paths-button'); + const findAddArtifactsExcludeButton = () => wrapper.findByTestId('add-artifacts-exclude-button'); + const findAddCachePathsButton = () => wrapper.findByTestId('add-cache-paths-button'); + + const dummyArtifactsPath = 'dummyArtifactsPath'; + const dummyArtifactsExclude = 'dummyArtifactsExclude'; + const dummyCachePath = 'dummyCachePath'; + const dummyCacheKey = 'dummyCacheKey'; + + const createComponent = ({ job = JSON.parse(JSON.stringify(JOB_TEMPLATE)) } = {}) => { + wrapper = shallowMountExtended(ArtifactsAndCacheItem, { + propsData: { + job, + }, + }); + }; + + it('should emit update job event when filling inputs', () => { + createComponent(); + + expect(wrapper.emitted('update-job')).toBeUndefined(); + + findArtifactsPathsInputByIndex(0).vm.$emit('input', dummyArtifactsPath); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toStrictEqual([ + 'artifacts.paths[0]', + dummyArtifactsPath, + ]); + + findArtifactsExcludeInputByIndex(0).vm.$emit('input', dummyArtifactsExclude); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toStrictEqual([ + 'artifacts.exclude[0]', + dummyArtifactsExclude, + ]); + + findCachePathsInputByIndex(0).vm.$emit('input', dummyCachePath); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[0]', dummyCachePath]); + + findCacheKeyInput().vm.$emit('input', dummyCacheKey); + + expect(wrapper.emitted('update-job')).toHaveLength(4); + expect(wrapper.emitted('update-job')[3]).toStrictEqual(['cache.key', dummyCacheKey]); + }); + + it('should emit update job event when click add item button', () => { + createComponent(); + + findAddArtifactsPathsButton().vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toStrictEqual(['artifacts.paths[1]', '']); + + findAddArtifactsExcludeButton().vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toStrictEqual(['artifacts.exclude[1]', '']); + + findAddCachePathsButton().vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[1]', '']); + }); + + it('should emit update job event when click delete item button', () => { + createComponent({ + job: { + artifacts: { + paths: ['0', '1'], + exclude: ['0', '1'], + }, + cache: { + paths: ['0', '1'], + key: '', + }, + }, + }); + + findDeleteArtifactsPathsButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toStrictEqual(['artifacts.paths[0]']); + + findDeleteArtifactsExcludeButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toStrictEqual(['artifacts.exclude[0]']); + + findDeleteCachePathsButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toStrictEqual(['cache.paths[0]']); + }); + + it('should not emit update job event when click the only one delete item button', () => { + createComponent(); + + findDeleteArtifactsPathsButtonByIndex(0).vm.$emit('click'); + findDeleteArtifactsExcludeButtonByIndex(0).vm.$emit('click'); + findDeleteCachePathsButtonByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('update-job')).toBeUndefined(); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js index eaad0dae90d..373fb1b70c7 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js @@ -1,4 +1,3 @@ -import createStore from '~/ci/pipeline_editor/store'; import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; @@ -18,8 +17,8 @@ describe('Job setup item', () => { const createComponent = () => { wrapper = shallowMountExtended(JobSetupItem, { - store: createStore(), propsData: { + availableStages: ['.pre', dummyJobStage, '.post'], tagOptions: [ { id: 'tag1', name: 'tag1' }, { id: 'tag2', name: 'tag2' }, diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js new file mode 100644 index 00000000000..659ccb25996 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js @@ -0,0 +1,70 @@ +import RulesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + JOB_TEMPLATE, + JOB_RULES_WHEN, + JOB_RULES_START_IN, +} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; + +describe('Rules item', () => { + let wrapper; + + const findRulesWhenSelect = () => wrapper.findByTestId('rules-when-select'); + const findRulesStartInNumberInput = () => wrapper.findByTestId('rules-start-in-number-input'); + const findRulesStartInUnitSelect = () => wrapper.findByTestId('rules-start-in-unit-select'); + const findRulesAllowFailureCheckBox = () => wrapper.findByTestId('rules-allow-failure-checkbox'); + + const dummyRulesWhen = JOB_RULES_WHEN.delayed.value; + const dummyRulesStartInNumber = 2; + const dummyRulesStartInUnit = JOB_RULES_START_IN.week.value; + const dummyRulesAllowFailure = true; + + const createComponent = () => { + wrapper = shallowMountExtended(RulesItem, { + propsData: { + isStartValid: true, + job: JSON.parse(JSON.stringify(JOB_TEMPLATE)), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should emit update job event when filling inputs', () => { + expect(wrapper.emitted('update-job')).toBeUndefined(); + + findRulesWhenSelect().vm.$emit('input', dummyRulesWhen); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toEqual([ + 'rules[0].when', + JOB_RULES_WHEN.delayed.value, + ]); + + findRulesStartInNumberInput().vm.$emit('input', dummyRulesStartInNumber); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toEqual([ + 'rules[0].start_in', + `2 ${JOB_RULES_START_IN.second.value}s`, + ]); + + findRulesStartInUnitSelect().vm.$emit('input', dummyRulesStartInUnit); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toEqual([ + 'rules[0].start_in', + `2 ${dummyRulesStartInUnit}s`, + ]); + + findRulesAllowFailureCheckBox().vm.$emit('input', dummyRulesAllowFailure); + + expect(wrapper.emitted('update-job')).toHaveLength(4); + expect(wrapper.emitted('update-job')[3]).toEqual([ + 'rules[0].allow_failure', + dummyRulesAllowFailure, + ]); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js index b293805d653..08aa7e3a11a 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js @@ -5,13 +5,16 @@ import { stringify } from 'yaml'; import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue'; import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue'; import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue'; -import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql'; +import ArtifactsAndCacheItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue'; +import RulesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue'; +import { JOB_RULES_WHEN } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; +import getRunnerTags from '~/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createStore from '~/ci/pipeline_editor/store'; -import { mockAllRunnersQueryResponse } from 'jest/ci/pipeline_editor/mock_data'; + import { mountExtended } from 'helpers/vue_test_utils_helper'; import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; +import { mockRunnersTagsQueryResponse, mockLintResponse, mockCiYml } from '../../mock_data'; Vue.use(VueApollo); @@ -23,22 +26,32 @@ describe('Job assistant drawer', () => { const dummyJobScript = 'b'; const dummyImageName = 'c'; const dummyImageEntrypoint = 'd'; + const dummyArtifactsPath = 'e'; + const dummyArtifactsExclude = 'f'; + const dummyCachePath = 'g'; + const dummyCacheKey = 'h'; + const dummyRulesWhen = JOB_RULES_WHEN.delayed.value; + const dummyRulesStartIn = '1 second'; + const dummyRulesAllowFailure = true; const findDrawer = () => wrapper.findComponent(GlDrawer); const findJobSetupItem = () => wrapper.findComponent(JobSetupItem); const findImageItem = () => wrapper.findComponent(ImageItem); + const findArtifactsAndCacheItem = () => wrapper.findComponent(ArtifactsAndCacheItem); + const findRulesItem = () => wrapper.findComponent(RulesItem); const findConfirmButton = () => wrapper.findByTestId('confirm-button'); const findCancelButton = () => wrapper.findByTestId('cancel-button'); const createComponent = () => { mockApollo = createMockApollo([ - [getAllRunners, jest.fn().mockResolvedValue(mockAllRunnersQueryResponse)], + [getRunnerTags, jest.fn().mockResolvedValue(mockRunnersTagsQueryResponse)], ]); wrapper = mountExtended(JobAssistantDrawer, { - store: createStore(), propsData: { + ciConfigData: mockLintResponse, + ciFileContent: mockCiYml, isVisible: true, }, apolloProvider: mockApollo, @@ -54,10 +67,27 @@ describe('Job assistant drawer', () => { expect(findJobSetupItem().exists()).toBe(true); }); + it('job setup item should have tag options', () => { + expect(findJobSetupItem().props('tagOptions')).toEqual([ + { id: 'tag1', name: 'tag1' }, + { id: 'tag2', name: 'tag2' }, + { id: 'tag3', name: 'tag3' }, + { id: 'tag4', name: 'tag4' }, + ]); + }); + it('should contain image accordion', () => { expect(findImageItem().exists()).toBe(true); }); + it('should contain artifacts and cache item accordion', () => { + expect(findArtifactsAndCacheItem().exists()).toBe(true); + }); + + it('should contain rules accordion', () => { + expect(findRulesItem().exists()).toBe(true); + }); + it('should emit close job assistant drawer event when closing the drawer', () => { expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined(); @@ -74,8 +104,7 @@ describe('Job assistant drawer', () => { expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1); }); - it('trigger validate if job name is empty', async () => { - const updateCiConfigSpy = jest.spyOn(wrapper.vm, 'updateCiConfig'); + it('should block submit if job name is empty', async () => { findJobSetupItem().vm.$emit('update-job', 'script', 'b'); findConfirmButton().trigger('click'); @@ -83,7 +112,17 @@ describe('Job assistant drawer', () => { expect(findJobSetupItem().props('isNameValid')).toBe(false); expect(findJobSetupItem().props('isScriptValid')).toBe(true); - expect(updateCiConfigSpy).toHaveBeenCalledTimes(0); + expect(wrapper.emitted('updateCiConfig')).toBeUndefined(); + }); + + it('should block submit if rules when is delayed and start in is out of range', async () => { + findRulesItem().vm.$emit('update-job', 'rules[0].when', JOB_RULES_WHEN.delayed.value); + findRulesItem().vm.$emit('update-job', 'rules[0].start_in', '2 weeks'); + findConfirmButton().trigger('click'); + + await nextTick(); + + expect(wrapper.emitted('updateCiConfig')).toBeUndefined(); }); describe('when enter valid input', () => { @@ -92,10 +131,24 @@ describe('Job assistant drawer', () => { findJobSetupItem().vm.$emit('update-job', 'script', dummyJobScript); findImageItem().vm.$emit('update-job', 'image.name', dummyImageName); findImageItem().vm.$emit('update-job', 'image.entrypoint', [dummyImageEntrypoint]); + findArtifactsAndCacheItem().vm.$emit('update-job', 'artifacts.paths', [dummyArtifactsPath]); + findArtifactsAndCacheItem().vm.$emit('update-job', 'artifacts.exclude', [ + dummyArtifactsExclude, + ]); + findArtifactsAndCacheItem().vm.$emit('update-job', 'cache.paths', [dummyCachePath]); + findArtifactsAndCacheItem().vm.$emit('update-job', 'cache.key', dummyCacheKey); + findRulesItem().vm.$emit('update-job', 'rules[0].allow_failure', dummyRulesAllowFailure); + findRulesItem().vm.$emit('update-job', 'rules[0].when', dummyRulesWhen); + findRulesItem().vm.$emit('update-job', 'rules[0].start_in', dummyRulesStartIn); }); it('passes correct prop to accordions', () => { - const accordions = [findJobSetupItem(), findImageItem()]; + const accordions = [ + findJobSetupItem(), + findImageItem(), + findArtifactsAndCacheItem(), + findRulesItem(), + ]; accordions.forEach((accordion) => { expect(accordion.props('job')).toMatchObject({ name: dummyJobName, @@ -104,6 +157,21 @@ describe('Job assistant drawer', () => { name: dummyImageName, entrypoint: [dummyImageEntrypoint], }, + artifacts: { + paths: [dummyArtifactsPath], + exclude: [dummyArtifactsExclude], + }, + cache: { + paths: [dummyCachePath], + key: dummyCacheKey, + }, + rules: [ + { + allow_failure: dummyRulesAllowFailure, + when: dummyRulesWhen, + start_in: dummyRulesStartIn, + }, + ], }); }); }); @@ -129,19 +197,60 @@ describe('Job assistant drawer', () => { expect(findJobSetupItem().props('job')).toMatchObject({ name: '', script: '' }); }); - it('should update correct ci content when click add button', () => { - const updateCiConfigSpy = jest.spyOn(wrapper.vm, 'updateCiConfig'); + it('should omit keys with default value when click add button', () => { + findRulesItem().vm.$emit('update-job', 'rules[0].allow_failure', false); + findRulesItem().vm.$emit('update-job', 'rules[0].when', JOB_RULES_WHEN.onSuccess.value); + findRulesItem().vm.$emit('update-job', 'rules[0].start_in', dummyRulesStartIn); + findConfirmButton().trigger('click'); + + expect(wrapper.emitted('updateCiConfig')).toStrictEqual([ + [ + `${wrapper.props('ciFileContent')}\n${stringify({ + [dummyJobName]: { + script: dummyJobScript, + image: { name: dummyImageName, entrypoint: [dummyImageEntrypoint] }, + artifacts: { + paths: [dummyArtifactsPath], + exclude: [dummyArtifactsExclude], + }, + cache: { + paths: [dummyCachePath], + key: dummyCacheKey, + }, + }, + })}`, + ], + ]); + }); + it('should update correct ci content when click add button', () => { findConfirmButton().trigger('click'); - expect(updateCiConfigSpy).toHaveBeenCalledWith( - `\n${stringify({ - [dummyJobName]: { - script: dummyJobScript, - image: { name: dummyImageName, entrypoint: [dummyImageEntrypoint] }, - }, - })}`, - ); + expect(wrapper.emitted('updateCiConfig')).toStrictEqual([ + [ + `${wrapper.props('ciFileContent')}\n${stringify({ + [dummyJobName]: { + script: dummyJobScript, + image: { name: dummyImageName, entrypoint: [dummyImageEntrypoint] }, + artifacts: { + paths: [dummyArtifactsPath], + exclude: [dummyArtifactsExclude], + }, + cache: { + paths: [dummyCachePath], + key: dummyCacheKey, + }, + rules: [ + { + allow_failure: dummyRulesAllowFailure, + when: dummyRulesWhen, + start_in: dummyRulesStartIn, + }, + ], + }, + })}`, + ], + ]); }); it('should emit scroll editor to button event when click add button', () => { diff --git a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js index 52a543c7686..cbdf01105c7 100644 --- a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -314,13 +314,13 @@ describe('Pipeline editor tabs component', () => { createComponent(); }); - it('shows walkthrough popover', async () => { + it('shows walkthrough popover', () => { expect(findWalkthroughPopover().exists()).toBe(true); }); }); describe('when isNewCiConfigFile prop is false', () => { - it('does not show walkthrough popover', async () => { + it('does not show walkthrough popover', () => { createComponent({ props: { isNewCiConfigFile: false } }); expect(findWalkthroughPopover().exists()).toBe(false); }); diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js index a9aabb103f2..3d84f06967a 100644 --- a/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js @@ -25,7 +25,7 @@ describe('FileTreePopover component', () => { }); describe('default', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ stubs: { GlSprintf } }); }); @@ -45,7 +45,7 @@ describe('FileTreePopover component', () => { }); describe('when popover has already been dismissed before', () => { - it('does not render popover', async () => { + it('does not render popover', () => { localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true'); createComponent(); diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js index 23f9c7a87ee..18eec48ad83 100644 --- a/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js @@ -20,7 +20,7 @@ describe('ValidatePopover component', () => { const findFeedbackLink = () => wrapper.findByTestId('feedback-link'); describe('template', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ stubs: { GlLink, GlSprintf }, }); diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js index 186fd803d47..37339b1c422 100644 --- a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js @@ -18,7 +18,7 @@ describe('WalkthroughPopover component', () => { await wrapper.findByTestId('ctaBtn').trigger('click'); }); - it('emits "walkthrough-popover-cta-clicked" event', async () => { + it('emits "walkthrough-popover-cta-clicked" event', () => { expect(wrapper.emitted()['walkthrough-popover-cta-clicked']).toHaveLength(1); }); }); diff --git a/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js index a4e7abba7b0..f02b1f5efbc 100644 --- a/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js @@ -64,7 +64,7 @@ describe('~/ci/pipeline_editor/components/ui/editor_tab.vue', () => { mockChildMounted = jest.fn(); }); - it('tabs are mounted lazily', async () => { + it('tabs are mounted lazily', () => { createMockedWrapper(); expect(mockChildMounted).toHaveBeenCalledTimes(0); @@ -192,7 +192,7 @@ describe('~/ci/pipeline_editor/components/ui/editor_tab.vue', () => { createMockedWrapper(); }); - it('renders correct number of badges', async () => { + it('renders correct number of badges', () => { expect(findBadges()).toHaveLength(1); expect(findBadges().at(0).text()).toBe('NEW'); }); diff --git a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js index 6a6cc3a14de..893f6775ac5 100644 --- a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js @@ -34,7 +34,7 @@ describe('~/ci/pipeline_editor/graphql/resolvers', () => { }); /* eslint-disable no-underscore-dangle */ - it('lint data has correct type names', async () => { + it('lint data has correct type names', () => { expect(result.__typename).toBe('CiLintContent'); expect(result.jobs[0].__typename).toBe('CiLintJob'); diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js index ecfc477184b..865dd34fbfe 100644 --- a/spec/frontend/ci/pipeline_editor/mock_data.js +++ b/spec/frontend/ci/pipeline_editor/mock_data.js @@ -583,86 +583,31 @@ export const mockCommitCreateResponse = { }, }; -export const mockAllRunnersQueryResponse = { +export const mockRunnersTagsQueryResponse = { data: { runners: { nodes: [ { id: 'gid://gitlab/Ci::Runner/1', - description: 'test', - runnerType: 'PROJECT_TYPE', - shortSha: 'DdTYMQGS', - version: '15.6.1', - ipAddress: '127.0.0.1', - active: true, - locked: true, - jobCount: 0, - jobExecutionStatus: 'IDLE', - tagList: ['tag1', 'tag2', 'tag3'], - createdAt: '2022-11-29T09:37:43Z', - contactedAt: null, - status: 'NEVER_CONTACTED', - userPermissions: { - updateRunner: true, - deleteRunner: true, - __typename: 'RunnerPermissions', - }, - groups: null, - ownerProject: { - id: 'gid://gitlab/Project/1', - name: '123', - nameWithNamespace: 'Administrator / 123', - webUrl: 'http://127.0.0.1:3000/root/test', - __typename: 'Project', - }, + tagList: ['tag1', 'tag2'], __typename: 'CiRunner', - upgradeStatus: 'NOT_AVAILABLE', - adminUrl: 'http://127.0.0.1:3000/admin/runners/1', - editAdminUrl: 'http://127.0.0.1:3000/admin/runners/1/edit', }, { id: 'gid://gitlab/Ci::Runner/2', - description: 'test', - runnerType: 'PROJECT_TYPE', - shortSha: 'DdTYMQGA', - version: '15.6.1', - ipAddress: '127.0.0.1', - active: true, - locked: true, - jobCount: 0, - jobExecutionStatus: 'IDLE', - tagList: ['tag3', 'tag4'], - createdAt: '2022-11-29T09:37:43Z', - contactedAt: null, - status: 'NEVER_CONTACTED', - userPermissions: { - updateRunner: true, - deleteRunner: true, - __typename: 'RunnerPermissions', - }, - groups: null, - ownerProject: { - id: 'gid://gitlab/Project/1', - name: '123', - nameWithNamespace: 'Administrator / 123', - webUrl: 'http://127.0.0.1:3000/root/test', - __typename: 'Project', - }, + tagList: ['tag2', 'tag3'], + __typename: 'CiRunner', + }, + { + id: 'gid://gitlab/Ci::Runner/3', + tagList: ['tag2', 'tag4'], + __typename: 'CiRunner', + }, + { + id: 'gid://gitlab/Ci::Runner/4', + tagList: [], __typename: 'CiRunner', - upgradeStatus: 'NOT_AVAILABLE', - adminUrl: 'http://127.0.0.1:3000/admin/runners/2', - editAdminUrl: 'http://127.0.0.1:3000/admin/runners/2/edit', }, ], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0yOSAwOTozNzo0My40OTEwNTEwMDAgKzAwMDAiLCJpZCI6IjIifQ', - endCursor: - 'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0yOSAwOTozNzo0My40OTEwNTEwMDAgKzAwMDAiLCJpZCI6IjIifQ', - __typename: 'PageInfo', - }, __typename: 'CiRunnerConnection', }, }, diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js index 7a13bfbd1ab..8bac46a3e9c 100644 --- a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js @@ -8,7 +8,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; -import createStore from '~/ci/pipeline_editor/store'; import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorEmptyState from '~/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; import PipelineEditorMessages from '~/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue'; @@ -81,9 +80,7 @@ describe('Pipeline editor app component', () => { provide = {}, stubs = {}, } = {}) => { - const store = createStore(); wrapper = shallowMount(PipelineEditorApp, { - store, provide: { ...defaultProvide, ...provide }, stubs, mocks: { @@ -99,7 +96,7 @@ describe('Pipeline editor app component', () => { }); }; - const createComponentWithApollo = async ({ + const createComponentWithApollo = ({ provide = {}, stubs = {}, withUndefinedBranch = false, @@ -255,10 +252,6 @@ describe('Pipeline editor app component', () => { .mockImplementation(jest.fn()); }); - it('available stages is updated', () => { - expect(wrapper.vm.$store.state.availableStages).toStrictEqual(['test', 'build']); - }); - it('shows pipeline editor home component', () => { expect(findEditorHome().exists()).toBe(true); }); @@ -267,7 +260,7 @@ describe('Pipeline editor app component', () => { expect(findAlert().exists()).toBe(false); }); - it('ci config query is called with correct variables', async () => { + it('ci config query is called with correct variables', () => { expect(mockCiConfigData).toHaveBeenCalledWith({ content: mockCiYml, projectPath: mockProjectFullPath, @@ -294,7 +287,7 @@ describe('Pipeline editor app component', () => { .mockImplementation(jest.fn()); }); - it('shows an empty state and does not show editor home component', async () => { + it('shows an empty state and does not show editor home component', () => { expect(findEmptyState().exists()).toBe(true); expect(findAlert().exists()).toBe(false); expect(findEditorHome().exists()).toBe(false); diff --git a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js index 1349461d8bc..9015031b6c8 100644 --- a/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js @@ -142,7 +142,7 @@ describe('Pipeline New Form', () => { await waitForPromises(); }); - it('displays the correct values for the provided query params', async () => { + it('displays the correct values for the provided query params', () => { expect(findVariableTypes().at(0).props('text')).toBe('Variable'); expect(findVariableTypes().at(1).props('text')).toBe('File'); expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); @@ -154,7 +154,7 @@ describe('Pipeline New Form', () => { expect(findValueInputs().at(0).element.value).toBe('test_var_val'); }); - it('displays an empty variable for the user to fill out', async () => { + it('displays an empty variable for the user to fill out', () => { expect(findKeyInputs().at(2).element.value).toBe(''); expect(findValueInputs().at(2).element.value).toBe(''); expect(findVariableTypes().at(2).props('text')).toBe('Variable'); @@ -186,12 +186,12 @@ describe('Pipeline New Form', () => { }); describe('Pipeline creation', () => { - beforeEach(async () => { + beforeEach(() => { mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); mock.onPost(pipelinesPath).reply(HTTP_STATUS_OK, newPipelinePostResponse); }); - it('does not submit the native HTML form', async () => { + it('does not submit the native HTML form', () => { createComponentWithApollo(); findForm().vm.$emit('submit', dummySubmitEvent); @@ -328,7 +328,7 @@ describe('Pipeline New Form', () => { }); const testBehaviorWhenCacheIsPopulated = (queryResponse) => { - beforeEach(async () => { + beforeEach(() => { mockCiConfigVariables.mockResolvedValue(queryResponse); createComponentWithApollo({ method: mountExtended }); }); @@ -406,7 +406,7 @@ describe('Pipeline New Form', () => { await waitForPromises(); }); - it('displays all the variables', async () => { + it('displays all the variables', () => { expect(findVariableRows()).toHaveLength(6); }); @@ -445,7 +445,7 @@ describe('Pipeline New Form', () => { await waitForPromises(); }); - it('displays variables with description only', async () => { + it('displays variables with description only', () => { expect(findVariableRows()).toHaveLength(2); // extra empty variable is added at the end }); }); diff --git a/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js index 60ace483712..82dac1358c5 100644 --- a/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js +++ b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js @@ -54,7 +54,7 @@ describe('Pipeline New Form', () => { expect(findRefsDropdownItems()).toHaveLength(0); }); - it('does not make requests immediately', async () => { + it('does not make requests immediately', () => { expect(mock.history.get).toHaveLength(0); }); @@ -117,14 +117,14 @@ describe('Pipeline New Form', () => { await waitForPromises(); }); - it('requests filtered tags and branches', async () => { + it('requests filtered tags and branches', () => { expect(mock.history.get).toHaveLength(2); expect(mock.history.get[1].params).toEqual({ search: mockSearchTerm, }); }); - it('displays dropdown with branches and tags', async () => { + it('displays dropdown with branches and tags', () => { const filteredRefLength = mockFilteredRefs.Tags.length + mockFilteredRefs.Branches.length; expect(findRefsDropdownItems()).toHaveLength(filteredRefLength); diff --git a/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js b/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js index c45267e5a47..e48f556c246 100644 --- a/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/delete_pipeline_schedule_modal_spec.js @@ -20,13 +20,13 @@ describe('Delete pipeline schedule modal', () => { createComponent(); }); - it('emits the deleteSchedule event', async () => { + it('emits the deleteSchedule event', () => { findModal().vm.$emit('primary'); expect(wrapper.emitted()).toEqual({ deleteSchedule: [[]] }); }); - it('emits the hideModal event', async () => { + it('emits the hideModal event', () => { findModal().vm.$emit('hide'); expect(wrapper.emitted()).toEqual({ hideModal: [[]] }); diff --git a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js index e3965d13c19..7cc254b7653 100644 --- a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_spec.js @@ -26,13 +26,13 @@ describe('Take ownership modal', () => { ); }); - it('emits the takeOwnership event', async () => { + it('emits the takeOwnership event', () => { findModal().vm.$emit('primary'); expect(wrapper.emitted()).toEqual({ takeOwnership: [[]] }); }); - it('emits the hideModal event', async () => { + it('emits the hideModal event', () => { findModal().vm.$emit('hide'); expect(wrapper.emitted()).toEqual({ hideModal: [[]] }); diff --git a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js index 85b1d3b1b2f..58a1c0bc18d 100644 --- a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js +++ b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js @@ -1,5 +1,3 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; import { GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; @@ -11,14 +9,15 @@ import AdminNewRunnerApp from '~/ci/runner/admin_new_runner/admin_new_runner_app import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; -import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM, WINDOWS_PLATFORM } from '~/ci/runner/constants'; +import { + PARAM_KEY_PLATFORM, + INSTANCE_TYPE, + DEFAULT_PLATFORM, + WINDOWS_PLATFORM, +} from '~/ci/runner/constants'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; import { redirectTo } from '~/lib/utils/url_utility'; -import { runnerCreateResult } from '../mock_data'; - -const mockLegacyRegistrationToken = 'LEGACY_REGISTRATION_TOKEN'; - -Vue.use(VueApollo); +import { runnerCreateResult, mockRegistrationToken } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); jest.mock('~/alert'); @@ -40,7 +39,7 @@ describe('AdminNewRunnerApp', () => { const createComponent = () => { wrapper = shallowMountExtended(AdminNewRunnerApp, { propsData: { - legacyRegistrationToken: mockLegacyRegistrationToken, + legacyRegistrationToken: mockRegistrationToken, }, directives: { GlModal: createMockDirective('gl-modal'), @@ -58,7 +57,7 @@ describe('AdminNewRunnerApp', () => { describe('Shows legacy modal', () => { it('passes legacy registration to modal', () => { expect(findRunnerInstructionsModal().props('registrationToken')).toEqual( - mockLegacyRegistrationToken, + mockRegistrationToken, ); }); @@ -76,8 +75,11 @@ describe('AdminNewRunnerApp', () => { }); describe('Runner form', () => { - it('shows the runner create form', () => { - expect(findRunnerCreateForm().exists()).toBe(true); + it('shows the runner create form for an instance runner', () => { + expect(findRunnerCreateForm().props()).toEqual({ + runnerType: INSTANCE_TYPE, + groupId: null, + }); }); describe('When a runner is saved', () => { @@ -93,7 +95,7 @@ describe('AdminNewRunnerApp', () => { }); it('redirects to the registration page', () => { - const url = `${mockCreatedRunner.registerAdminUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; expect(redirectTo).toHaveBeenCalledWith(url); }); @@ -106,7 +108,7 @@ describe('AdminNewRunnerApp', () => { }); it('redirects to the registration page with the platform', () => { - const url = `${mockCreatedRunner.registerAdminUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; expect(redirectTo).toHaveBeenCalledWith(url); }); diff --git a/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js b/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js index d04df85d58f..60244ba5bc2 100644 --- a/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js +++ b/spec/frontend/ci/runner/admin_register_runner/admin_register_runner_app_spec.js @@ -37,7 +37,7 @@ describe('AdminRegisterRunnerApp', () => { }; describe('When showing runner details', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); }); @@ -64,7 +64,7 @@ describe('AdminRegisterRunnerApp', () => { }); describe('When another platform has been selected', () => { - beforeEach(async () => { + beforeEach(() => { setWindowLocation(`?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`); createComponent(); diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js index 9d9142f2c68..d1f95aef349 100644 --- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -81,7 +81,7 @@ describe('AdminRunnerShowApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('expect GraphQL ID to be requested', async () => { + it('expect GraphQL ID to be requested', () => { expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); @@ -89,7 +89,7 @@ describe('AdminRunnerShowApp', () => { expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); }); - it('displays the runner edit and pause buttons', async () => { + it('displays the runner edit and pause buttons', () => { expect(findRunnerEditButton().attributes('href')).toBe(mockRunner.editAdminUrl); expect(findRunnerPauseButton().exists()).toBe(true); expect(findRunnerDeleteButton().exists()).toBe(true); @@ -99,7 +99,7 @@ describe('AdminRunnerShowApp', () => { expect(findRunnerDetailsTabs().props('runner')).toEqual(mockRunner); }); - it('shows basic runner details', async () => { + it('shows basic runner details', () => { const expected = `Description My Runner Last contact Never contacted Version 1.0.0 diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js index 0cf6241c24f..2cd1bc0b2f8 100644 --- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -57,13 +57,13 @@ import { allRunnersDataPaginated, onlineContactTimeoutSecs, staleTimeoutSecs, + mockRegistrationToken, newRunnerPath, emptyPageInfo, emptyStateSvgPath, emptyStateFilteredSvgPath, } from '../mock_data'; -const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; const mockRunners = allRunnersData.data.runners.nodes; const mockRunnersCount = runnersCountData.data.runners.count; @@ -208,13 +208,13 @@ describe('AdminRunnersApp', () => { it('runner item links to the runner admin page', async () => { await createComponent({ mountFn: mountExtended }); - const { id, shortSha } = mockRunners[0]; + const { id, shortSha, adminUrl } = mockRunners[0]; const numericId = getIdFromGraphQLId(id); const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); expect(runnerLink.text()).toBe(`#${numericId} (${shortSha})`); - expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`); + expect(runnerLink.attributes('href')).toBe(adminUrl); }); it('renders runner actions for each runner', async () => { @@ -264,7 +264,7 @@ describe('AdminRunnersApp', () => { }); describe('Single runner row', () => { - const { id: graphqlId, shortSha } = mockRunners[0]; + const { id: graphqlId, shortSha, adminUrl } = mockRunners[0]; const id = getIdFromGraphQLId(graphqlId); beforeEach(async () => { @@ -273,11 +273,11 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('Links to the runner page', async () => { + it('Links to the runner page', () => { const runnerLink = wrapper.find('tr [data-testid="td-summary"]').findComponent(GlLink); expect(runnerLink.text()).toBe(`#${id} (${shortSha})`); - expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); + expect(runnerLink.attributes('href')).toBe(adminUrl); }); it('Shows job status and links to jobs', () => { @@ -286,13 +286,10 @@ describe('AdminRunnersApp', () => { .findComponent(RunnerJobStatusBadge); expect(badge.props('jobStatus')).toBe(mockRunners[0].jobExecutionStatus); - - const badgeHref = new URL(badge.attributes('href')); - expect(badgeHref.pathname).toBe(`/admin/runners/${id}`); - expect(badgeHref.hash).toBe(`#${JOBS_ROUTE_PATH}`); + expect(badge.attributes('href')).toBe(`${adminUrl}#${JOBS_ROUTE_PATH}`); }); - it('When runner is paused or unpaused, some data is refetched', async () => { + it('When runner is paused or unpaused, some data is refetched', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); findRunnerActionsCell().vm.$emit('toggledPaused'); @@ -301,7 +298,7 @@ describe('AdminRunnersApp', () => { expect(showToast).toHaveBeenCalledTimes(0); }); - it('When runner is deleted, data is refetched and a toast message is shown', async () => { + it('When runner is deleted, data is refetched and a toast message is shown', () => { findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); @@ -324,7 +321,7 @@ describe('AdminRunnersApp', () => { { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, { type: PARAM_KEY_PAUSED, value: { data: 'true', operator: '=' } }, ], - sort: 'CREATED_DESC', + sort: DEFAULT_SORT, pagination: {}, }); }); @@ -410,7 +407,7 @@ describe('AdminRunnersApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('count data is refetched', async () => { + it('count data is refetched', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); @@ -418,7 +415,7 @@ describe('AdminRunnersApp', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); }); - it('toast is shown', async () => { + it('toast is shown', () => { expect(showToast).toHaveBeenCalledTimes(0); findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' }); @@ -480,11 +477,11 @@ describe('AdminRunnersApp', () => { await createComponent(); }); - it('error is shown to the user', async () => { + it('error is shown to the user', () => { expect(createAlert).toHaveBeenCalledTimes(1); }); - it('error is reported to sentry', async () => { + it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ error: new Error('Error!'), component: 'AdminRunnersApp', diff --git a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js index ec23d8415e8..c435dd57de2 100644 --- a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import RunnerStatusCell from '~/ci/runner/components/cells/runner_status_cell.vue'; import RunnerStatusBadge from '~/ci/runner/components/runner_status_badge.vue'; @@ -20,7 +20,7 @@ describe('RunnerStatusCell', () => { const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge); const createComponent = ({ runner = {}, ...options } = {}) => { - wrapper = mount(RunnerStatusCell, { + wrapper = shallowMount(RunnerStatusCell, { propsData: { runner: { runnerType: INSTANCE_TYPE, @@ -30,6 +30,10 @@ describe('RunnerStatusCell', () => { ...runner, }, }, + stubs: { + RunnerStatusBadge, + RunnerPausedBadge, + }, ...options, }); }; diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js index 585a03c0811..23ec170961a 100644 --- a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js @@ -1,5 +1,6 @@ -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; @@ -11,11 +12,13 @@ import { I18N_INSTANCE_TYPE, PROJECT_TYPE, I18N_NO_DESCRIPTION, + I18N_CREATED_AT_LABEL, + I18N_CREATED_AT_BY_LABEL, } from '~/ci/runner/constants'; -import { allRunnersData } from '../../mock_data'; +import { allRunnersWithCreatorData } from '../../mock_data'; -const mockRunner = allRunnersData.data.runners.nodes[0]; +const mockRunner = allRunnersWithCreatorData.data.runners.nodes[0]; describe('RunnerTypeCell', () => { let wrapper; @@ -142,10 +145,42 @@ describe('RunnerTypeCell', () => { expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+'); }); - it('Displays created at', () => { - expect(findRunnerSummaryField('calendar').findComponent(TimeAgo).props('time')).toBe( - mockRunner.createdAt, - ); + describe('Displays creation info', () => { + const findCreatedTime = () => findRunnerSummaryField('calendar').findComponent(TimeAgo); + + it('Displays created at ...', () => { + createComponent({ + createdBy: null, + }); + + expect(findRunnerSummaryField('calendar').text()).toMatchInterpolatedText( + sprintf(I18N_CREATED_AT_LABEL, { + timeAgo: findCreatedTime().text(), + }), + ); + expect(findCreatedTime().props('time')).toBe(mockRunner.createdAt); + }); + + it('Displays created at ... by ...', () => { + expect(findRunnerSummaryField('calendar').text()).toMatchInterpolatedText( + sprintf(I18N_CREATED_AT_BY_LABEL, { + timeAgo: findCreatedTime().text(), + avatar: mockRunner.createdBy.username, + }), + ); + expect(findCreatedTime().props('time')).toBe(mockRunner.createdAt); + }); + + it('Displays creator avatar', () => { + const { name, avatarUrl, webUrl, username } = mockRunner.createdBy; + + expect(wrapper.findComponent(UserAvatarLink).props()).toMatchObject({ + imgAlt: expect.stringContaining(name), + imgSrc: avatarUrl, + linkHref: webUrl, + tooltipText: username, + }); + }); }); it('Displays tag list', () => { diff --git a/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap b/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap index 09d032fd32d..5eb7ffaacd6 100644 --- a/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/ci/runner/components/registration/__snapshots__/utils_spec.js.snap @@ -75,8 +75,7 @@ exports[`registration utils for "linux" platform registerCommand is correct 1`] Array [ "gitlab-runner register", " --url http://test.host", - " --registration-token REGISTRATION_TOKEN", - " --description 'RUNNER'", + " --token MOCK_AUTHENTICATION_TOKEN", ] `; @@ -130,8 +129,7 @@ exports[`registration utils for "osx" platform registerCommand is correct 1`] = Array [ "gitlab-runner register", " --url http://test.host", - " --registration-token REGISTRATION_TOKEN", - " --description 'RUNNER'", + " --token MOCK_AUTHENTICATION_TOKEN", ] `; @@ -189,8 +187,7 @@ exports[`registration utils for "windows" platform registerCommand is correct 1` Array [ ".\\\\gitlab-runner.exe register", " --url http://test.host", - " --registration-token REGISTRATION_TOKEN", - " --description 'RUNNER'", + " --token MOCK_AUTHENTICATION_TOKEN", ] `; diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js index 9ed59b0a57d..d23723807b1 100644 --- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -1,10 +1,10 @@ import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; -import { mount, shallowMount, createWrapper } from '@vue/test-utils'; +import { createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { s__ } from '~/locale'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -21,9 +21,7 @@ import { mockRunnerPlatforms, mockInstructions, } from 'jest/vue_shared/components/runner_instructions/mock_data'; - -const mockToken = '0123456789'; -const maskToken = '**********'; +import { mockRegistrationToken } from '../../mock_data'; Vue.use(VueApollo); @@ -53,17 +51,15 @@ describe('RegistrationDropdown', () => { await waitForPromises(); }; - const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { - wrapper = extendedWrapper( - mountFn(RegistrationDropdown, { - propsData: { - registrationToken: mockToken, - type: INSTANCE_TYPE, - ...props, - }, - ...options, - }), - ); + const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(RegistrationDropdown, { + propsData: { + registrationToken: mockRegistrationToken, + type: INSTANCE_TYPE, + ...props, + }, + ...options, + }); }; const createComponentWithModal = () => { @@ -79,7 +75,7 @@ describe('RegistrationDropdown', () => { // Use `attachTo` to find the modal attachTo: document.body, }, - mount, + mountExtended, ); }; @@ -89,7 +85,7 @@ describe('RegistrationDropdown', () => { ${GROUP_TYPE} | ${s__('Runners|Register a group runner')} ${PROJECT_TYPE} | ${s__('Runners|Register a project runner')} `('Dropdown text for type $type is "$text"', () => { - createComponent({ props: { type: INSTANCE_TYPE } }, mount); + createComponent({ props: { type: INSTANCE_TYPE } }, mountExtended); expect(wrapper.text()).toContain('Register an instance runner'); }); @@ -111,7 +107,7 @@ describe('RegistrationDropdown', () => { describe('When the dropdown item is clicked', () => { beforeEach(async () => { - createComponentWithModal({}, mount); + createComponentWithModal({}, mountExtended); await openModal(); }); @@ -142,7 +138,15 @@ describe('RegistrationDropdown', () => { }); it('Displays masked value by default', () => { - createComponent({}, mount); + const mockToken = '0123456789'; + const maskToken = '**********'; + + createComponent( + { + props: { registrationToken: mockToken }, + }, + mountExtended, + ); expect(findRegistrationTokenInput().element.value).toBe(maskToken); }); @@ -171,7 +175,7 @@ describe('RegistrationDropdown', () => { }; it('Updates token input', async () => { - createComponent({}, mount); + createComponent({}, mountExtended); expect(findRegistrationToken().props('value')).not.toBe(newToken); @@ -181,11 +185,11 @@ describe('RegistrationDropdown', () => { }); it('Updates token in modal', async () => { - createComponentWithModal({}, mount); + createComponentWithModal({}, mountExtended); await openModal(); - expect(findModalContent()).toContain(mockToken); + expect(findModalContent()).toContain(mockRegistrationToken); await resetToken(); diff --git a/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js b/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js index eb4b659091d..8c196d7b5e3 100644 --- a/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_instructions_spec.js @@ -22,16 +22,13 @@ import { RUNNER_REGISTRATION_POLLING_INTERVAL_MS, I18N_REGISTRATION_SUCCESS, } from '~/ci/runner/constants'; -import { runnerForRegistration } from '../../mock_data'; +import { runnerForRegistration, mockAuthenticationToken } from '../../mock_data'; Vue.use(VueApollo); -const MOCK_TOKEN = 'MOCK_TOKEN'; -const mockDescription = runnerForRegistration.data.runner.description; - const mockRunner = { ...runnerForRegistration.data.runner, - ephemeralAuthenticationToken: MOCK_TOKEN, + ephemeralAuthenticationToken: mockAuthenticationToken, }; const mockRunnerWithoutToken = { ...runnerForRegistration.data.runner, @@ -53,6 +50,18 @@ describe('RegistrationInstructions', () => { await waitForPromises(); }; + const mockBeforeunload = () => { + const event = new Event('beforeunload'); + const preventDefault = jest.spyOn(event, 'preventDefault'); + const returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); + + return { + event, + preventDefault, + returnValueSetter, + }; + }; + const mockResolvedRunner = (runner = mockRunner) => { mockRunnerQuery.mockResolvedValue({ data: { @@ -84,7 +93,7 @@ describe('RegistrationInstructions', () => { window.gon.gitlab_url = TEST_HOST; }); - it('loads runner with id', async () => { + it('loads runner with id', () => { createComponent(); expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunner.id }); @@ -139,13 +148,12 @@ describe('RegistrationInstructions', () => { command: [ 'gitlab-runner register', ` --url ${TEST_HOST}`, - ` --registration-token ${MOCK_TOKEN}`, - ` --description '${mockDescription}'`, + ` --token ${mockAuthenticationToken}`, ], prompt: '$', }); - expect(step1.find('[data-testid="runner-token"]').text()).toBe(MOCK_TOKEN); - expect(step1.findComponent(ClipboardButton).props('text')).toBe(MOCK_TOKEN); + expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken); + expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken); }); it('renders step 1 in loading state', () => { @@ -169,9 +177,8 @@ describe('RegistrationInstructions', () => { expect(step1.findComponent(CliCommand).props('command')).toEqual([ 'gitlab-runner register', ` --url ${TEST_HOST}`, - ` --description '${mockDescription}'`, ]); - expect(step1.find('[data-testid="runner-token"]').exists()).toBe(false); + expect(step1.findByTestId('runner-token').exists()).toBe(false); expect(step1.findComponent(ClipboardButton).exists()).toBe(false); }); @@ -211,11 +218,10 @@ describe('RegistrationInstructions', () => { expect(step1.findComponent(CliCommand).props('command')).toEqual([ 'gitlab-runner register', ` --url ${TEST_HOST}`, - ` --registration-token ${MOCK_TOKEN}`, - ` --description '${mockDescription}'`, + ` --token ${mockAuthenticationToken}`, ]); - expect(step1.find('[data-testid="runner-token"]').text()).toBe(MOCK_TOKEN); - expect(step1.findComponent(ClipboardButton).props('text')).toBe(MOCK_TOKEN); + expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken); + expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken); }); it('when runner is not available (e.g. deleted), the UI does not update', async () => { @@ -226,11 +232,10 @@ describe('RegistrationInstructions', () => { expect(step1.findComponent(CliCommand).props('command')).toEqual([ 'gitlab-runner register', ` --url ${TEST_HOST}`, - ` --registration-token ${MOCK_TOKEN}`, - ` --description '${mockDescription}'`, + ` --token ${mockAuthenticationToken}`, ]); - expect(step1.find('[data-testid="runner-token"]').text()).toBe(MOCK_TOKEN); - expect(step1.findComponent(ClipboardButton).props('text')).toBe(MOCK_TOKEN); + expect(step1.findByTestId('runner-token').text()).toBe(mockAuthenticationToken); + expect(step1.findComponent(ClipboardButton).props('text')).toBe(mockAuthenticationToken); }); }); }); @@ -273,6 +278,20 @@ describe('RegistrationInstructions', () => { it('does not show success message', () => { expect(wrapper.text()).not.toContain(I18N_REGISTRATION_SUCCESS); }); + + describe('when the page is closing', () => { + it('warns the user against closing', () => { + const { event, preventDefault, returnValueSetter } = mockBeforeunload(); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(returnValueSetter).not.toHaveBeenCalled(); + + window.dispatchEvent(event); + + expect(preventDefault).toHaveBeenCalledWith(); + expect(returnValueSetter).toHaveBeenCalledWith(expect.any(String)); + }); + }); }); describe('when the runner has been registered', () => { @@ -288,6 +307,20 @@ describe('RegistrationInstructions', () => { expect(wrapper.text()).toContain('🎉'); expect(wrapper.text()).toContain(I18N_REGISTRATION_SUCCESS); }); + + describe('when the page is closing', () => { + it('does not warn the user against closing', () => { + const { event, preventDefault, returnValueSetter } = mockBeforeunload(); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(returnValueSetter).not.toHaveBeenCalled(); + + window.dispatchEvent(event); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(returnValueSetter).not.toHaveBeenCalled(); + }); + }); }); }); }); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js index ff69fd6d3d6..bfdde922e17 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -18,7 +18,7 @@ jest.mock('~/ci/runner/sentry_utils'); Vue.use(VueApollo); Vue.use(GlToast); -const mockNewToken = 'NEW_TOKEN'; +const mockNewRegistrationToken = 'MOCK_NEW_REGISTRATION_TOKEN'; const modalID = 'token-reset-modal'; describe('RegistrationTokenResetDropdownItem', () => { @@ -54,7 +54,7 @@ describe('RegistrationTokenResetDropdownItem', () => { runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({ data: { runnersRegistrationTokenReset: { - token: mockNewToken, + token: mockNewRegistrationToken, errors: [], }, }, @@ -109,7 +109,7 @@ describe('RegistrationTokenResetDropdownItem', () => { it('emits result', () => { expect(wrapper.emitted('tokenReset')).toHaveLength(1); - expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]); + expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewRegistrationToken]); }); it('does not show a loading state', () => { diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js index 4f44e6e10b2..fc659f7974f 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js @@ -3,9 +3,7 @@ import Vue from 'vue'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RegistrationToken from '~/ci/runner/components/registration/registration_token.vue'; import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; - -const mockToken = '01234567890'; -const mockMasked = '***********'; +import { mockRegistrationToken } from '../../mock_data'; describe('RegistrationToken', () => { let wrapper; @@ -18,7 +16,7 @@ describe('RegistrationToken', () => { const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => { wrapper = mountFn(RegistrationToken, { propsData: { - value: mockToken, + value: mockRegistrationToken, inputId: 'token-value', ...props, }, @@ -30,7 +28,7 @@ describe('RegistrationToken', () => { it('Displays value and copy button', () => { createComponent(); - expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken); + expect(findInputCopyToggleVisibility().props('value')).toBe(mockRegistrationToken); expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe( 'Copy registration token', ); @@ -38,9 +36,17 @@ describe('RegistrationToken', () => { // Component integration test to ensure secure masking it('Displays masked value by default', () => { - createComponent({ mountFn: mountExtended }); + const mockToken = '0123456789'; + const maskToken = '**********'; + + createComponent({ + props: { + value: mockToken, + }, + mountFn: mountExtended, + }); - expect(wrapper.find('input').element.value).toBe(mockMasked); + expect(wrapper.find('input').element.value).toBe(maskToken); }); describe('When the copy to clipboard button is clicked', () => { diff --git a/spec/frontend/ci/runner/components/registration/utils_spec.js b/spec/frontend/ci/runner/components/registration/utils_spec.js index acf5993b15b..997cc5769ee 100644 --- a/spec/frontend/ci/runner/components/registration/utils_spec.js +++ b/spec/frontend/ci/runner/components/registration/utils_spec.js @@ -14,8 +14,7 @@ import { platformArchitectures, } from '~/ci/runner/components/registration/utils'; -const REGISTRATION_TOKEN = 'REGISTRATION_TOKEN'; -const DESCRIPTION = 'RUNNER'; +import { mockAuthenticationToken } from '../../mock_data'; describe('registration utils', () => { beforeEach(() => { @@ -33,8 +32,7 @@ describe('registration utils', () => { expect( registerCommand({ platform, - registrationToken: REGISTRATION_TOKEN, - description: DESCRIPTION, + token: mockAuthenticationToken, }), ).toMatchSnapshot(); @@ -47,26 +45,6 @@ describe('registration utils', () => { }, ); - describe.each([LINUX_PLATFORM, MACOS_PLATFORM])('for "%s" platform', (platform) => { - it.each` - description | parameter - ${'my runner'} | ${"'my runner'"} - ${"bob's runner"} | ${"'bob'\\''s runner'"} - `('registerCommand escapes description `$description`', ({ description, parameter }) => { - expect(registerCommand({ platform, description })[2]).toBe(` --description ${parameter}`); - }); - }); - - describe.each([WINDOWS_PLATFORM])('for "%s" platform', (platform) => { - it.each` - description | parameter - ${'my runner'} | ${"'my runner'"} - ${"bob's runner"} | ${"'bob''s runner'"} - `('registerCommand escapes description `$description`', ({ description, parameter }) => { - expect(registerCommand({ platform, description })[2]).toBe(` --description ${parameter}`); - }); - }); - describe('for missing platform', () => { it('commandPrompt uses the default', () => { const expected = commandPrompt({ platform: DEFAULT_PLATFORM }); @@ -78,15 +56,13 @@ describe('registration utils', () => { it('registerCommand uses the default', () => { const expected = registerCommand({ platform: DEFAULT_PLATFORM, - registrationToken: REGISTRATION_TOKEN, + token: mockAuthenticationToken, }); - expect(registerCommand({ platform: null, registrationToken: REGISTRATION_TOKEN })).toEqual( + expect(registerCommand({ platform: null, token: mockAuthenticationToken })).toEqual(expected); + expect(registerCommand({ platform: undefined, token: mockAuthenticationToken })).toEqual( expected, ); - expect( - registerCommand({ platform: undefined, registrationToken: REGISTRATION_TOKEN }), - ).toEqual(expected); }); it('runCommand uses the default', () => { diff --git a/spec/frontend/ci/runner/components/runner_create_form_spec.js b/spec/frontend/ci/runner/components/runner_create_form_spec.js index 1123a026a4d..a13a19db067 100644 --- a/spec/frontend/ci/runner/components/runner_create_form_spec.js +++ b/spec/frontend/ci/runner/components/runner_create_form_spec.js @@ -6,7 +6,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue'; -import { DEFAULT_ACCESS_LEVEL } from '~/ci/runner/constants'; +import { DEFAULT_ACCESS_LEVEL, INSTANCE_TYPE, GROUP_TYPE } from '~/ci/runner/constants'; import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql'; import { captureException } from '~/ci/runner/sentry_utils'; import { runnerCreateResult } from '../mock_data'; @@ -35,30 +35,42 @@ describe('RunnerCreateForm', () => { const findRunnerFormFields = () => wrapper.findComponent(RunnerFormFields); const findSubmitBtn = () => wrapper.find('[type="submit"]'); - const createComponent = () => { + const createComponent = ({ props } = {}) => { wrapper = shallowMountExtended(RunnerCreateForm, { + propsData: { + runnerType: INSTANCE_TYPE, + ...props, + }, apolloProvider: createMockApollo([[runnerCreateMutation, runnerCreateHandler]]), }); }; beforeEach(() => { runnerCreateHandler = jest.fn().mockResolvedValue(runnerCreateResult); - - createComponent(); }); it('shows default runner values', () => { + createComponent(); + expect(findRunnerFormFields().props('value')).toEqual(defaultRunnerModel); }); it('shows a submit button', () => { + createComponent(); + expect(findSubmitBtn().exists()).toBe(true); }); - describe('when user submits', () => { + describe.each` + typeName | props | scopeData + ${'an instance runner'} | ${{ runnerType: INSTANCE_TYPE }} | ${{ runnerType: INSTANCE_TYPE }} + ${'a group runner'} | ${{ runnerType: GROUP_TYPE, groupId: 'gid://gitlab/Group/72' }} | ${{ runnerType: GROUP_TYPE, groupId: 'gid://gitlab/Group/72' }} + `('when user submits $typeName', ({ props, scopeData }) => { let preventDefault; beforeEach(() => { + createComponent({ props }); + preventDefault = jest.fn(); findRunnerFormFields().vm.$emit('input', { @@ -82,10 +94,11 @@ describe('RunnerCreateForm', () => { expect(findSubmitBtn().props('loading')).toBe(true); }); - it('saves runner', async () => { + it('saves runner', () => { expect(runnerCreateHandler).toHaveBeenCalledWith({ input: { ...defaultRunnerModel, + ...scopeData, description: 'My runner', maximumTimeout: 0, tagList: ['tag1', 'tag2'], @@ -100,7 +113,7 @@ describe('RunnerCreateForm', () => { await waitForPromises(); }); - it('emits "saved" result', async () => { + it('emits "saved" result', () => { expect(wrapper.emitted('saved')[0]).toEqual([mockCreatedRunner]); }); @@ -119,7 +132,7 @@ describe('RunnerCreateForm', () => { await waitForPromises(); }); - it('emits "error" result', async () => { + it('emits "error" result', () => { expect(wrapper.emitted('error')[0]).toEqual([error]); }); @@ -154,7 +167,7 @@ describe('RunnerCreateForm', () => { await waitForPromises(); }); - it('emits "error" results', async () => { + it('emits "error" results', () => { expect(wrapper.emitted('error')[0]).toEqual([new Error(`${errorMsg1} ${errorMsg2}`)]); }); diff --git a/spec/frontend/ci/runner/components/runner_delete_button_spec.js b/spec/frontend/ci/runner/components/runner_delete_button_spec.js index f9bea318d84..3123f2894fb 100644 --- a/spec/frontend/ci/runner/components/runner_delete_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_delete_button_spec.js @@ -124,15 +124,15 @@ describe('RunnerDeleteButton', () => { }); describe('Immediately after the delete button is clicked', () => { - beforeEach(async () => { + beforeEach(() => { findModal().vm.$emit('primary'); }); - it('The button has a loading state', async () => { + it('The button has a loading state', () => { expect(findBtn().props('loading')).toBe(true); }); - it('The stale tooltip is removed', async () => { + it('The stale tooltip is removed', () => { expect(getTooltip()).toBe(''); }); }); @@ -255,15 +255,15 @@ describe('RunnerDeleteButton', () => { }); describe('Immediately after the button is clicked', () => { - beforeEach(async () => { + beforeEach(() => { findModal().vm.$emit('primary'); }); - it('The button has a loading state', async () => { + it('The button has a loading state', () => { expect(findBtn().props('loading')).toBe(true); }); - it('The stale tooltip is removed', async () => { + it('The stale tooltip is removed', () => { expect(getTooltip()).toBe(''); }); }); diff --git a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js index ac84c7898bf..7572122a5f3 100644 --- a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js @@ -1,5 +1,6 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { assertProps } from 'helpers/assert_props'; import RunnerFilteredSearchBar from '~/ci/runner/components/runner_filtered_search_bar.vue'; import { statusTokenConfig } from '~/ci/runner/components/search_tokens/status_token_config'; import TagToken from '~/ci/runner/components/search_tokens/tag_token.vue'; @@ -43,12 +44,12 @@ describe('RunnerList', () => { expect(inputs[inputs.length - 1][0]).toEqual(value); }; + const defaultProps = { namespace: 'runners', tokens: [], value: mockSearch }; + const createComponent = ({ props = {}, options = {} } = {}) => { wrapper = shallowMountExtended(RunnerFilteredSearchBar, { propsData: { - namespace: 'runners', - tokens: [], - value: mockSearch, + ...defaultProps, ...props, }, stubs: { @@ -109,11 +110,14 @@ describe('RunnerList', () => { it('fails validation for v-model with the wrong shape', () => { expect(() => { - createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } }); + assertProps(RunnerFilteredSearchBar, { + ...defaultProps, + value: { filters: 'wrong_filters', sort: 'sort' }, + }); }).toThrow('Invalid prop: custom validator check failed'); expect(() => { - createComponent({ props: { value: { sort: 'sort' } } }); + assertProps(RunnerFilteredSearchBar, { ...defaultProps, value: { sort: 'sort' } }); }).toThrow('Invalid prop: custom validator check failed'); }); diff --git a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js index 3e813723b5b..e4ca84853c3 100644 --- a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js @@ -5,6 +5,7 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; import { + mockRegistrationToken, newRunnerPath, emptyStateSvgPath, emptyStateFilteredSvgPath, @@ -12,8 +13,6 @@ import { import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue'; -const mockRegistrationToken = 'REGISTRATION_TOKEN'; - describe('RunnerListEmptyState', () => { let wrapper; diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js index 6f4913dca3e..0f4ec717c3e 100644 --- a/spec/frontend/ci/runner/components/runner_list_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -164,7 +164,7 @@ describe('RunnerList', () => { }); }); - it('Emits a deleted event', async () => { + it('Emits a deleted event', () => { const event = { message: 'Deleted!' }; findRunnerBulkDelete().vm.$emit('deleted', event); diff --git a/spec/frontend/ci/runner/components/runner_pause_button_spec.js b/spec/frontend/ci/runner/components/runner_pause_button_spec.js index 62e6cc902b7..350d029f3fc 100644 --- a/spec/frontend/ci/runner/components/runner_pause_button_spec.js +++ b/spec/frontend/ci/runner/components/runner_pause_button_spec.js @@ -134,7 +134,7 @@ describe('RunnerPauseButton', () => { await clickAndWait(); }); - it(`The mutation to that sets active to ${newActiveValue} is called`, async () => { + it(`The mutation to that sets active to ${newActiveValue} is called`, () => { expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1); expect(runnerToggleActiveHandler).toHaveBeenCalledWith({ input: { diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js index db6fd2c369b..d419b34df1b 100644 --- a/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js +++ b/spec/frontend/ci/runner/components/runner_platforms_radio_group_spec.js @@ -84,7 +84,7 @@ describe('RunnerPlatformsRadioGroup', () => { text | href ${'Docker'} | ${DOCKER_HELP_URL} ${'Kubernetes'} | ${KUBERNETES_HELP_URL} - `('provides link to "$text" docs', async ({ text, href }) => { + `('provides link to "$text" docs', ({ text, href }) => { const radio = findFormRadioByText(text); expect(radio.findComponent(GlLink).attributes()).toEqual({ diff --git a/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js index fb81edd1ae2..340b04637f8 100644 --- a/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js +++ b/spec/frontend/ci/runner/components/runner_platforms_radio_spec.js @@ -41,7 +41,7 @@ describe('RunnerPlatformsRadio', () => { expect(findFormRadio().attributes('value')).toBe(mockValue); }); - it('emits when item is clicked', async () => { + it('emits when item is clicked', () => { findDiv().trigger('click'); expect(wrapper.emitted('input')).toEqual([[mockValue]]); @@ -94,7 +94,7 @@ describe('RunnerPlatformsRadio', () => { expect(wrapper.classes('gl-cursor-pointer')).toBe(false); }); - it('does not emit when item is clicked', async () => { + it('does not emit when item is clicked', () => { findDiv().trigger('click'); expect(wrapper.emitted('input')).toBe(undefined); diff --git a/spec/frontend/ci/runner/components/runner_projects_spec.js b/spec/frontend/ci/runner/components/runner_projects_spec.js index ccc1bc18675..afdc54d8ebc 100644 --- a/spec/frontend/ci/runner/components/runner_projects_spec.js +++ b/spec/frontend/ci/runner/components/runner_projects_spec.js @@ -89,7 +89,7 @@ describe('RunnerProjects', () => { await waitForPromises(); }); - it('Shows a heading', async () => { + it('Shows a heading', () => { const expected = sprintf(I18N_ASSIGNED_PROJECTS, { projectCount: mockProjects.length }); expect(findHeading().text()).toBe(expected); diff --git a/spec/frontend/ci/runner/components/runner_type_badge_spec.js b/spec/frontend/ci/runner/components/runner_type_badge_spec.js index 7a0fb6f69ea..f7ecd108967 100644 --- a/spec/frontend/ci/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_type_badge_spec.js @@ -2,6 +2,7 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RunnerTypeBadge from '~/ci/runner/components/runner_type_badge.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { assertProps } from 'helpers/assert_props'; import { INSTANCE_TYPE, GROUP_TYPE, @@ -50,7 +51,7 @@ describe('RunnerTypeBadge', () => { it('validation fails for an incorrect type', () => { expect(() => { - createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); + assertProps(RunnerTypeBadge, { type: 'AN_UNKNOWN_VALUE' }); }).toThrow(); }); diff --git a/spec/frontend/ci/runner/components/runner_type_tabs_spec.js b/spec/frontend/ci/runner/components/runner_type_tabs_spec.js index 6e15c84ad7e..71dcc5b4226 100644 --- a/spec/frontend/ci/runner/components/runner_type_tabs_spec.js +++ b/spec/frontend/ci/runner/components/runner_type_tabs_spec.js @@ -8,6 +8,7 @@ import { PROJECT_TYPE, DEFAULT_MEMBERSHIP, DEFAULT_SORT, + STATUS_ONLINE, } from '~/ci/runner/constants'; const mockSearch = { @@ -111,7 +112,7 @@ describe('RunnerTypeTabs', () => { it('Renders a count next to each tab', () => { const mockVariables = { paused: true, - status: 'ONLINE', + status: STATUS_ONLINE, }; createComponent({ diff --git a/spec/frontend/ci/runner/components/stat/runner_count_spec.js b/spec/frontend/ci/runner/components/stat/runner_count_spec.js index 42d8c9a1080..df774ba3e57 100644 --- a/spec/frontend/ci/runner/components/stat/runner_count_spec.js +++ b/spec/frontend/ci/runner/components/stat/runner_count_spec.js @@ -2,7 +2,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; import RunnerCount from '~/ci/runner/components/stat/runner_count.vue'; -import { INSTANCE_TYPE, GROUP_TYPE } from '~/ci/runner/constants'; +import { INSTANCE_TYPE, GROUP_TYPE, STATUS_ONLINE } from '~/ci/runner/constants'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { captureException } from '~/ci/runner/sentry_utils'; @@ -47,7 +47,7 @@ describe('RunnerCount', () => { }); describe('in admin scope', () => { - const mockVariables = { status: 'ONLINE' }; + const mockVariables = { status: STATUS_ONLINE }; beforeEach(async () => { await createComponent({ props: { scope: INSTANCE_TYPE } }); @@ -67,7 +67,7 @@ describe('RunnerCount', () => { expect(wrapper.html()).toBe(`<strong>${runnersCountData.data.runners.count}</strong>`); }); - it('does not fetch from the group query', async () => { + it('does not fetch from the group query', () => { expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled(); }); @@ -89,7 +89,7 @@ describe('RunnerCount', () => { await createComponent({ props: { scope: INSTANCE_TYPE, skip: true } }); }); - it('does not fetch data', async () => { + it('does not fetch data', () => { expect(mockRunnersCountHandler).not.toHaveBeenCalled(); expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled(); @@ -106,7 +106,7 @@ describe('RunnerCount', () => { await createComponent({ props: { scope: INSTANCE_TYPE } }); }); - it('data is not shown and error is reported', async () => { + it('data is not shown and error is reported', () => { expect(wrapper.html()).toBe('<strong></strong>'); expect(captureException).toHaveBeenCalledWith({ @@ -121,7 +121,7 @@ describe('RunnerCount', () => { await createComponent({ props: { scope: GROUP_TYPE } }); }); - it('fetches data from the group query', async () => { + it('fetches data from the group query', () => { expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(1); expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({}); @@ -141,7 +141,7 @@ describe('RunnerCount', () => { wrapper.vm.refetch(); }); - it('data is not shown and error is reported', async () => { + it('data is not shown and error is reported', () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(2); }); }); diff --git a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js new file mode 100644 index 00000000000..027196ab004 --- /dev/null +++ b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js @@ -0,0 +1,132 @@ +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; + +import GroupRunnerRunnerApp from '~/ci/runner/group_new_runner/group_new_runner_app.vue'; +import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; +import { + PARAM_KEY_PLATFORM, + GROUP_TYPE, + DEFAULT_PLATFORM, + WINDOWS_PLATFORM, +} from '~/ci/runner/constants'; +import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { runnerCreateResult, mockRegistrationToken } from '../mock_data'; + +const mockGroupId = 'gid://gitlab/Group/72'; + +jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + redirectTo: jest.fn(), +})); + +const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner; + +describe('GroupRunnerRunnerApp', () => { + let wrapper; + + const findLegacyInstructionsLink = () => wrapper.findByTestId('legacy-instructions-link'); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + const findRunnerPlatformsRadioGroup = () => wrapper.findComponent(RunnerPlatformsRadioGroup); + const findRunnerCreateForm = () => wrapper.findComponent(RunnerCreateForm); + + const createComponent = () => { + wrapper = shallowMountExtended(GroupRunnerRunnerApp, { + propsData: { + groupId: mockGroupId, + legacyRegistrationToken: mockRegistrationToken, + }, + directives: { + GlModal: createMockDirective('gl-modal'), + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('Shows legacy modal', () => { + it('passes legacy registration to modal', () => { + expect(findRunnerInstructionsModal().props('registrationToken')).toEqual( + mockRegistrationToken, + ); + }); + + it('opens a modal with the legacy instructions', () => { + const modalId = getBinding(findLegacyInstructionsLink().element, 'gl-modal').value; + + expect(findRunnerInstructionsModal().props('modalId')).toBe(modalId); + }); + }); + + describe('Platform', () => { + it('shows the platforms radio group', () => { + expect(findRunnerPlatformsRadioGroup().props('value')).toBe(DEFAULT_PLATFORM); + }); + }); + + describe('Runner form', () => { + it('shows the runner create form for an instance runner', () => { + expect(findRunnerCreateForm().props()).toEqual({ + runnerType: GROUP_TYPE, + groupId: mockGroupId, + }); + }); + + describe('When a runner is saved', () => { + beforeEach(() => { + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('pushes an alert to be shown after redirection', () => { + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: s__('Runners|Runner created.'), + variant: VARIANT_SUCCESS, + }); + }); + + it('redirects to the registration page', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`; + + expect(redirectTo).toHaveBeenCalledWith(url); + }); + }); + + describe('When another platform is selected and a runner is saved', () => { + beforeEach(() => { + findRunnerPlatformsRadioGroup().vm.$emit('input', WINDOWS_PLATFORM); + findRunnerCreateForm().vm.$emit('saved', mockCreatedRunner); + }); + + it('redirects to the registration page with the platform', () => { + const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`; + + expect(redirectTo).toHaveBeenCalledWith(url); + }); + }); + + describe('When runner fails to save', () => { + const ERROR_MSG = 'Cannot save!'; + + beforeEach(() => { + findRunnerCreateForm().vm.$emit('error', new Error(ERROR_MSG)); + }); + + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: ERROR_MSG }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js b/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js new file mode 100644 index 00000000000..2f0807c700c --- /dev/null +++ b/spec/frontend/ci/runner/group_register_runner_app/group_register_runner_app_spec.js @@ -0,0 +1,120 @@ +import { nextTick } from 'vue'; +import { GlButton } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; + +import { updateHistory } from '~/lib/utils/url_utility'; +import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM, WINDOWS_PLATFORM } from '~/ci/runner/constants'; +import GroupRegisterRunnerApp from '~/ci/runner/group_register_runner/group_register_runner_app.vue'; +import RegistrationInstructions from '~/ci/runner/components/registration/registration_instructions.vue'; +import PlatformsDrawer from '~/ci/runner/components/registration/platforms_drawer.vue'; +import { runnerForRegistration } from '../mock_data'; + +const mockRunnerId = runnerForRegistration.data.runner.id; +const mockRunnersPath = '/groups/group1/-/runners'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); + +describe('GroupRegisterRunnerApp', () => { + let wrapper; + + const findRegistrationInstructions = () => wrapper.findComponent(RegistrationInstructions); + const findPlatformsDrawer = () => wrapper.findComponent(PlatformsDrawer); + const findBtn = () => wrapper.findComponent(GlButton); + + const createComponent = () => { + wrapper = shallowMountExtended(GroupRegisterRunnerApp, { + propsData: { + runnerId: mockRunnerId, + runnersPath: mockRunnersPath, + }, + }); + }; + + describe('When showing runner details', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when runner token is available', () => { + it('shows registration instructions', () => { + expect(findRegistrationInstructions().props()).toEqual({ + platform: DEFAULT_PLATFORM, + runnerId: mockRunnerId, + }); + }); + + it('configures platform drawer', () => { + expect(findPlatformsDrawer().props()).toEqual({ + open: false, + platform: DEFAULT_PLATFORM, + }); + }); + + it('shows runner list button', () => { + expect(findBtn().attributes('href')).toBe(mockRunnersPath); + expect(findBtn().props('variant')).toBe('confirm'); + }); + }); + }); + + describe('When another platform has been selected', () => { + beforeEach(() => { + setWindowLocation(`?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`); + + createComponent(); + }); + + it('shows registration instructions for the platform', () => { + expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM); + }); + }); + + describe('When opening install instructions', () => { + beforeEach(() => { + createComponent(); + + findRegistrationInstructions().vm.$emit('toggleDrawer'); + }); + + it('opens platform drawer', () => { + expect(findPlatformsDrawer().props('open')).toBe(true); + }); + + it('closes platform drawer', async () => { + findRegistrationInstructions().vm.$emit('toggleDrawer'); + await nextTick(); + + expect(findPlatformsDrawer().props('open')).toBe(false); + }); + + it('closes platform drawer from drawer', async () => { + findPlatformsDrawer().vm.$emit('close'); + await nextTick(); + + expect(findPlatformsDrawer().props('open')).toBe(false); + }); + + describe('when selecting a platform', () => { + beforeEach(() => { + findPlatformsDrawer().vm.$emit('selectPlatform', WINDOWS_PLATFORM); + }); + + it('updates the url', () => { + expect(updateHistory).toHaveBeenCalledTimes(1); + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`, + }); + }); + + it('updates the registration instructions', () => { + expect(findRegistrationInstructions().props('platform')).toBe(WINDOWS_PLATFORM); + }); + }); + }); +}); diff --git a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js index fadc6e5ebc5..60f51704c0e 100644 --- a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js @@ -83,7 +83,7 @@ describe('GroupRunnerShowApp', () => { await createComponent({ mountFn: mountExtended }); }); - it('expect GraphQL ID to be requested', async () => { + it('expect GraphQL ID to be requested', () => { expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); }); @@ -91,7 +91,7 @@ describe('GroupRunnerShowApp', () => { expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`); }); - it('displays the runner edit and pause buttons', async () => { + it('displays the runner edit and pause buttons', () => { expect(findRunnerEditButton().attributes('href')).toBe(mockEditGroupRunnerPath); expect(findRunnerPauseButton().exists()).toBe(true); expect(findRunnerDeleteButton().exists()).toBe(true); diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js index 00c7262e38b..6824242cba9 100644 --- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -58,6 +58,8 @@ import { groupRunnersCountData, onlineContactTimeoutSecs, staleTimeoutSecs, + mockRegistrationToken, + newRunnerPath, emptyPageInfo, emptyStateSvgPath, emptyStateFilteredSvgPath, @@ -67,7 +69,6 @@ Vue.use(VueApollo); Vue.use(GlToast); const mockGroupFullPath = 'group1'; -const mockRegistrationToken = 'AABBCC'; const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges; const mockGroupRunnersCount = mockGroupRunnersEdges.length; @@ -87,6 +88,7 @@ describe('GroupRunnersApp', () => { const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); + const findNewRunnerBtn = () => wrapper.findByText(s__('Runners|New group runner')); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); @@ -114,6 +116,7 @@ describe('GroupRunnersApp', () => { propsData: { registrationToken: mockRegistrationToken, groupFullPath: mockGroupFullPath, + newRunnerPath, ...props, }, provide: { @@ -287,7 +290,7 @@ describe('GroupRunnersApp', () => { }); }); - it('When runner is paused or unpaused, some data is refetched', async () => { + it('When runner is paused or unpaused, some data is refetched', () => { expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); findRunnerActionsCell().vm.$emit('toggledPaused'); @@ -299,7 +302,7 @@ describe('GroupRunnersApp', () => { expect(showToast).toHaveBeenCalledTimes(0); }); - it('When runner is deleted, data is refetched and a toast message is shown', async () => { + it('When runner is deleted, data is refetched and a toast message is shown', () => { findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' }); expect(showToast).toHaveBeenCalledTimes(1); @@ -416,7 +419,7 @@ describe('GroupRunnersApp', () => { expect(createAlert).not.toHaveBeenCalled(); }); - it('shows an empty state', async () => { + it('shows an empty state', () => { expect(findRunnerListEmptyState().exists()).toBe(true); }); }); @@ -427,11 +430,11 @@ describe('GroupRunnersApp', () => { await createComponent(); }); - it('error is shown to the user', async () => { + it('error is shown to the user', () => { expect(createAlert).toHaveBeenCalledTimes(1); }); - it('error is reported to sentry', async () => { + it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ error: new Error('Error!'), component: 'GroupRunnersApp', @@ -468,32 +471,69 @@ describe('GroupRunnersApp', () => { }); describe('when user has permission to register group runner', () => { - beforeEach(() => { + it('shows the register group runner button', () => { createComponent({ - propsData: { + props: { registrationToken: mockRegistrationToken, - groupFullPath: mockGroupFullPath, }, }); + expect(findRegistrationDropdown().exists()).toBe(true); }); - it('shows the register group runner button', () => { - expect(findRegistrationDropdown().exists()).toBe(true); + it('when create_runner_workflow_for_namespace is enabled', () => { + createComponent({ + props: { + newRunnerPath, + }, + provide: { + glFeatures: { + createRunnerWorkflowForNamespace: true, + }, + }, + }); + + expect(findNewRunnerBtn().attributes('href')).toBe(newRunnerPath); + }); + + it('when create_runner_workflow_for_namespace is disabled', () => { + createComponent({ + props: { + newRunnerPath, + }, + provide: { + glFeatures: { + createRunnerWorkflowForNamespace: false, + }, + }, + }); + + expect(findNewRunnerBtn().exists()).toBe(false); }); }); describe('when user has no permission to register group runner', () => { - beforeEach(() => { + it('does not show the register group runner button', () => { createComponent({ - propsData: { + props: { registrationToken: null, - groupFullPath: mockGroupFullPath, }, }); + expect(findRegistrationDropdown().exists()).toBe(false); }); - it('does not show the register group runner button', () => { - expect(findRegistrationDropdown().exists()).toBe(false); + it('when create_runner_workflow_for_namespace is enabled', () => { + createComponent({ + props: { + newRunnerPath: null, + }, + provide: { + glFeatures: { + createRunnerWorkflowForNamespace: true, + }, + }, + }); + + expect(findNewRunnerBtn().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js index 092a419c1fe..196005c9882 100644 --- a/spec/frontend/ci/runner/mock_data.js +++ b/spec/frontend/ci/runner/mock_data.js @@ -1,5 +1,14 @@ // Fixtures generated by: spec/frontend/fixtures/runner.rb +// List queries +import allRunnersData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.json'; +import allRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.paginated.json'; +import allRunnersWithCreatorData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.with_creator.json'; +import runnersCountData from 'test_fixtures/graphql/ci/runner/list/all_runners_count.query.graphql.json'; +import groupRunnersData from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.json'; +import groupRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.paginated.json'; +import groupRunnersCountData from 'test_fixtures/graphql/ci/runner/list/group_runners_count.query.graphql.json'; + // Register runner queries import runnerForRegistration from 'test_fixtures/graphql/ci/runner/register/runner_for_registration.query.graphql.json'; @@ -14,16 +23,15 @@ import runnerJobsData from 'test_fixtures/graphql/ci/runner/show/runner_jobs.que import runnerFormData from 'test_fixtures/graphql/ci/runner/edit/runner_form.query.graphql.json'; // New runner queries - -// List queries -import allRunnersData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.json'; -import allRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.paginated.json'; -import runnersCountData from 'test_fixtures/graphql/ci/runner/list/all_runners_count.query.graphql.json'; -import groupRunnersData from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.json'; -import groupRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/group_runners.query.graphql.paginated.json'; -import groupRunnersCountData from 'test_fixtures/graphql/ci/runner/list/group_runners_count.query.graphql.json'; - -import { DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE } from '~/ci/runner/constants'; +import { + DEFAULT_MEMBERSHIP, + INSTANCE_TYPE, + CREATED_DESC, + CREATED_ASC, + STATUS_ONLINE, + STATUS_STALE, + RUNNER_PAGE_SIZE, +} from '~/ci/runner/constants'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; const emptyPageInfo = { @@ -46,29 +54,29 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, isDefault: true, }, { name: 'a single status', - urlQuery: '?status[]=ACTIVE', + urlQuery: '?status[]=ONLINE', search: { runnerType: null, membership: DEFAULT_MEMBERSHIP, - filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, - status: 'ACTIVE', - sort: 'CREATED_DESC', + status: STATUS_ONLINE, + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -85,12 +93,12 @@ export const mockSearchExamples = [ }, ], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, search: 'something', - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -111,12 +119,12 @@ export const mockSearchExamples = [ }, ], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, search: 'something else', - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -124,54 +132,54 @@ export const mockSearchExamples = [ name: 'single instance type', urlQuery: '?runner_type[]=INSTANCE_TYPE', search: { - runnerType: 'INSTANCE_TYPE', + runnerType: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, filters: [], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { - type: 'INSTANCE_TYPE', + type: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, { name: 'multiple runner status', - urlQuery: '?status[]=ACTIVE&status[]=PAUSED', + urlQuery: '?status[]=ONLINE&status[]=STALE', search: { runnerType: null, membership: DEFAULT_MEMBERSHIP, filters: [ - { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, - { type: 'status', value: { data: 'PAUSED', operator: '=' } }, + { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }, + { type: 'status', value: { data: STATUS_STALE, operator: '=' } }, ], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { - status: 'ACTIVE', + status: STATUS_ONLINE, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, { name: 'multiple status, a single instance type and a non default sort', - urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', + urlQuery: '?status[]=ONLINE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', search: { - runnerType: 'INSTANCE_TYPE', + runnerType: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, - filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], + filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }], pagination: {}, - sort: 'CREATED_ASC', + sort: CREATED_ASC, }, graphqlVariables: { - status: 'ACTIVE', - type: 'INSTANCE_TYPE', + status: STATUS_ONLINE, + type: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_ASC', + sort: CREATED_ASC, first: RUNNER_PAGE_SIZE, }, }, @@ -183,13 +191,13 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1'], - first: 20, - sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + sort: CREATED_DESC, }, }, { @@ -203,13 +211,13 @@ export const mockSearchExamples = [ { type: 'tag', value: { data: 'tag-2', operator: '=' } }, ], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1', 'tag-2'], - first: 20, - sort: 'CREATED_DESC', + first: RUNNER_PAGE_SIZE, + sort: CREATED_DESC, }, }, { @@ -220,11 +228,11 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [], pagination: { after: 'AFTER_CURSOR' }, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE, }, @@ -237,11 +245,11 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [], pagination: { before: 'BEFORE_CURSOR' }, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE, }, @@ -249,24 +257,24 @@ export const mockSearchExamples = [ { name: 'the next page filtered by a status, an instance type, tags and a non default sort', urlQuery: - '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR', + '?status[]=ONLINE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&after=AFTER_CURSOR', search: { - runnerType: 'INSTANCE_TYPE', + runnerType: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, filters: [ - { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }, { type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } }, ], pagination: { after: 'AFTER_CURSOR' }, - sort: 'CREATED_ASC', + sort: CREATED_ASC, }, graphqlVariables: { - status: 'ACTIVE', - type: 'INSTANCE_TYPE', + status: STATUS_ONLINE, + type: INSTANCE_TYPE, membership: DEFAULT_MEMBERSHIP, tagList: ['tag-1', 'tag-2'], - sort: 'CREATED_ASC', + sort: CREATED_ASC, after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE, }, @@ -279,12 +287,12 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { paused: true, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -296,12 +304,12 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }], pagination: {}, - sort: 'CREATED_DESC', + sort: CREATED_DESC, }, graphqlVariables: { paused: false, membership: DEFAULT_MEMBERSHIP, - sort: 'CREATED_DESC', + sort: CREATED_DESC, first: RUNNER_PAGE_SIZE, }, }, @@ -310,12 +318,16 @@ export const mockSearchExamples = [ export const onlineContactTimeoutSecs = 2 * 60 * 60; export const staleTimeoutSecs = 7889238; // Ruby's `3.months` +export const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; +export const mockAuthenticationToken = 'MOCK_AUTHENTICATION_TOKEN'; + export const newRunnerPath = '/runners/new'; export const emptyStateSvgPath = 'emptyStateSvgPath.svg'; export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg'; export { allRunnersData, + allRunnersWithCreatorData, allRunnersDataPaginated, runnersCountData, groupRunnersData, diff --git a/spec/frontend/ci/runner/runner_search_utils_spec.js b/spec/frontend/ci/runner/runner_search_utils_spec.js index f64b89d47fd..9a4a6139198 100644 --- a/spec/frontend/ci/runner/runner_search_utils_spec.js +++ b/spec/frontend/ci/runner/runner_search_utils_spec.js @@ -7,6 +7,7 @@ import { isSearchFiltered, } from 'ee_else_ce/ci/runner/runner_search_utils'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; +import { DEFAULT_SORT } from '~/ci/runner/constants'; import { mockSearchExamples } from './mock_data'; describe('search_params.js', () => { @@ -68,7 +69,7 @@ describe('search_params.js', () => { 'http://test.host/?paused[]=true', 'http://test.host/?search=my_text', ])('When a filter is removed, it is removed from the URL', (initialUrl) => { - const search = { filters: [], sort: 'CREATED_DESC' }; + const search = { filters: [], sort: DEFAULT_SORT }; const expectedUrl = `http://test.host/`; expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); @@ -76,7 +77,7 @@ describe('search_params.js', () => { it('When unrelated search parameter is present, it does not get removed', () => { const initialUrl = `http://test.host/?unrelated=UNRELATED&status[]=ACTIVE`; - const search = { filters: [], sort: 'CREATED_DESC' }; + const search = { filters: [], sort: DEFAULT_SORT }; const expectedUrl = `http://test.host/?unrelated=UNRELATED`; expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); diff --git a/spec/frontend/ci/runner/sentry_utils_spec.js b/spec/frontend/ci/runner/sentry_utils_spec.js index f7b689272ce..2f17cc43ac5 100644 --- a/spec/frontend/ci/runner/sentry_utils_spec.js +++ b/spec/frontend/ci/runner/sentry_utils_spec.js @@ -6,7 +6,7 @@ jest.mock('@sentry/browser'); describe('~/ci/runner/sentry_utils', () => { let mockSetTag; - beforeEach(async () => { + beforeEach(() => { mockSetTag = jest.fn(); Sentry.withScope.mockImplementation((fn) => { diff --git a/spec/frontend/clusters/agents/components/create_token_modal_spec.js b/spec/frontend/clusters/agents/components/create_token_modal_spec.js index ff698952c6b..42e6a70ee26 100644 --- a/spec/frontend/clusters/agents/components/create_token_modal_spec.js +++ b/spec/frontend/clusters/agents/components/create_token_modal_spec.js @@ -213,7 +213,7 @@ describe('CreateTokenModal', () => { await mockCreatedResponse(createAgentTokenErrorResponse); }); - it('displays the error message', async () => { + it('displays the error message', () => { expect(findAlert().text()).toBe( createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0], ); diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index a2ec19c5b4a..d657566713f 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlShowCluster from 'test_fixtures/clusters/show_cluster.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import Clusters from '~/clusters/clusters_bundle'; import axios from '~/lib/utils/axios_utils'; @@ -24,7 +25,7 @@ describe('Clusters', () => { }; beforeEach(() => { - loadHTMLFixture('clusters/show_cluster.html'); + setHTMLFixture(htmlShowCluster); mockGetClusterStatusRequest(); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index 9cbb83eedd2..0f68a69458e 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -13,9 +13,9 @@ const defaultConfigHelpUrl = const provideData = { gitlabVersion: '14.8', - kasVersion: '14.8', + kasVersion: '14.8.0', }; -const propsData = { +const defaultProps = { agents: clusterAgents, }; @@ -26,9 +26,6 @@ const DeleteAgentButtonStub = stubComponent(DeleteAgentButton, { const outdatedTitle = I18N_AGENT_TABLE.versionOutdatedTitle; const mismatchTitle = I18N_AGENT_TABLE.versionMismatchTitle; const mismatchOutdatedTitle = I18N_AGENT_TABLE.versionMismatchOutdatedTitle; -const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, { - version: provideData.kasVersion, -}); const mismatchText = I18N_AGENT_TABLE.versionMismatchText; describe('AgentTable', () => { @@ -39,127 +36,150 @@ describe('AgentTable', () => { const findStatusIcon = (at) => findStatusText(at).findComponent(GlIcon); const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at); const findVersionText = (at) => wrapper.findAllByTestId('cluster-agent-version').at(at); + const findAgentId = (at) => wrapper.findAllByTestId('cluster-agent-id').at(at); const findConfiguration = (at) => wrapper.findAllByTestId('cluster-agent-configuration-link').at(at); const findDeleteAgentButton = () => wrapper.findAllComponents(DeleteAgentButton); - beforeEach(() => { + const createWrapper = ({ provide = provideData, propsData = defaultProps } = {}) => { wrapper = mountExtended(AgentTable, { propsData, - provide: provideData, + provide, stubs: { DeleteAgentButton: DeleteAgentButtonStub, }, }); - }); - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - }); + }; describe('agent table', () => { - it.each` - agentName | link | lineNumber - ${'agent-1'} | ${'/agent-1'} | ${0} - ${'agent-2'} | ${'/agent-2'} | ${1} - `('displays agent link for $agentName', ({ agentName, link, lineNumber }) => { - expect(findAgentLink(lineNumber).text()).toBe(agentName); - expect(findAgentLink(lineNumber).attributes('href')).toBe(link); + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + it.each` + agentName | link | lineNumber + ${'agent-1'} | ${'/agent-1'} | ${0} + ${'agent-2'} | ${'/agent-2'} | ${1} + `('displays agent link for $agentName', ({ agentName, link, lineNumber }) => { + expect(findAgentLink(lineNumber).text()).toBe(agentName); + expect(findAgentLink(lineNumber).attributes('href')).toBe(link); + }); + + it.each` + agentGraphQLId | agentId | lineNumber + ${'gid://gitlab/Clusters::Agent/1'} | ${'1'} | ${0} + ${'gid://gitlab/Clusters::Agent/2'} | ${'2'} | ${1} + `( + 'displays agent id as "$agentId" for "$agentGraphQLId" at line $lineNumber', + ({ agentId, lineNumber }) => { + expect(findAgentId(lineNumber).text()).toBe(agentId); + }, + ); + + it.each` + status | iconName | lineNumber + ${'Never connected'} | ${'status-neutral'} | ${0} + ${'Connected'} | ${'status-success'} | ${1} + ${'Not connected'} | ${'status-alert'} | ${2} + `( + 'displays agent connection status as "$status" at line $lineNumber', + ({ status, iconName, lineNumber }) => { + expect(findStatusText(lineNumber).text()).toBe(status); + expect(findStatusIcon(lineNumber).props('name')).toBe(iconName); + }, + ); + + it.each` + lastContact | lineNumber + ${'Never'} | ${0} + ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1} + ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2} + `( + 'displays agent last contact time as "$lastContact" at line $lineNumber', + ({ lastContact, lineNumber }) => { + expect(findLastContactText(lineNumber).text()).toBe(lastContact); + }, + ); + + it.each` + agentConfig | link | lineNumber + ${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0} + ${'Default configuration'} | ${defaultConfigHelpUrl} | ${1} + `( + 'displays config file path as "$agentPath" at line $lineNumber', + ({ agentConfig, link, lineNumber }) => { + const findLink = findConfiguration(lineNumber).findComponent(GlLink); + + expect(findLink.attributes('href')).toBe(link); + expect(findConfiguration(lineNumber).text()).toBe(agentConfig); + }, + ); + + it('displays actions menu for each agent', () => { + expect(findDeleteAgentButton()).toHaveLength(clusterAgents.length); + }); }); - it.each` - status | iconName | lineNumber - ${'Never connected'} | ${'status-neutral'} | ${0} - ${'Connected'} | ${'status-success'} | ${1} - ${'Not connected'} | ${'status-alert'} | ${2} + describe.each` + agentMockIdx | agentVersion | kasVersion | versionMismatch | versionOutdated | title + ${0} | ${''} | ${'14.8.0'} | ${false} | ${false} | ${''} + ${1} | ${'14.8.0'} | ${'14.8.0'} | ${false} | ${false} | ${''} + ${2} | ${'14.6.0'} | ${'14.8.0'} | ${false} | ${true} | ${outdatedTitle} + ${3} | ${'14.7.0'} | ${'14.8.0'} | ${true} | ${false} | ${mismatchTitle} + ${4} | ${'14.3.0'} | ${'14.8.0'} | ${true} | ${true} | ${mismatchOutdatedTitle} + ${5} | ${'14.6.0'} | ${'14.8.0-rc1'} | ${false} | ${false} | ${''} + ${6} | ${'14.8.0'} | ${'15.0.0'} | ${false} | ${true} | ${outdatedTitle} + ${7} | ${'14.8.0'} | ${'15.0.0-rc1'} | ${false} | ${true} | ${outdatedTitle} + ${8} | ${'14.8.0'} | ${'14.8.10'} | ${false} | ${false} | ${''} `( - 'displays agent connection status as "$status" at line $lineNumber', - ({ status, iconName, lineNumber }) => { - expect(findStatusText(lineNumber).text()).toBe(status); - expect(findStatusIcon(lineNumber).props('name')).toBe(iconName); - }, - ); + 'when agent version is "$agentVersion", KAS version is "$kasVersion" and version mismatch is "$versionMismatch"', + ({ agentMockIdx, agentVersion, kasVersion, versionMismatch, versionOutdated, title }) => { + const currentAgent = clusterAgents[agentMockIdx]; - it.each` - lastContact | lineNumber - ${'Never'} | ${0} - ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1} - ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2} - `( - 'displays agent last contact time as "$lastContact" at line $lineNumber', - ({ lastContact, lineNumber }) => { - expect(findLastContactText(lineNumber).text()).toBe(lastContact); - }, - ); + const findIcon = () => findVersionText(0).findComponent(GlIcon); + const findPopover = () => wrapper.findByTestId(`popover-${currentAgent.name}`); - describe.each` - agent | version | podsNumber | versionMismatch | versionOutdated | title | texts | lineNumber - ${'agent-1'} | ${''} | ${1} | ${false} | ${false} | ${''} | ${''} | ${0} - ${'agent-2'} | ${'14.8'} | ${2} | ${false} | ${false} | ${''} | ${''} | ${1} - ${'agent-3'} | ${'14.5'} | ${1} | ${false} | ${true} | ${outdatedTitle} | ${[outdatedText]} | ${2} - ${'agent-4'} | ${'14.7'} | ${2} | ${true} | ${false} | ${mismatchTitle} | ${[mismatchText]} | ${3} - ${'agent-5'} | ${'14.3'} | ${2} | ${true} | ${true} | ${mismatchOutdatedTitle} | ${[mismatchText, outdatedText]} | ${4} - `( - 'agent version column at line $lineNumber', - ({ - agent, - version, - podsNumber, - versionMismatch, - versionOutdated, - title, - texts, - lineNumber, - }) => { - const findIcon = () => findVersionText(lineNumber).findComponent(GlIcon); - const findPopover = () => wrapper.findByTestId(`popover-${agent}`); const versionWarning = versionMismatch || versionOutdated; + const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, { + version: kasVersion, + }); - it('shows the correct agent version', () => { - expect(findVersionText(lineNumber).text()).toBe(version); + beforeEach(() => { + createWrapper({ + provide: { gitlabVersion: '14.8', kasVersion }, + propsData: { agents: [currentAgent] }, + }); + }); + + it('shows the correct agent version text', () => { + expect(findVersionText(0).text()).toBe(agentVersion); }); if (versionWarning) { - it(`shows a warning icon when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated} and the number of pods is ${podsNumber}`, () => { + it('shows a warning icon', () => { expect(findIcon().props('name')).toBe('warning'); }); - it(`renders correct title for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => { expect(findPopover().props('title')).toBe(title); }); - - it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => { - texts.forEach((text) => { - expect(findPopover().text()).toContain(text); + if (versionMismatch) { + it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch}`, () => { + expect(findPopover().text()).toContain(mismatchText); }); - }); + } + if (versionOutdated) { + it(`renders correct text for the popover when agent versions outdated is ${versionOutdated}`, () => { + expect(findPopover().text()).toContain(outdatedText); + }); + } } else { - it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated} and the number of pods is ${podsNumber}`, () => { + it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => { expect(findIcon().exists()).toBe(false); expect(findPopover().exists()).toBe(false); }); } }, ); - - it.each` - agentConfig | link | lineNumber - ${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0} - ${'Default configuration'} | ${defaultConfigHelpUrl} | ${1} - `( - 'displays config file path as "$agentPath" at line $lineNumber', - ({ agentConfig, link, lineNumber }) => { - const findLink = findConfiguration(lineNumber).findComponent(GlLink); - - expect(findLink.attributes('href')).toBe(link); - expect(findConfiguration(lineNumber).text()).toBe(agentConfig); - }, - ); - - it('displays actions menu for each agent', () => { - expect(findDeleteAgentButton()).toHaveLength(5); - }); }); }); diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js index 3156eaaecfc..f9009696c7b 100644 --- a/spec/frontend/clusters_list/components/install_agent_modal_spec.js +++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js @@ -256,7 +256,7 @@ describe('InstallAgentModal', () => { return mockSelectedAgentResponse(); }); - it('displays the error message', async () => { + it('displays the error message', () => { expect(findAlert().text()).toBe( createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0], ); diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js index 3d18b22d727..af1fb496118 100644 --- a/spec/frontend/clusters_list/components/mock_data.js +++ b/spec/frontend/clusters_list/components/mock_data.js @@ -19,7 +19,7 @@ export const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIV export const clusterAgents = [ { name: 'agent-1', - id: 'agent-1-id', + id: 'gid://gitlab/Clusters::Agent/1', configFolder: { webPath: '/agent/full/path', }, @@ -30,17 +30,17 @@ export const clusterAgents = [ }, { name: 'agent-2', - id: 'agent-2-id', + id: 'gid://gitlab/Clusters::Agent/2', webPath: '/agent-2', status: 'active', lastContact: connectedTimeNow.getTime(), connections: { nodes: [ { - metadata: { version: 'v14.8' }, + metadata: { version: 'v14.8.0' }, }, { - metadata: { version: 'v14.8' }, + metadata: { version: 'v14.8.0' }, }, ], }, @@ -54,14 +54,14 @@ export const clusterAgents = [ }, { name: 'agent-3', - id: 'agent-3-id', + id: 'gid://gitlab/Clusters::Agent/3', webPath: '/agent-3', status: 'inactive', lastContact: connectedTimeInactive.getTime(), connections: { nodes: [ { - metadata: { version: 'v14.5' }, + metadata: { version: 'v14.6.0' }, }, ], }, @@ -75,17 +75,17 @@ export const clusterAgents = [ }, { name: 'agent-4', - id: 'agent-4-id', + id: 'gid://gitlab/Clusters::Agent/4', webPath: '/agent-4', status: 'inactive', lastContact: connectedTimeInactive.getTime(), connections: { nodes: [ { - metadata: { version: 'v14.7' }, + metadata: { version: 'v14.7.0' }, }, { - metadata: { version: 'v14.8' }, + metadata: { version: 'v14.8.0' }, }, ], }, @@ -99,17 +99,101 @@ export const clusterAgents = [ }, { name: 'agent-5', - id: 'agent-5-id', + id: 'gid://gitlab/Clusters::Agent/5', webPath: '/agent-5', status: 'inactive', lastContact: connectedTimeInactive.getTime(), connections: { nodes: [ { - metadata: { version: 'v14.5' }, + metadata: { version: 'v14.5.0' }, }, { - metadata: { version: 'v14.3' }, + metadata: { version: 'v14.3.0' }, + }, + ], + }, + tokens: { + nodes: [ + { + lastUsedAt: connectedTimeInactive, + }, + ], + }, + }, + { + name: 'agent-6', + id: 'gid://gitlab/Clusters::Agent/6', + webPath: '/agent-6', + status: 'inactive', + lastContact: connectedTimeInactive.getTime(), + connections: { + nodes: [ + { + metadata: { version: 'v14.6.0' }, + }, + ], + }, + tokens: { + nodes: [ + { + lastUsedAt: connectedTimeInactive, + }, + ], + }, + }, + { + name: 'agent-7', + id: 'gid://gitlab/Clusters::Agent/7', + webPath: '/agent-7', + status: 'inactive', + lastContact: connectedTimeInactive.getTime(), + connections: { + nodes: [ + { + metadata: { version: 'v14.8.0' }, + }, + ], + }, + tokens: { + nodes: [ + { + lastUsedAt: connectedTimeInactive, + }, + ], + }, + }, + { + name: 'agent-8', + id: 'gid://gitlab/Clusters::Agent/8', + webPath: '/agent-8', + status: 'inactive', + lastContact: connectedTimeInactive.getTime(), + connections: { + nodes: [ + { + metadata: { version: 'v14.8.0' }, + }, + ], + }, + tokens: { + nodes: [ + { + lastUsedAt: connectedTimeInactive, + }, + ], + }, + }, + { + name: 'agent-9', + id: 'gid://gitlab/Clusters::Agent/9', + webPath: '/agent-9', + status: 'inactive', + lastContact: connectedTimeInactive.getTime(), + connections: { + nodes: [ + { + metadata: { version: 'v14.8.0' }, }, ], }, diff --git a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap new file mode 100644 index 00000000000..c979ee5a1d2 --- /dev/null +++ b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap @@ -0,0 +1,140 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Comment templates list item component renders list item 1`] = ` +<li + class="gl-pt-4 gl-pb-5 gl-border-b" +> + <div + class="gl-display-flex gl-align-items-center" + > + <h6 + class="gl-mr-3 gl-my-0" + data-testid="comment-template-name" + > + test + </h6> + + <div + class="gl-ml-auto" + > + <div + class="gl-new-dropdown gl-disclosure-dropdown" + > + <button + aria-controls="base-dropdown-5" + aria-labelledby="actions-toggle-3" + class="btn btn-default btn-md gl-button btn-default-tertiary gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret" + data-testid="base-dropdown-toggle" + id="actions-toggle-3" + listeners="[object Object]" + type="button" + > + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="ellipsis_v-icon" + role="img" + > + <use + href="#ellipsis_v" + /> + </svg> + + <span + class="gl-button-text" + > + <span + class="gl-new-dropdown-button-text gl-sr-only" + > + + Comment template actions + + </span> + + <!----> + </span> + </button> + + <div + class="gl-new-dropdown-panel" + data-testid="base-dropdown-menu" + id="base-dropdown-5" + > + <div + class="gl-new-dropdown-inner" + > + + <ul + aria-labelledby="actions-toggle-3" + class="gl-new-dropdown-contents" + data-testid="disclosure-content" + id="disclosure-4" + tabindex="-1" + > + <li + class="gl-new-dropdown-item" + data-testid="disclosure-dropdown-item" + tabindex="0" + > + <button + class="gl-new-dropdown-item-content" + data-testid="comment-template-edit-btn" + tabindex="-1" + type="button" + > + <span + class="gl-new-dropdown-item-text-wrapper" + > + + Edit + + </span> + </button> + </li> + <li + class="gl-new-dropdown-item" + data-testid="disclosure-dropdown-item" + tabindex="0" + > + <button + class="gl-new-dropdown-item-content gl-text-red-500!" + data-testid="comment-template-delete-btn" + tabindex="-1" + type="button" + > + <span + class="gl-new-dropdown-item-text-wrapper" + > + + Delete + + </span> + </button> + </li> + </ul> + + </div> + </div> + </div> + + <div + class="gl-tooltip" + > + + Comment template actions + + </div> + </div> + </div> + + <div + class="gl-mt-3 gl-font-monospace" + > + /assign_reviewer + </div> + + <!----> +</li> +`; diff --git a/spec/frontend/saved_replies/components/form_spec.js b/spec/frontend/comment_templates/components/form_spec.js index adeda498e6f..053a5099c37 100644 --- a/spec/frontend/saved_replies/components/form_spec.js +++ b/spec/frontend/comment_templates/components/form_spec.js @@ -2,13 +2,13 @@ import Vue, { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import { GlAlert } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; -import createdSavedReplyResponse from 'test_fixtures/graphql/saved_replies/create_saved_reply.mutation.graphql.json'; -import createdSavedReplyErrorResponse from 'test_fixtures/graphql/saved_replies/create_saved_reply_with_errors.mutation.graphql.json'; +import createdSavedReplyResponse from 'test_fixtures/graphql/comment_templates/create_saved_reply.mutation.graphql.json'; +import createdSavedReplyErrorResponse from 'test_fixtures/graphql/comment_templates/create_saved_reply_with_errors.mutation.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import Form from '~/saved_replies/components/form.vue'; -import createSavedReplyMutation from '~/saved_replies/queries/create_saved_reply.mutation.graphql'; -import updateSavedReplyMutation from '~/saved_replies/queries/update_saved_reply.mutation.graphql'; +import Form from '~/comment_templates/components/form.vue'; +import createSavedReplyMutation from '~/comment_templates/queries/create_saved_reply.mutation.graphql'; +import updateSavedReplyMutation from '~/comment_templates/queries/update_saved_reply.mutation.graphql'; let wrapper; let createSavedReplyResponseSpy; @@ -39,18 +39,19 @@ function createComponent(id = null, response = createdSavedReplyResponse) { }); } -const findSavedReplyNameInput = () => wrapper.find('[data-testid="saved-reply-name-input"]'); +const findSavedReplyNameInput = () => wrapper.find('[data-testid="comment-template-name-input"]'); const findSavedReplyNameFormGroup = () => - wrapper.find('[data-testid="saved-reply-name-form-group"]'); -const findSavedReplyContentInput = () => wrapper.find('[data-testid="saved-reply-content-input"]'); + wrapper.find('[data-testid="comment-template-name-form-group"]'); +const findSavedReplyContentInput = () => + wrapper.find('[data-testid="comment-template-content-input"]'); const findSavedReplyContentFormGroup = () => - wrapper.find('[data-testid="saved-reply-content-form-group"]'); -const findSavedReplyFrom = () => wrapper.find('[data-testid="saved-reply-form"]'); + wrapper.find('[data-testid="comment-template-content-form-group"]'); +const findSavedReplyFrom = () => wrapper.find('[data-testid="comment-template-form"]'); const findAlerts = () => wrapper.findAllComponents(GlAlert); -const findSubmitBtn = () => wrapper.find('[data-testid="saved-reply-form-submit-btn"]'); +const findSubmitBtn = () => wrapper.find('[data-testid="comment-template-form-submit-btn"]'); -describe('Saved replies form component', () => { - describe('create saved reply', () => { +describe('Comment templates form component', () => { + describe('creates comment template', () => { it('calls apollo mutation', async () => { wrapper = createComponent(); diff --git a/spec/frontend/comment_templates/components/list_item_spec.js b/spec/frontend/comment_templates/components/list_item_spec.js new file mode 100644 index 00000000000..925d78da4ad --- /dev/null +++ b/spec/frontend/comment_templates/components/list_item_spec.js @@ -0,0 +1,154 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mount } from '@vue/test-utils'; +import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { createMockDirective } from 'helpers/vue_mock_directive'; +import ListItem from '~/comment_templates/components/list_item.vue'; +import deleteSavedReplyMutation from '~/comment_templates/queries/delete_saved_reply.mutation.graphql'; + +function createMockApolloProvider(requestHandlers = [deleteSavedReplyMutation]) { + Vue.use(VueApollo); + + return createMockApollo([requestHandlers]); +} + +describe('Comment templates list item component', () => { + let wrapper; + let $router; + + function createComponent(propsData = {}, apolloProvider = createMockApolloProvider) { + $router = { + push: jest.fn(), + }; + + return mount(ListItem, { + propsData, + directives: { + GlModal: createMockDirective('gl-modal'), + }, + apolloProvider, + mocks: { + $router, + }, + }); + } + + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findModal = () => wrapper.findComponent(GlModal); + + it('renders list item', () => { + wrapper = createComponent({ template: { name: 'test', content: '/assign_reviewer' } }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('comment template actions dropdown', () => { + beforeEach(() => { + wrapper = createComponent({ template: { name: 'test', content: '/assign_reviewer' } }); + }); + + it('exists', () => { + expect(findDropdown().exists()).toBe(true); + }); + + it('has correct toggle text', () => { + expect(findDropdown().props('toggleText')).toBe(__('Comment template actions')); + }); + + it('has correct amount of dropdown items', () => { + const items = findDropdownItems(); + + expect(items.exists()).toBe(true); + expect(items).toHaveLength(2); + }); + + describe('edit option', () => { + it('exists', () => { + const items = findDropdownItems(); + + const editItem = items.filter((item) => item.text() === __('Edit')); + + expect(editItem.exists()).toBe(true); + }); + + it('shows as first dropdown item', () => { + const items = findDropdownItems(); + + expect(items.at(0).text()).toBe(__('Edit')); + }); + }); + + describe('delete option', () => { + it('exists', () => { + const items = findDropdownItems(); + + const deleteItem = items.filter((item) => item.text() === __('Delete')); + + expect(deleteItem.exists()).toBe(true); + }); + + it('shows as first dropdown item', () => { + const items = findDropdownItems(); + + expect(items.at(1).text()).toBe(__('Delete')); + }); + }); + }); + + describe('Delete modal', () => { + let deleteSavedReplyMutationResponse; + + beforeEach(() => { + deleteSavedReplyMutationResponse = jest + .fn() + .mockResolvedValue({ data: { savedReplyDestroy: { errors: [] } } }); + + const apolloProvider = createMockApolloProvider([ + deleteSavedReplyMutation, + deleteSavedReplyMutationResponse, + ]); + + wrapper = createComponent( + { template: { name: 'test', content: '/assign_reviewer', id: 1 } }, + apolloProvider, + ); + }); + + it('exists', () => { + expect(findModal().exists()).toBe(true); + }); + + it('has correct title', () => { + expect(findModal().props('title')).toBe(__('Delete comment template')); + }); + + it('delete button calls Apollo mutate', async () => { + await findModal().vm.$emit('primary'); + + expect(deleteSavedReplyMutationResponse).toHaveBeenCalledWith({ id: 1 }); + }); + + it('cancel button does not trigger Apollo mutation', async () => { + await findModal().vm.$emit('secondary'); + + expect(deleteSavedReplyMutationResponse).not.toHaveBeenCalled(); + }); + }); + + describe('Dropdown Edit', () => { + beforeEach(() => { + wrapper = createComponent({ template: { name: 'test', content: '/assign_reviewer' } }); + }); + + it('click triggers router push', async () => { + const editComponent = findDropdownItems().at(0); + + await editComponent.find('button').trigger('click'); + + expect($router.push).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/saved_replies/components/list_spec.js b/spec/frontend/comment_templates/components/list_spec.js index bc69cb852e0..8b0daf2fe2f 100644 --- a/spec/frontend/saved_replies/components/list_spec.js +++ b/spec/frontend/comment_templates/components/list_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; -import noSavedRepliesResponse from 'test_fixtures/graphql/saved_replies/saved_replies_empty.query.graphql.json'; -import savedRepliesResponse from 'test_fixtures/graphql/saved_replies/saved_replies.query.graphql.json'; -import List from '~/saved_replies/components/list.vue'; -import ListItem from '~/saved_replies/components/list_item.vue'; +import noSavedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies_empty.query.graphql.json'; +import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies.query.graphql.json'; +import List from '~/comment_templates/components/list.vue'; +import ListItem from '~/comment_templates/components/list_item.vue'; let wrapper; @@ -18,28 +18,28 @@ function createComponent(res = {}) { }); } -describe('Saved replies list component', () => { +describe('Comment templates list component', () => { it('does not render any list items when response is empty', () => { wrapper = createComponent(noSavedRepliesResponse); expect(wrapper.findAllComponents(ListItem).length).toBe(0); }); - it('render saved replies count', () => { + it('render comment templates count', () => { wrapper = createComponent(savedRepliesResponse); - expect(wrapper.find('[data-testid="title"]').text()).toEqual('My saved replies (2)'); + expect(wrapper.find('[data-testid="title"]').text()).toEqual('My comment templates (2)'); }); - it('renders list of saved replies', () => { + it('renders list of comment templates', () => { const savedReplies = savedRepliesResponse.data.currentUser.savedReplies.nodes; wrapper = createComponent(savedRepliesResponse); expect(wrapper.findAllComponents(ListItem).length).toBe(2); - expect(wrapper.findAllComponents(ListItem).at(0).props('reply')).toEqual( + expect(wrapper.findAllComponents(ListItem).at(0).props('template')).toEqual( expect.objectContaining(savedReplies[0]), ); - expect(wrapper.findAllComponents(ListItem).at(1).props('reply')).toEqual( + expect(wrapper.findAllComponents(ListItem).at(1).props('template')).toEqual( expect.objectContaining(savedReplies[1]), ); }); diff --git a/spec/frontend/saved_replies/pages/index_spec.js b/spec/frontend/comment_templates/pages/index_spec.js index 771025d64ec..6dbec3ef4a4 100644 --- a/spec/frontend/saved_replies/pages/index_spec.js +++ b/spec/frontend/comment_templates/pages/index_spec.js @@ -1,12 +1,12 @@ import Vue from 'vue'; import { mount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; -import savedRepliesResponse from 'test_fixtures/graphql/saved_replies/saved_replies.query.graphql.json'; +import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import IndexPage from '~/saved_replies/pages/index.vue'; -import ListItem from '~/saved_replies/components/list_item.vue'; -import savedRepliesQuery from '~/saved_replies/queries/saved_replies.query.graphql'; +import IndexPage from '~/comment_templates/pages/index.vue'; +import ListItem from '~/comment_templates/components/list_item.vue'; +import savedRepliesQuery from '~/comment_templates/queries/saved_replies.query.graphql'; let wrapper; @@ -26,8 +26,8 @@ function createComponent(options = {}) { }); } -describe('Saved replies index page component', () => { - it('renders list of saved replies', async () => { +describe('Comment templates index page component', () => { + it('renders list of comment templates', async () => { const mockApollo = createMockApolloProvider(savedRepliesResponse); const savedReplies = savedRepliesResponse.data.currentUser.savedReplies.nodes; wrapper = createComponent({ mockApollo }); @@ -35,10 +35,10 @@ describe('Saved replies index page component', () => { await waitForPromises(); expect(wrapper.findAllComponents(ListItem).length).toBe(2); - expect(wrapper.findAllComponents(ListItem).at(0).props('reply')).toEqual( + expect(wrapper.findAllComponents(ListItem).at(0).props('template')).toEqual( expect.objectContaining(savedReplies[0]), ); - expect(wrapper.findAllComponents(ListItem).at(1).props('reply')).toEqual( + expect(wrapper.findAllComponents(ListItem).at(1).props('template')).toEqual( expect.objectContaining(savedReplies[1]), ); }); diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js index 64623968aa0..cc251104811 100644 --- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -83,7 +83,7 @@ describe('Commit box pipeline mini graph', () => { await createComponent(); }); - it('should not display loading state after the query is resolved', async () => { + it('should not display loading state after the query is resolved', () => { expect(findLoadingIcon().exists()).toBe(false); expect(findPipelineMiniGraph().exists()).toBe(true); }); diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js index 9c7a41b3506..5df35cc6dda 100644 --- a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js +++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js @@ -70,7 +70,7 @@ describe('Commit box pipeline status', () => { await waitForPromises(); }); - it('should display pipeline status after the query is resolved successfully', async () => { + it('should display pipeline status after the query is resolved successfully', () => { expect(findStatusIcon().exists()).toBe(true); expect(findLoadingIcon().exists()).toBe(false); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap deleted file mode 100644 index 331a0a474a3..00000000000 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ /dev/null @@ -1,33 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`content_editor/components/toolbar_link_button renders dropdown component 1`] = ` -"<div title=\\"Insert link\\" lazy=\\"\\"> - <li role=\\"presentation\\" class=\\"gl-px-3!\\"> - <form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\"> - <div role=\\"group\\" class=\\"input-group\\" placeholder=\\"Link URL\\"> - <!----> - <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\"> - <div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\"> - <!----> - <!----> <span class=\\"gl-button-text\\">Apply</span></button></div> - <!----> - </div> - </form> - </li> - <li role=\\"presentation\\" class=\\"gl-dropdown-divider\\"> - <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\"> - </li> - <li role=\\"presentation\\" class=\\"gl-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\"> - <!----> - <!----> - <!----> - <div class=\\"gl-dropdown-item-text-wrapper\\"> - <p class=\\"gl-dropdown-item-text-primary\\"> - Upload file - </p> - <!----> - </div> - <!----> - </button></li> -</div>" -`; diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js index 085a6d3a28d..2a6ab75227c 100644 --- a/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js @@ -59,7 +59,7 @@ describe('content_editor/components/bubble_menus/code_block_bubble_menu', () => checked: x.props('isChecked'), })); - beforeEach(async () => { + beforeEach(() => { buildEditor(); buildWrapper(); }); @@ -133,7 +133,7 @@ describe('content_editor/components/bubble_menus/code_block_bubble_menu', () => }); describe('preview button', () => { - it('does not appear for a regular code block', async () => { + it('does not appear for a regular code block', () => { tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); expect(wrapper.findByTestId('preview-diagram').exists()).toBe(false); @@ -269,7 +269,7 @@ describe('content_editor/components/bubble_menus/code_block_bubble_menu', () => await emitEditorEvent({ event: 'transaction', tiptapEditor }); }); - it('hides the custom language input form and shows dropdown items', async () => { + it('hides the custom language input form and shows dropdown items', () => { expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); expect(wrapper.findComponent(GlSearchBoxByType).exists()).toBe(true); expect(wrapper.findComponent(GlDropdownForm).exists()).toBe(false); diff --git a/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js index 7bab473529f..c4bc29adb52 100644 --- a/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/formatting_bubble_menu_spec.js @@ -53,7 +53,7 @@ describe('content_editor/components/bubble_menus/formatting_bubble_menu', () => ${'superscript'} | ${{ contentType: 'superscript', iconName: 'superscript', label: 'Superscript', editorCommand: 'toggleSuperscript' }} ${'subscript'} | ${{ contentType: 'subscript', iconName: 'subscript', label: 'Subscript', editorCommand: 'toggleSubscript' }} ${'highlight'} | ${{ contentType: 'highlight', iconName: 'highlight', label: 'Highlight', editorCommand: 'toggleHighlight' }} - ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' } }} + ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'editLink' }} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); diff --git a/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js index eb5a3b61591..2a8a1b00692 100644 --- a/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/link_bubble_menu_spec.js @@ -59,13 +59,13 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => { expect(wrapper.findByTestId('remove-link').exists()).toBe(exist); }; - beforeEach(async () => { + beforeEach(() => { buildEditor(); tiptapEditor .chain() .insertContent( - 'Download <a href="/path/to/project/-/wikis/uploads/my_file.pdf" data-canonical-src="uploads/my_file.pdf" title="Click here to download">PDF File</a>', + 'Download <a href="/path/to/project/-/wikis/uploads/my_file.pdf" data-canonical-src="uploads/my_file.pdf">PDF File</a>', ) .setTextSelection(14) // put cursor in the middle of the link .run(); @@ -84,7 +84,6 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => { expect.objectContaining({ href: '/path/to/project/-/wikis/uploads/my_file.pdf', 'aria-label': 'uploads/my_file.pdf', - title: 'uploads/my_file.pdf', target: '_blank', }), ); @@ -181,52 +180,17 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => { }); }); - describe('for a placeholder link', () => { - beforeEach(async () => { - tiptapEditor - .chain() - .clearContent() - .insertContent('Dummy link') - .selectAll() - .setLink({ href: '' }) - .setTextSelection(4) - .run(); - - await buildWrapperAndDisplayMenu(); - }); - - it('directly opens the edit form for a placeholder link', async () => { - expectLinkButtonsToExist(false); - - expect(wrapper.findComponent(GlForm).exists()).toBe(true); - }); - - it('removes the link on clicking apply (if no change)', async () => { - await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); - - expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>'); - }); - - it('removes the link on clicking cancel', async () => { - await wrapper.findByTestId('cancel-link').vm.$emit('click'); - - expect(tiptapEditor.getHTML()).toBe('<p>Dummy link</p>'); - }); - }); - describe('edit button', () => { let linkHrefInput; - let linkTitleInput; beforeEach(async () => { await buildWrapperAndDisplayMenu(); await wrapper.findByTestId('edit-link').vm.$emit('click'); linkHrefInput = wrapper.findByTestId('link-href'); - linkTitleInput = wrapper.findByTestId('link-title'); }); - it('hides the link and copy/edit/remove link buttons', async () => { + it('hides the link and copy/edit/remove link buttons', () => { expectLinkButtonsToExist(false); }); @@ -234,7 +198,6 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => { expect(wrapper.findComponent(GlForm).exists()).toBe(true); expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf'); - expect(linkTitleInput.element.value).toBe('Click here to download'); }); it('extends selection to select the entire link', () => { @@ -247,26 +210,18 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => { describe('after making changes in the form and clicking apply', () => { beforeEach(async () => { linkHrefInput.setValue('https://google.com'); - linkTitleInput.setValue('Search Google'); contentEditor.resolveUrl.mockResolvedValue('https://google.com'); await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); }); - it('updates prosemirror doc with new link', async () => { - expect(tiptapEditor.getHTML()).toBe( - '<p>Download <a target="_blank" rel="noopener noreferrer nofollow" href="https://google.com" title="Search Google">PDF File</a></p>', - ); - }); - it('updates the link in the bubble menu', () => { const link = wrapper.findComponent(GlLink); expect(link.attributes()).toEqual( expect.objectContaining({ href: 'https://google.com', 'aria-label': 'https://google.com', - title: 'https://google.com', target: '_blank', }), ); @@ -277,7 +232,6 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => { describe('after making changes in the form and clicking cancel', () => { beforeEach(async () => { linkHrefInput.setValue('https://google.com'); - linkTitleInput.setValue('Search Google'); await wrapper.findByTestId('cancel-link').vm.$emit('click'); }); @@ -285,17 +239,6 @@ describe('content_editor/components/bubble_menus/link_bubble_menu', () => { it('hides the form and shows the copy/edit/remove link buttons', () => { expectLinkButtonsToExist(); }); - - it('resets the form with old values of the link from prosemirror', async () => { - // click edit once again to show the form back - await wrapper.findByTestId('edit-link').vm.$emit('click'); - - linkHrefInput = wrapper.findByTestId('link-href'); - linkTitleInput = wrapper.findByTestId('link-title'); - - expect(linkHrefInput.element.value).toBe('uploads/my_file.pdf'); - expect(linkTitleInput.element.value).toBe('Click here to download'); - }); }); }); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js index c918f068c07..e02b36fb8e9 100644 --- a/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/media_bubble_menu_spec.js @@ -100,11 +100,11 @@ describe.each` bubbleMenu = wrapper.findComponent(BubbleMenu); }); - it('renders bubble menu component', async () => { + it('renders bubble menu component', () => { expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base', 'gl-bg-white']); }); - it('shows a clickable link to the image', async () => { + it('shows a clickable link to the image', () => { const link = wrapper.findComponent(GlLink); expect(link.attributes()).toEqual( expect.objectContaining({ @@ -202,7 +202,7 @@ describe.each` mediaAltInput = wrapper.findByTestId('media-alt'); }); - it('hides the link and copy/edit/remove link buttons', async () => { + it('hides the link and copy/edit/remove link buttons', () => { expectLinkButtonsToExist(false); }); @@ -225,7 +225,7 @@ describe.each` await wrapper.findComponent(GlForm).vm.$emit('submit', createFakeEvent()); }); - it(`updates prosemirror doc with new src to the ${mediaType}`, async () => { + it(`updates prosemirror doc with new src to the ${mediaType}`, () => { expect(tiptapEditor.getHTML()).toBe(mediaOutputHTML); }); diff --git a/spec/frontend/content_editor/components/content_editor_alert_spec.js b/spec/frontend/content_editor/components/content_editor_alert_spec.js index e62e2331d25..e6873e2cf96 100644 --- a/spec/frontend/content_editor/components/content_editor_alert_spec.js +++ b/spec/frontend/content_editor/components/content_editor_alert_spec.js @@ -14,7 +14,7 @@ describe('content_editor/components/content_editor_alert', () => { const findErrorAlert = () => wrapper.findComponent(GlAlert); - const createWrapper = async () => { + const createWrapper = () => { tiptapEditor = createTestEditor(); eventHub = eventHubFactory(); diff --git a/spec/frontend/content_editor/components/formatting_toolbar_spec.js b/spec/frontend/content_editor/components/formatting_toolbar_spec.js index 4a7b7cedf19..5d2a9e493e5 100644 --- a/spec/frontend/content_editor/components/formatting_toolbar_spec.js +++ b/spec/frontend/content_editor/components/formatting_toolbar_spec.js @@ -35,7 +35,7 @@ describe('content_editor/components/formatting_toolbar', () => { ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a checklist', editorCommand: 'toggleTaskList' }} - ${'image'} | ${{}} + ${'attachment'} | ${{}} ${'table'} | ${{}} ${'more'} | ${{}} `('given a $testId toolbar control', ({ testId, controlProps }) => { diff --git a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js index e72eb892e74..9d34d9d0e9e 100644 --- a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js +++ b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlAvatarLabeled, GlDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SuggestionsDropdown from '~/content_editor/components/suggestions_dropdown.vue'; @@ -75,6 +75,26 @@ describe('~/content_editor/components/suggestions_dropdown', () => { unicodeVersion: '6.0', }; + it.each` + loading | description + ${false} | ${'does not show a loading indicator'} + ${true} | ${'shows a loading indicator'} + `('$description if loading=$loading', ({ loading }) => { + buildWrapper({ + propsData: { + loading, + char: '@', + nodeType: 'reference', + nodeProps: { + referenceType: 'member', + }, + items: [exampleUser], + }, + }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(loading); + }); + describe('on item select', () => { it.each` nodeType | referenceType | char | reference | insertedText | insertedProps diff --git a/spec/frontend/content_editor/components/toolbar_attachment_button_spec.js b/spec/frontend/content_editor/components/toolbar_attachment_button_spec.js new file mode 100644 index 00000000000..06ea863dbfa --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_attachment_button_spec.js @@ -0,0 +1,57 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarAttachmentButton from '~/content_editor/components/toolbar_attachment_button.vue'; +import Attachment from '~/content_editor/extensions/attachment'; +import Link from '~/content_editor/extensions/link'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; + +describe('content_editor/components/toolbar_attachment_button', () => { + let wrapper; + let editor; + + const buildWrapper = () => { + wrapper = mountExtended(ToolbarAttachmentButton, { + provide: { + tiptapEditor: editor, + }, + }); + }; + + const selectFile = async (file) => { + const input = wrapper.findComponent({ ref: 'fileSelector' }); + + // override the property definition because `input.files` isn't directly modifyable + Object.defineProperty(input.element, 'files', { value: [file], writable: true }); + await input.trigger('change'); + }; + + beforeEach(() => { + editor = createTestEditor({ + extensions: [ + Link, + Attachment.configure({ + renderMarkdown: jest.fn(), + uploadsPath: '/uploads/', + }), + ], + }); + + buildWrapper(); + }); + + afterEach(() => { + editor.destroy(); + }); + + it('uploads the selected attachment when file input changes', async () => { + const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']); + const file = new File(['foo'], 'foo.png', { type: 'image/png' }); + + await selectFile(file); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link', value: 'upload' }]); + }); +}); diff --git a/spec/frontend/content_editor/components/toolbar_image_button_spec.js b/spec/frontend/content_editor/components/toolbar_image_button_spec.js deleted file mode 100644 index 0ec950137fc..00000000000 --- a/spec/frontend/content_editor/components/toolbar_image_button_spec.js +++ /dev/null @@ -1,96 +0,0 @@ -import { GlButton, GlFormInputGroup, GlDropdown } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue'; -import Attachment from '~/content_editor/extensions/attachment'; -import Image from '~/content_editor/extensions/image'; -import { stubComponent } from 'helpers/stub_component'; -import { createTestEditor, mockChainedCommands } from '../test_utils'; - -describe('content_editor/components/toolbar_image_button', () => { - let wrapper; - let editor; - - const buildWrapper = () => { - wrapper = mountExtended(ToolbarImageButton, { - provide: { - tiptapEditor: editor, - }, - stubs: { - GlDropdown: stubComponent(GlDropdown), - }, - }); - }; - - const findImageURLInput = () => - wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); - const findApplyImageButton = () => wrapper.findComponent(GlButton); - const findDropdown = () => wrapper.findComponent(GlDropdown); - - const selectFile = async (file) => { - const input = wrapper.findComponent({ ref: 'fileSelector' }); - - // override the property definition because `input.files` isn't directly modifyable - Object.defineProperty(input.element, 'files', { value: [file], writable: true }); - await input.trigger('change'); - }; - - beforeEach(() => { - editor = createTestEditor({ - extensions: [ - Image, - Attachment.configure({ - renderMarkdown: jest.fn(), - uploadsPath: '/uploads/', - }), - ], - }); - - buildWrapper(); - }); - - afterEach(() => { - editor.destroy(); - }); - - it('sets the image to the value in the URL input when "Insert" button is clicked', async () => { - const commands = mockChainedCommands(editor, ['focus', 'setImage', 'run']); - - await findImageURLInput().setValue('https://example.com/img.jpg'); - await findApplyImageButton().trigger('click'); - - expect(commands.focus).toHaveBeenCalled(); - expect(commands.setImage).toHaveBeenCalledWith({ - alt: 'img', - src: 'https://example.com/img.jpg', - canonicalSrc: 'https://example.com/img.jpg', - }); - expect(commands.run).toHaveBeenCalled(); - - expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'url' }]); - }); - - it('uploads the selected image when file input changes', async () => { - const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']); - const file = new File(['foo'], 'foo.png', { type: 'image/png' }); - - await selectFile(file); - - expect(commands.focus).toHaveBeenCalled(); - expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); - expect(commands.run).toHaveBeenCalled(); - - expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]); - }); - - describe('a11y tests', () => { - it('sets text, title, and text-sr-only properties to the table button dropdown', () => { - buildWrapper(); - - expect(findDropdown().props()).toMatchObject({ - text: 'Insert image', - textSrOnly: true, - }); - expect(findDropdown().attributes('title')).toBe('Insert image'); - }); - }); -}); diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js deleted file mode 100644 index 80090c0278f..00000000000 --- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js +++ /dev/null @@ -1,223 +0,0 @@ -import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; -import eventHubFactory from '~/helpers/event_hub_factory'; -import Link from '~/content_editor/extensions/link'; -import { hasSelection } from '~/content_editor/services/utils'; -import { stubComponent } from 'helpers/stub_component'; -import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; - -jest.mock('~/content_editor/services/utils'); - -describe('content_editor/components/toolbar_link_button', () => { - let wrapper; - let editor; - - const buildWrapper = () => { - wrapper = mountExtended(ToolbarLinkButton, { - provide: { - tiptapEditor: editor, - eventHub: eventHubFactory(), - }, - stubs: { - GlDropdown: stubComponent(GlDropdown), - }, - }); - }; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findLinkURLInput = () => wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); - const findApplyLinkButton = () => wrapper.findComponent(GlButton); - const findRemoveLinkButton = () => wrapper.findByText('Remove link'); - - const selectFile = async (file) => { - const input = wrapper.findComponent({ ref: 'fileSelector' }); - - // override the property definition because `input.files` isn't directly modifyable - Object.defineProperty(input.element, 'files', { value: [file], writable: true }); - await input.trigger('change'); - }; - - beforeEach(() => { - editor = createTestEditor(); - }); - - afterEach(() => { - editor.destroy(); - }); - - it('renders dropdown component', () => { - buildWrapper(); - - expect(findDropdown().html()).toMatchSnapshot(); - }); - - describe('when there is an active link', () => { - beforeEach(async () => { - jest.spyOn(editor, 'isActive').mockReturnValueOnce(true); - buildWrapper(); - - await emitEditorEvent({ event: 'transaction', tiptapEditor: editor }); - }); - - it('sets dropdown as active when link extension is active', () => { - expect(findDropdown().props('toggleClass')).toEqual({ active: true }); - }); - - it('does not display the upload file option', () => { - expect(wrapper.findByText('Upload file').exists()).toBe(false); - }); - - it('displays a remove link dropdown option', () => { - expect(wrapper.findByText('Remove link').exists()).toBe(true); - }); - - it('executes removeLink command when the remove link option is clicked', async () => { - const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'run']); - - await findRemoveLinkButton().trigger('click'); - - expect(commands.unsetLink).toHaveBeenCalled(); - expect(commands.focus).toHaveBeenCalled(); - expect(commands.run).toHaveBeenCalled(); - }); - - it('updates the link with a new link when "Apply" button is clicked', async () => { - const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']); - - await findLinkURLInput().setValue('https://example'); - await findApplyLinkButton().trigger('click'); - - expect(commands.focus).toHaveBeenCalled(); - expect(commands.unsetLink).toHaveBeenCalled(); - expect(commands.setLink).toHaveBeenCalledWith({ - href: 'https://example', - canonicalSrc: 'https://example', - }); - expect(commands.run).toHaveBeenCalled(); - - expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); - }); - - describe('on selection update', () => { - it('updates link input box with canonical-src if present', async () => { - jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({ - canonicalSrc: 'uploads/my-file.zip', - href: '/username/my-project/uploads/abcdefgh133535/my-file.zip', - }); - - await emitEditorEvent({ event: 'transaction', tiptapEditor: editor }); - - expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip'); - }); - - it('updates link input box with link href otherwise', async () => { - jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({ - href: 'https://gitlab.com', - }); - - await emitEditorEvent({ event: 'transaction', tiptapEditor: editor }); - - expect(findLinkURLInput().element.value).toEqual('https://gitlab.com'); - }); - }); - }); - - describe('when there is no active link', () => { - beforeEach(() => { - jest.spyOn(editor, 'isActive'); - editor.isActive.mockReturnValueOnce(false); - buildWrapper(); - }); - - it('does not set dropdown as active', () => { - expect(findDropdown().props('toggleClass')).toEqual({ active: false }); - }); - - it('displays the upload file option', () => { - expect(wrapper.findByText('Upload file').exists()).toBe(true); - }); - - it('does not display a remove link dropdown option', () => { - expect(wrapper.findByText('Remove link').exists()).toBe(false); - }); - - it('sets the link to the value in the URL input when "Apply" button is clicked', async () => { - const commands = mockChainedCommands(editor, ['focus', 'unsetLink', 'setLink', 'run']); - - await findLinkURLInput().setValue('https://example'); - await findApplyLinkButton().trigger('click'); - - expect(commands.focus).toHaveBeenCalled(); - expect(commands.setLink).toHaveBeenCalledWith({ - href: 'https://example', - canonicalSrc: 'https://example', - }); - expect(commands.run).toHaveBeenCalled(); - - expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); - }); - - it('uploads the selected image when file input changes', async () => { - const commands = mockChainedCommands(editor, ['focus', 'uploadAttachment', 'run']); - const file = new File(['foo'], 'foo.png', { type: 'image/png' }); - - await selectFile(file); - - expect(commands.focus).toHaveBeenCalled(); - expect(commands.uploadAttachment).toHaveBeenCalledWith({ file }); - expect(commands.run).toHaveBeenCalled(); - - expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); - }); - }); - - describe('when the user displays the dropdown', () => { - let commands; - - beforeEach(() => { - commands = mockChainedCommands(editor, ['focus', 'extendMarkRange', 'run']); - }); - - describe('given the user has not selected text', () => { - beforeEach(() => { - hasSelection.mockReturnValueOnce(false); - }); - - it('the editor selection is extended to the current mark extent', () => { - buildWrapper(); - - findDropdown().vm.$emit('show'); - expect(commands.extendMarkRange).toHaveBeenCalledWith(Link.name); - expect(commands.focus).toHaveBeenCalled(); - expect(commands.run).toHaveBeenCalled(); - }); - }); - - describe('given the user has selected text', () => { - beforeEach(() => { - hasSelection.mockReturnValueOnce(true); - }); - - it('the editor does not modify the current selection', () => { - buildWrapper(); - - findDropdown().vm.$emit('show'); - expect(commands.extendMarkRange).not.toHaveBeenCalled(); - expect(commands.focus).not.toHaveBeenCalled(); - expect(commands.run).not.toHaveBeenCalled(); - }); - }); - }); - - describe('a11y tests', () => { - it('sets text, title, and text-sr-only properties to the table button dropdown', () => { - buildWrapper(); - - expect(findDropdown().props()).toMatchObject({ - text: 'Insert link', - textSrOnly: true, - }); - expect(findDropdown().attributes('title')).toBe('Insert link'); - }); - }); -}); diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js index 5af4784f358..78b02744d51 100644 --- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js @@ -53,7 +53,7 @@ describe('content_editor/components/toolbar_more_dropdown', () => { let commands; let btn; - beforeEach(async () => { + beforeEach(() => { buildWrapper(); commands = mockChainedCommands(tiptapEditor, [command, 'focus', 'run']); diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index 057e50cd0e2..cbeea90dcb4 100644 --- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -26,7 +26,7 @@ describe('content/components/wrappers/code_block', () => { eventHub = eventHubFactory(); }; - const createWrapper = async (nodeAttrs = { language }) => { + const createWrapper = (nodeAttrs = { language }) => { updateAttributesFn = jest.fn(); wrapper = mountExtended(CodeBlockWrapper, { @@ -97,7 +97,7 @@ describe('content/components/wrappers/code_block', () => { jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(true); }); - it('does not render a preview if showPreview: false', async () => { + it('does not render a preview if showPreview: false', () => { createWrapper({ language: 'plantuml', isDiagram: true, showPreview: false }); expect(wrapper.findComponent({ ref: 'diagramContainer' }).exists()).toBe(false); diff --git a/spec/frontend/content_editor/components/wrappers/details_spec.js b/spec/frontend/content_editor/components/wrappers/details_spec.js index 232c1e9aede..e35b04636f7 100644 --- a/spec/frontend/content_editor/components/wrappers/details_spec.js +++ b/spec/frontend/content_editor/components/wrappers/details_spec.js @@ -5,7 +5,7 @@ import DetailsWrapper from '~/content_editor/components/wrappers/details.vue'; describe('content/components/wrappers/details', () => { let wrapper; - const createWrapper = async () => { + const createWrapper = () => { wrapper = shallowMountExtended(DetailsWrapper, { propsData: { node: {}, diff --git a/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js b/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js index 91c6799478e..b5b118a2d9a 100644 --- a/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js +++ b/spec/frontend/content_editor/components/wrappers/footnote_definition_spec.js @@ -4,7 +4,7 @@ import FootnoteDefinitionWrapper from '~/content_editor/components/wrappers/foot describe('content/components/wrappers/footnote_definition', () => { let wrapper; - const createWrapper = async (node = {}) => { + const createWrapper = (node = {}) => { wrapper = shallowMountExtended(FootnoteDefinitionWrapper, { propsData: { node, diff --git a/spec/frontend/content_editor/components/wrappers/label_spec.js b/spec/frontend/content_editor/components/wrappers/reference_label_spec.js index fa32b746142..f57caee911b 100644 --- a/spec/frontend/content_editor/components/wrappers/label_spec.js +++ b/spec/frontend/content_editor/components/wrappers/reference_label_spec.js @@ -1,12 +1,12 @@ import { GlLabel } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import LabelWrapper from '~/content_editor/components/wrappers/label.vue'; +import ReferenceLabelWrapper from '~/content_editor/components/wrappers/reference_label.vue'; -describe('content/components/wrappers/label', () => { +describe('content/components/wrappers/reference_label', () => { let wrapper; - const createWrapper = async (node = {}) => { - wrapper = shallowMountExtended(LabelWrapper, { + const createWrapper = (node = {}) => { + wrapper = shallowMountExtended(ReferenceLabelWrapper, { propsData: { node }, }); }; diff --git a/spec/frontend/content_editor/components/wrappers/reference_spec.js b/spec/frontend/content_editor/components/wrappers/reference_spec.js new file mode 100644 index 00000000000..828b92a6b1e --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/reference_spec.js @@ -0,0 +1,46 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ReferenceWrapper from '~/content_editor/components/wrappers/reference.vue'; + +describe('content/components/wrappers/reference', () => { + let wrapper; + + const createWrapper = (node = {}) => { + wrapper = shallowMountExtended(ReferenceWrapper, { + propsData: { node }, + }); + }; + + it('renders a span for commands', () => { + createWrapper({ attrs: { referenceType: 'command', text: '/assign' } }); + + const span = wrapper.find('span'); + expect(span.text()).toBe('/assign'); + }); + + it('renders an anchor for everything else', () => { + createWrapper({ attrs: { referenceType: 'issue', text: '#252522' } }); + + const link = wrapper.findComponent(GlLink); + expect(link.text()).toBe('#252522'); + }); + + it('adds gfm-project_member class for project members', () => { + createWrapper({ attrs: { referenceType: 'user', text: '@root' } }); + + const link = wrapper.findComponent(GlLink); + expect(link.text()).toBe('@root'); + expect(link.classes('gfm-project_member')).toBe(true); + expect(link.classes('current-user')).toBe(false); + }); + + it('adds a current-user class if the project member is current user', () => { + window.gon = { current_username: 'root' }; + + createWrapper({ attrs: { referenceType: 'user', text: '@root' } }); + + const link = wrapper.findComponent(GlLink); + expect(link.text()).toBe('@root'); + expect(link.classes('current-user')).toBe(true); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js index d8f34565705..71ffbd3f93c 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -13,7 +13,7 @@ describe('content/components/wrappers/table_cell_base', () => { let editor; let node; - const createWrapper = async (propsData = { cellType: 'td' }) => { + const createWrapper = (propsData = { cellType: 'td' }) => { wrapper = shallowMountExtended(TableCellBaseWrapper, { propsData: { editor, @@ -118,7 +118,7 @@ describe('content/components/wrappers/table_cell_base', () => { }, ); - it('does not allow deleting rows and columns', async () => { + it('does not allow deleting rows and columns', () => { expect(findDropdownItemWithLabelExists('Delete row')).toBe(false); expect(findDropdownItemWithLabelExists('Delete column')).toBe(false); }); @@ -173,7 +173,7 @@ describe('content/components/wrappers/table_cell_base', () => { await nextTick(); }); - it('does not allow adding a row before the header', async () => { + it('does not allow adding a row before the header', () => { expect(findDropdownItemWithLabelExists('Insert row before')).toBe(false); }); diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js index 506f442bcc7..4c91573e0c7 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js @@ -8,7 +8,7 @@ describe('content/components/wrappers/table_cell_body', () => { let editor; let node; - const createWrapper = async () => { + const createWrapper = () => { wrapper = shallowMount(TableCellBodyWrapper, { propsData: { editor, diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js index bebe7fb4124..689a8bc32bb 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js @@ -8,7 +8,7 @@ describe('content/components/wrappers/table_cell_header', () => { let editor; let node; - const createWrapper = async () => { + const createWrapper = () => { wrapper = shallowMount(TableCellHeaderWrapper, { propsData: { editor, diff --git a/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js b/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js index 4d5911dda0c..037da7678bb 100644 --- a/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_of_contents_spec.js @@ -20,7 +20,7 @@ describe('content/components/wrappers/table_of_contents', () => { eventHub = eventHubFactory(); }; - const createWrapper = async () => { + const createWrapper = () => { wrapper = mountExtended(TableOfContentsWrapper, { propsData: { editor: tiptapEditor, diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js index 8f3a4934e77..c9997e3c58f 100644 --- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js +++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js @@ -2,6 +2,7 @@ import PasteMarkdown from '~/content_editor/extensions/paste_markdown'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import Diagram from '~/content_editor/extensions/diagram'; import Frontmatter from '~/content_editor/extensions/frontmatter'; +import Heading from '~/content_editor/extensions/heading'; import Bold from '~/content_editor/extensions/bold'; import { VARIANT_DANGER } from '~/alert'; import eventHubFactory from '~/helpers/event_hub_factory'; @@ -20,6 +21,7 @@ describe('content_editor/extensions/paste_markdown', () => { let doc; let p; let bold; + let heading; let renderMarkdown; let eventHub; const defaultData = { 'text/plain': '**bold text**' }; @@ -36,16 +38,18 @@ describe('content_editor/extensions/paste_markdown', () => { CodeBlockHighlight, Diagram, Frontmatter, + Heading, PasteMarkdown.configure({ renderMarkdown, eventHub }), ], }); ({ - builders: { doc, p, bold }, + builders: { doc, p, bold, heading }, } = createDocBuilder({ tiptapEditor, names: { bold: { markType: Bold.name }, + heading: { nodeType: Heading.name }, }, })); }); @@ -110,6 +114,52 @@ describe('content_editor/extensions/paste_markdown', () => { expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); + + describe('when pasting inline content in an existing paragraph', () => { + it('inserts the inline content next to the existing paragraph content', async () => { + const expectedDoc = doc(p('Initial text and', bold('bold text'))); + + tiptapEditor.commands.setContent('Initial text and '); + + await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when pasting inline content and there is text selected', () => { + it('inserts the block content after the existing paragraph', async () => { + const expectedDoc = doc(p('Initial text', bold('bold text'))); + + tiptapEditor.commands.setContent('Initial text and '); + tiptapEditor.commands.setTextSelection({ from: 13, to: 17 }); + + await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when pasting block content in an existing paragraph', () => { + beforeEach(() => { + renderMarkdown.mockReset(); + renderMarkdown.mockResolvedValueOnce('<h1>Heading</h1><p><strong>bold text</strong></p>'); + }); + + it('inserts the block content after the existing paragraph', async () => { + const expectedDoc = doc( + p('Initial text and'), + heading({ level: 1 }, 'Heading'), + p(bold('bold text')), + ); + + tiptapEditor.commands.setContent('Initial text and '); + + await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); + + expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); + }); + }); }); describe('when rendering markdown fails', () => { diff --git a/spec/frontend/content_editor/markdown_snapshot_spec.js b/spec/frontend/content_editor/markdown_snapshot_spec.js index fd64003420e..49b466fd7f5 100644 --- a/spec/frontend/content_editor/markdown_snapshot_spec.js +++ b/spec/frontend/content_editor/markdown_snapshot_spec.js @@ -42,7 +42,7 @@ describe('markdown example snapshots in ContentEditor', () => { const expectedProseMirrorJsonExamples = loadExamples(prosemirrorJsonYml); const exampleNames = Object.keys(markdownExamples); - beforeAll(async () => { + beforeAll(() => { return renderHtmlAndJsonForAllExamples(markdownExamples).then((examples) => { actualHtmlAndJsonExamples = examples; }); @@ -60,7 +60,7 @@ describe('markdown example snapshots in ContentEditor', () => { if (skipRunningSnapshotWysiwygHtmlTests) { it.todo(`${exampleNamePrefix} HTML: ${skipRunningSnapshotWysiwygHtmlTests}`); } else { - it(`${exampleNamePrefix} HTML`, async () => { + it(`${exampleNamePrefix} HTML`, () => { const expectedHtml = expectedHtmlExamples[name].wysiwyg; const { html: actualHtml } = actualHtmlAndJsonExamples[name]; @@ -78,7 +78,7 @@ describe('markdown example snapshots in ContentEditor', () => { if (skipRunningSnapshotProsemirrorJsonTests) { it.todo(`${exampleNamePrefix} ProseMirror JSON: ${skipRunningSnapshotProsemirrorJsonTests}`); } else { - it(`${exampleNamePrefix} ProseMirror JSON`, async () => { + it(`${exampleNamePrefix} ProseMirror JSON`, () => { const expectedJson = expectedProseMirrorJsonExamples[name]; const { json: actualJson } = actualHtmlAndJsonExamples[name]; diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index 6175cbdd3d4..5dfe9c06923 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -64,13 +64,13 @@ describe('content_editor/services/content_editor', () => { }); describe('editable', () => { - it('returns true when tiptapEditor is editable', async () => { + it('returns true when tiptapEditor is editable', () => { contentEditor.setEditable(true); expect(contentEditor.editable).toBe(true); }); - it('returns false when tiptapEditor is readonly', async () => { + it('returns false when tiptapEditor is readonly', () => { contentEditor.setEditable(false); expect(contentEditor.editable).toBe(false); diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js index 00cc628ca72..53cd51b8c5f 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -53,7 +53,7 @@ describe('content_editor/services/create_content_editor', () => { }); }); - it('allows providing external content editor extensions', async () => { + it('allows providing external content editor extensions', () => { const labelReference = 'this is a ~group::editor'; const { tiptapExtension, serializer } = createTestContentEditorExtension(); diff --git a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js index 8ee37282ee9..a9960918e62 100644 --- a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js +++ b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js @@ -43,7 +43,7 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => { }); }); - it('transforms HTML returned by render function to a ProseMirror document', async () => { + it('transforms HTML returned by render function to a ProseMirror document', () => { const document = doc(p(bold(text)), comment(' some comment ')); expect(result.document.toJSON()).toEqual(document.toJSON()); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index c4d302547a5..a28d5a278e6 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -268,6 +268,19 @@ comment --> ).toBe('![GitLab][gitlab-url]'); }); + it('omits image data urls when serializing', () => { + expect( + serialize( + paragraph( + image({ + src: 'data:image/png;base64,iVBORw0KGgoAAAAN', + alt: 'image', + }), + ), + ), + ).toBe('![image]()'); + }); + it('correctly serializes strikethrough', () => { expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~'); }); @@ -885,6 +898,59 @@ _An elephant at sunset_ ); }); + it('correctly renders a table with checkboxes', () => { + expect( + serialize( + table( + // each table cell must contain at least one paragraph + tableRow( + tableHeader(paragraph('')), + tableHeader(paragraph('Item')), + tableHeader(paragraph('Description')), + ), + tableRow( + tableCell(taskList(taskItem(paragraph('')))), + tableCell(paragraph('Item 1')), + tableCell(paragraph('Description 1')), + ), + tableRow( + tableCell(taskList(taskItem(paragraph('some text')))), + tableCell(paragraph('Item 2')), + tableCell(paragraph('Description 2')), + ), + ), + ).trim(), + ).toBe( + ` +<table> +<tr> +<th> + +</th> +<th>Item</th> +<th>Description</th> +</tr> +<tr> +<td> + +* [ ] +</td> +<td>Item 1</td> +<td>Description 1</td> +</tr> +<tr> +<td> + +* [ ] some text +</td> +<td>Item 2</td> +<td>Description 2</td> +</tr> +</table> + `.trim(), + ); + }); + it('correctly serializes a table with line breaks', () => { expect( serialize( @@ -1309,6 +1375,25 @@ paragraph .run(); }; + const editNonInclusiveMarkAction = (initialContent) => { + tiptapEditor.commands.setContent(initialContent.toJSON()); + tiptapEditor.commands.selectTextblockEnd(); + + let { from } = tiptapEditor.state.selection; + tiptapEditor.commands.setTextSelection({ + from: from - 1, + to: from - 1, + }); + + const sel = tiptapEditor.state.doc.textBetween(from - 1, from, ' '); + tiptapEditor.commands.insertContent(`${sel} modified`); + + tiptapEditor.commands.selectTextblockEnd(); + from = tiptapEditor.state.selection.from; + + tiptapEditor.commands.deleteRange({ from: from - 1, to: from }); + }; + it.each` mark | markdown | modifiedMarkdown | editAction ${'bold'} | ${'**bold**'} | ${'**bold modified**'} | ${defaultEditAction} @@ -1319,8 +1404,8 @@ paragraph ${'italic'} | ${'*italic*'} | ${'*italic modified*'} | ${defaultEditAction} ${'italic'} | ${'<em>italic</em>'} | ${'<em>italic modified</em>'} | ${defaultEditAction} ${'italic'} | ${'<i>italic</i>'} | ${'<i>italic modified</i>'} | ${defaultEditAction} - ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} | ${defaultEditAction} - ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} | ${defaultEditAction} + ${'link'} | ${'[gitlab](https://gitlab.com)'} | ${'[gitlab modified](https://gitlab.com)'} | ${editNonInclusiveMarkAction} + ${'link'} | ${'<a href="https://gitlab.com">link</a>'} | ${'<a href="https://gitlab.com">link modified</a>'} | ${editNonInclusiveMarkAction} ${'link'} | ${'link www.gitlab.com'} | ${'modified link www.gitlab.com'} | ${prependContentEditAction} ${'link'} | ${'link https://www.gitlab.com'} | ${'modified link https://www.gitlab.com'} | ${prependContentEditAction} ${'link'} | ${'link(https://www.gitlab.com)'} | ${'modified link(https://www.gitlab.com)'} | ${prependContentEditAction} diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js index 8c1a3831a74..1459988cf8f 100644 --- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -43,7 +43,7 @@ describe('content_editor/services/track_input_rules_and_shortcuts', () => { }); describe('when creating a heading using an keyboard shortcut', () => { - it('sends a tracking event indicating that a heading was created using an input rule', async () => { + it('sends a tracking event indicating that a heading was created using an input rule', () => { const shortcuts = Heading.parent.config.addKeyboardShortcuts.call(Heading); const [firstShortcut] = Object.keys(shortcuts); const nodeName = Heading.name; @@ -68,7 +68,7 @@ describe('content_editor/services/track_input_rules_and_shortcuts', () => { }); describe('when creating a heading using an input rule', () => { - it('sends a tracking event indicating that a heading was created using an input rule', async () => { + it('sends a tracking event indicating that a heading was created using an input rule', () => { const nodeName = Heading.name; triggerNodeInputRule({ tiptapEditor: editor, inputRuleText: '## ' }); expect(trackingSpy).toHaveBeenCalledWith(undefined, INPUT_RULE_TRACKING_ACTION, { diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap index 4b7439f6fd2..5cfb4702be7 100644 --- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -64,6 +64,7 @@ exports[`Contributors charts should render charts and a RefSelector when loading legendlayout="inline" legendmaxtext="Max" legendmintext="Min" + legendseriesinfo="" option="[object Object]" responsive="" thresholds="" @@ -100,6 +101,7 @@ exports[`Contributors charts should render charts and a RefSelector when loading legendlayout="inline" legendmaxtext="Max" legendmintext="Min" + legendseriesinfo="" option="[object Object]" responsive="" thresholds="" diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js index 12fef9d5ddf..d3cdd0d16ef 100644 --- a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js +++ b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js @@ -173,7 +173,7 @@ describe('custom metrics form fields component', () => { return axios.waitForAll(); }); - it('shows invalid query message', async () => { + it('shows invalid query message', () => { expect(wrapper.text()).toContain(errorMessage); }); }); diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js index 5f20d4ad542..3c4fa2a6de6 100644 --- a/spec/frontend/deploy_keys/components/key_spec.js +++ b/spec/frontend/deploy_keys/components/key_spec.js @@ -1,9 +1,10 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import data from 'test_fixtures/deploy_keys/keys.json'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import key from '~/deploy_keys/components/key.vue'; import DeployKeysStore from '~/deploy_keys/store'; -import { getTimeago } from '~/lib/utils/datetime_utility'; +import { getTimeago, formatDate } from '~/lib/utils/datetime_utility'; describe('Deploy keys key', () => { let wrapper; @@ -18,6 +19,9 @@ describe('Deploy keys key', () => { endpoint: 'https://test.host/dummy/endpoint', ...propsData, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; @@ -43,6 +47,33 @@ describe('Deploy keys key', () => { ); }); + it('renders human friendly expiration date', () => { + const expiresAt = new Date(); + createComponent({ + deployKey: { ...deployKey, expires_at: expiresAt }, + }); + + expect(findTextAndTrim('.key-expires-at')).toBe(`${getTimeago().format(expiresAt)}`); + }); + it('shows tooltip for expiration date', () => { + const expiresAt = new Date(); + createComponent({ + deployKey: { ...deployKey, expires_at: expiresAt }, + }); + + const expiryComponent = wrapper.find('[data-testid="expires-at-tooltip"]'); + const tooltip = getBinding(expiryComponent.element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(expiryComponent.attributes('title')).toBe(`${formatDate(expiresAt)}`); + }); + it('renders never when no expiration date', () => { + createComponent({ + deployKey: { ...deployKey, expires_at: null }, + }); + + expect(wrapper.find('[data-testid="expires-never"]').exists()).toBe(true); + }); + it('shows pencil button for editing', () => { createComponent({ deployKey }); diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index 56bf0fa60a7..a6ab147884f 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -381,7 +381,7 @@ describe('Design discussions component', () => { }); }); - it('should open confirmation modal when the note emits `delete-note` event', async () => { + it('should open confirmation modal when the note emits `delete-note` event', () => { createComponent(); findDesignNotes().at(0).vm.$emit('delete-note', { id: '1' }); diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 82848bd1a19..6f5b282fa3b 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -189,7 +189,7 @@ describe('Design note component', () => { }); }); - it('should emit `delete-note` event with proper payload when delete note button is clicked', async () => { + it('should emit `delete-note` event with proper payload when delete note button is clicked', () => { const payload = { ...note, userPermissions: { diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js index db1cfb4f504..f08efc0c685 100644 --- a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js @@ -1,7 +1,9 @@ import { GlAlert } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import Autosave from '~/autosave'; +import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import createNoteMutation from '~/design_management/graphql/mutations/create_note.mutation.graphql'; @@ -17,11 +19,14 @@ import { mockNoteSubmitFailureMutationResponse, } from '../../mock_data/apollo_mock'; +Vue.use(VueApollo); + jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); jest.mock('~/autosave'); describe('Design reply form component', () => { let wrapper; + let mockApollo; const findTextarea = () => wrapper.find('textarea'); const findSubmitButton = () => wrapper.findComponent({ ref: 'submitButton' }); @@ -32,14 +37,10 @@ describe('Design reply form component', () => { const mockComment = 'New comment'; const mockDiscussionId = 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8'; const createNoteMutationData = { - mutation: createNoteMutation, - update: expect.anything(), - variables: { - input: { - noteableId: mockNoteableId, - discussionId: mockDiscussionId, - body: mockComment, - }, + input: { + noteableId: mockNoteableId, + discussionId: mockDiscussionId, + body: mockComment, }, }; @@ -49,14 +50,15 @@ describe('Design reply form component', () => { const metaKey = { metaKey: true, }; - const mutationHandler = jest.fn().mockResolvedValue(); + const mockMutationHandler = jest.fn().mockResolvedValue(mockNoteSubmitSuccessMutationResponse); function createComponent({ props = {}, mountOptions = {}, data = {}, - mutation = mutationHandler, + mutationHandler = mockMutationHandler, } = {}) { + mockApollo = createMockApollo([[createNoteMutation, mutationHandler]]); wrapper = mount(DesignReplyForm, { propsData: { designNoteMutation: createNoteMutation, @@ -67,11 +69,7 @@ describe('Design reply form component', () => { ...props, }, ...mountOptions, - mocks: { - $apollo: { - mutate: mutation, - }, - }, + apolloProvider: mockApollo, data() { return { ...data, @@ -85,6 +83,7 @@ describe('Design reply form component', () => { }); afterEach(() => { + mockApollo = null; confirmAction.mockReset(); }); @@ -125,9 +124,8 @@ describe('Design reply form component', () => { ${'gid://gitlab/DiffDiscussion/123'} | ${123} `( 'initializes autosave support on discussion with proper key', - async ({ discussionId, shortDiscussionId }) => { + ({ discussionId, shortDiscussionId }) => { createComponent({ props: { discussionId } }); - await nextTick(); expect(Autosave).toHaveBeenCalledWith(expect.any(Element), [ 'Discussion', @@ -138,9 +136,8 @@ describe('Design reply form component', () => { ); describe('when form has no text', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); - await nextTick(); }); it('submit button is disabled', () => { @@ -151,11 +148,10 @@ describe('Design reply form component', () => { key | keyData ${'ctrl'} | ${ctrlKey} ${'meta'} | ${metaKey} - `('does not perform mutation on textarea $key+enter keydown', async ({ keyData }) => { + `('does not perform mutation on textarea $key+enter keydown', ({ keyData }) => { findTextarea().trigger('keydown.enter', keyData); - await nextTick(); - expect(mutationHandler).not.toHaveBeenCalled(); + expect(mockMutationHandler).not.toHaveBeenCalled(); }); it('emits cancelForm event on pressing escape button on textarea', () => { @@ -182,22 +178,20 @@ describe('Design reply form component', () => { noteableId: mockNoteableId, discussionId: mockDiscussionId, }; - const successfulMutation = jest.fn().mockResolvedValue(mockNoteSubmitSuccessMutationResponse); + createComponent({ props: { - designNoteMutation: createNoteMutation, mutationVariables: mockMutationVariables, value: mockComment, }, - mutation: successfulMutation, }); findSubmitButton().vm.$emit('click'); - await nextTick(); - expect(successfulMutation).toHaveBeenCalledWith(createNoteMutationData); + expect(mockMutationHandler).toHaveBeenCalledWith(createNoteMutationData); await waitForPromises(); + expect(wrapper.emitted('note-submit-complete')).toEqual([ [mockNoteSubmitSuccessMutationResponse], ]); @@ -212,20 +206,17 @@ describe('Design reply form component', () => { noteableId: mockNoteableId, discussionId: mockDiscussionId, }; - const successfulMutation = jest.fn().mockResolvedValue(mockNoteSubmitSuccessMutationResponse); + createComponent({ props: { - designNoteMutation: createNoteMutation, mutationVariables: mockMutationVariables, value: mockComment, }, - mutation: successfulMutation, }); findTextarea().trigger('keydown.enter', keyData); - await nextTick(); - expect(successfulMutation).toHaveBeenCalledWith(createNoteMutationData); + expect(mockMutationHandler).toHaveBeenCalledWith(createNoteMutationData); await waitForPromises(); expect(wrapper.emitted('note-submit-complete')).toEqual([ @@ -240,7 +231,7 @@ describe('Design reply form component', () => { designNoteMutation: createNoteMutation, value: mockComment, }, - mutation: failedMutation, + mutationHandler: failedMutation, data: { errorMessage: 'error', }, @@ -260,7 +251,7 @@ describe('Design reply form component', () => { ${false} | ${false} | ${UPDATE_NOTE_ERROR} `( 'return proper error message on error in case of isDiscussion is $isDiscussion and isNewComment is $isNewComment', - async ({ isDiscussion, isNewComment, errorMessage }) => { + ({ isDiscussion, isNewComment, errorMessage }) => { createComponent({ props: { isDiscussion, isNewComment } }); expect(wrapper.vm.getErrorMessage()).toBe(errorMessage); @@ -275,12 +266,11 @@ describe('Design reply form component', () => { expect(wrapper.emitted('cancel-form')).toHaveLength(1); }); - it('opens confirmation modal on Escape key when text has changed', async () => { + it('opens confirmation modal on Escape key when text has changed', () => { createComponent(); findTextarea().setValue(mockComment); - await nextTick(); findTextarea().trigger('keyup.esc'); expect(confirmAction).toHaveBeenCalled(); @@ -292,7 +282,6 @@ describe('Design reply form component', () => { createComponent({ props: { value: mockComment } }); findTextarea().setValue('Comment changed'); - await nextTick(); findTextarea().trigger('keyup.esc'); expect(confirmAction).toHaveBeenCalled(); @@ -306,10 +295,8 @@ describe('Design reply form component', () => { createComponent({ props: { value: mockComment } }); findTextarea().setValue('Comment changed'); - await nextTick(); findTextarea().trigger('keyup.esc'); - await nextTick(); expect(confirmAction).toHaveBeenCalled(); await waitForPromises(); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index 2807fe7727f..3eb47fdb97e 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -1,6 +1,6 @@ -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import DesignOverlay from '~/design_management/components/design_overlay.vue'; @@ -16,22 +16,20 @@ describe('Design overlay component', () => { const mockDimensions = { width: 100, height: 100 }; - const findOverlay = () => wrapper.find('[data-testid="design-overlay"]'); - const findAllNotes = () => wrapper.findAll('[data-testid="note-pin"]'); - const findCommentBadge = () => wrapper.find('[data-testid="comment-badge"]'); + const findOverlay = () => wrapper.findByTestId('design-overlay'); + const findAllNotes = () => wrapper.findAllByTestId('note-pin'); + const findCommentBadge = () => wrapper.findByTestId('comment-badge'); const findBadgeAtIndex = (noteIndex) => findAllNotes().at(noteIndex); const findFirstBadge = () => findBadgeAtIndex(0); const findSecondBadge = () => findBadgeAtIndex(1); - const clickAndDragBadge = async (elem, fromPoint, toPoint) => { + const clickAndDragBadge = (elem, fromPoint, toPoint) => { elem.vm.$emit( 'mousedown', new MouseEvent('click', { clientX: fromPoint.x, clientY: fromPoint.y }), ); findOverlay().trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y }); - await nextTick(); elem.vm.$emit('mouseup', new MouseEvent('click', { clientX: toPoint.x, clientY: toPoint.y })); - await nextTick(); }; function createComponent(props = {}, data = {}) { @@ -47,7 +45,7 @@ describe('Design overlay component', () => { }, }); - wrapper = shallowMount(DesignOverlay, { + wrapper = shallowMountExtended(DesignOverlay, { apolloProvider, propsData: { dimensions: mockDimensions, @@ -80,7 +78,7 @@ describe('Design overlay component', () => { expect(wrapper.attributes().style).toBe('width: 100px; height: 100px; top: 0px; left: 0px;'); }); - it('should emit `openCommentForm` when clicking on overlay', async () => { + it('should emit `openCommentForm` when clicking on overlay', () => { createComponent(); const newCoordinates = { x: 10, @@ -90,7 +88,7 @@ describe('Design overlay component', () => { wrapper .find('[data-qa-selector="design_image_button"]') .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y }); - await nextTick(); + expect(wrapper.emitted('openCommentForm')).toEqual([ [{ x: newCoordinates.x, y: newCoordinates.y }], ]); @@ -175,25 +173,15 @@ describe('Design overlay component', () => { }); }); - it('should recalculate badges positions on window resize', async () => { + it('should calculate badges positions based on dimensions', () => { createComponent({ notes, dimensions: { - width: 400, - height: 400, - }, - }); - - expect(findFirstBadge().props('position')).toEqual({ left: '40px', top: '60px' }); - - wrapper.setProps({ - dimensions: { width: 200, height: 200, }, }); - await nextTick(); expect(findFirstBadge().props('position')).toEqual({ left: '20px', top: '30px' }); }); @@ -216,7 +204,6 @@ describe('Design overlay component', () => { new MouseEvent('click', { clientX: position.x, clientY: position.y }), ); - await nextTick(); findFirstBadge().vm.$emit( 'mouseup', new MouseEvent('click', { clientX: position.x, clientY: position.y }), @@ -290,7 +277,7 @@ describe('Design overlay component', () => { }); describe('when moving the comment badge', () => { - it('should update badge style when note-moving action ends', async () => { + it('should update badge style when note-moving action ends', () => { const { position } = notes[0]; createComponent({ currentCommentForm: { @@ -298,19 +285,15 @@ describe('Design overlay component', () => { }, }); - const commentBadge = findCommentBadge(); + expect(findCommentBadge().props('position')).toEqual({ left: '10px', top: '15px' }); + const toPoint = { x: 20, y: 20 }; - await clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint); - commentBadge.vm.$emit('mouseup', new MouseEvent('click')); - // simulates the currentCommentForm being updated in index.vue component, and - // propagated back down to this prop - wrapper.setProps({ + createComponent({ currentCommentForm: { height: position.height, width: position.width, ...toPoint }, }); - await nextTick(); - expect(commentBadge.props('position')).toEqual({ left: '20px', top: '20px' }); + expect(findCommentBadge().props('position')).toEqual({ left: '20px', top: '20px' }); }); it('should emit `openCommentForm` event when mouseleave fired on overlay element', async () => { @@ -330,8 +313,7 @@ describe('Design overlay component', () => { newCoordinates, ); - wrapper.trigger('mouseleave'); - await nextTick(); + findOverlay().vm.$emit('mouseleave'); expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]); }); diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js index 62a26a8f5dd..b29448b4471 100644 --- a/spec/frontend/design_management/components/design_scaler_spec.js +++ b/spec/frontend/design_management/components/design_scaler_spec.js @@ -36,7 +36,7 @@ describe('Design management design scaler component', () => { expect(wrapper.emitted('scale')[1]).toEqual([1]); }); - it('emits @scale event when "decrement" button clicked', async () => { + it('emits @scale event when "decrement" button clicked', () => { getDecreaseScaleButton().vm.$emit('click'); expect(wrapper.emitted('scale')[1]).toEqual([1.4]); }); diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js index f713203c0ee..698535d8937 100644 --- a/spec/frontend/design_management/components/design_todo_button_spec.js +++ b/spec/frontend/design_management/components/design_todo_button_spec.js @@ -81,7 +81,7 @@ describe('Design management design todo button', () => { await nextTick(); }); - it('calls `$apollo.mutate` with the `todoMarkDone` mutation and variables containing `id`', async () => { + it('calls `$apollo.mutate` with the `todoMarkDone` mutation and variables containing `id`', () => { const todoMarkDoneMutationVariables = { mutation: todoMarkDoneMutation, update: expect.anything(), @@ -127,7 +127,7 @@ describe('Design management design todo button', () => { await nextTick(); }); - it('calls `$apollo.mutate` with the `createDesignTodoMutation` mutation and variables containing `issuable_id`, `issue_id`, & `projectPath`', async () => { + it('calls `$apollo.mutate` with the `createDesignTodoMutation` mutation and variables containing `issuable_id`, `issue_id`, & `projectPath`', () => { const createDesignTodoMutationVariables = { mutation: createDesignTodoMutation, update: expect.anything(), diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap index b5a69b28a88..934bda570d4 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap @@ -7,7 +7,7 @@ exports[`Design management pagination component renders navigation buttons 1`] = class="gl-display-flex gl-align-items-center" > - 0 of 2 + 0 of 3 <gl-button-group-stub class="gl-mx-5" diff --git a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js index 8427d83ceee..28d6b7118be 100644 --- a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js +++ b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js @@ -1,9 +1,18 @@ /* global Mousetrap */ import 'mousetrap'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButtonGroup } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import DesignNavigation from '~/design_management/components/toolbar/design_navigation.vue'; import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants'; +import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + getDesignListQueryResponse, + designListQueryResponseNodes, +} from '../../mock_data/apollo_mock'; const push = jest.fn(); const $router = { @@ -18,11 +27,23 @@ const $route = { describe('Design management pagination component', () => { let wrapper; - function createComponent() { + const buildMockHandler = (nodes = designListQueryResponseNodes) => { + return jest.fn().mockResolvedValue(getDesignListQueryResponse({ designs: nodes })); + }; + + const createMockApolloProvider = (handler) => { + Vue.use(VueApollo); + + return createMockApollo([[getDesignListQuery, handler]]); + }; + + function createComponent({ propsData = {}, handler = buildMockHandler() } = {}) { wrapper = shallowMount(DesignNavigation, { propsData: { id: '2', + ...propsData, }, + apolloProvider: createMockApolloProvider(handler), mocks: { $router, $route, @@ -30,48 +51,45 @@ describe('Design management pagination component', () => { }); } - beforeEach(() => { - createComponent(); - }); + const findGlButtonGroup = () => wrapper.findComponent(GlButtonGroup); + + it('hides components when designs are empty', async () => { + createComponent({ handler: buildMockHandler([]) }); + await waitForPromises(); - it('hides components when designs are empty', () => { + expect(findGlButtonGroup().exists()).toBe(false); expect(wrapper.element).toMatchSnapshot(); }); it('renders navigation buttons', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - designCollection: { designs: [{ id: '1' }, { id: '2' }] }, - }); + createComponent({ handler: buildMockHandler() }); + await waitForPromises(); - await nextTick(); + expect(findGlButtonGroup().exists()).toBe(true); expect(wrapper.element).toMatchSnapshot(); }); describe('keyboard buttons navigation', () => { - beforeEach(() => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - designCollection: { designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }] }, - }); - }); + it('routes to previous design on Left button', async () => { + createComponent({ propsData: { id: designListQueryResponseNodes[1].filename } }); + await waitForPromises(); - it('routes to previous design on Left button', () => { Mousetrap.trigger('left'); expect(push).toHaveBeenCalledWith({ name: DESIGN_ROUTE_NAME, - params: { id: '1' }, + params: { id: designListQueryResponseNodes[0].filename }, query: {}, }); }); - it('routes to next design on Right button', () => { + it('routes to next design on Right button', async () => { + createComponent({ propsData: { id: designListQueryResponseNodes[1].filename } }); + await waitForPromises(); + Mousetrap.trigger('right'); expect(push).toHaveBeenCalledWith({ name: DESIGN_ROUTE_NAME, - params: { id: '3' }, + params: { id: designListQueryResponseNodes[2].filename }, query: {}, }); }); diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js index cdfff61ba4f..3ee68f80538 100644 --- a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js +++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js @@ -1,9 +1,14 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { GlAvatar, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import mockAllVersions from './mock_data/all_versions'; +import mockAllVersions from '../../mock_data/all_versions'; +import { getDesignListQueryResponse } from '../../mock_data/apollo_mock'; const LATEST_VERSION_ID = 1; const PREVIOUS_VERSION_ID = 2; @@ -20,11 +25,20 @@ const MOCK_ROUTE = { query: {}, }; +Vue.use(VueApollo); + describe('Design management design version dropdown component', () => { let wrapper; function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) { + const designVersions = + maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions; + const designListHandler = jest + .fn() + .mockResolvedValue(getDesignListQueryResponse({ versions: designVersions })); + wrapper = shallowMount(DesignVersionDropdown, { + apolloProvider: createMockApollo([[getDesignListQuery, designListHandler]]), propsData: { projectPath: '', issueIid: '', @@ -34,12 +48,6 @@ describe('Design management design version dropdown component', () => { }, stubs: { GlAvatar: true, GlCollapsibleListbox }, }); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions, - }); } const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); @@ -52,7 +60,7 @@ describe('Design management design version dropdown component', () => { beforeEach(async () => { createComponent(); - await nextTick(); + await waitForPromises(); listItem = findAllListboxItems().at(0); }); @@ -74,7 +82,8 @@ describe('Design management design version dropdown component', () => { it('has "latest" on most recent version item', async () => { createComponent(); - await nextTick(); + await waitForPromises(); + expect(findVersionLink(0).text()).toContain('latest'); }); }); @@ -83,7 +92,7 @@ describe('Design management design version dropdown component', () => { it('displays latest version text by default', async () => { createComponent(); - await nextTick(); + await waitForPromises(); expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); @@ -91,35 +100,39 @@ describe('Design management design version dropdown component', () => { it('displays latest version text when only 1 version is present', async () => { createComponent({ maxVersions: 1 }); - await nextTick(); + await waitForPromises(); + expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); it('displays version text when the current version is not the latest', async () => { createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) }); - await nextTick(); + await waitForPromises(); + expect(findListbox().props('toggleText')).toBe(`Showing version #1`); }); it('displays latest version text when the current version is the latest', async () => { createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) }); - await nextTick(); + await waitForPromises(); + expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); it('should have the same length as apollo query', async () => { createComponent(); - await nextTick(); + await waitForPromises(); + expect(findAllListboxItems()).toHaveLength(wrapper.vm.allVersions.length); }); it('should render TimeAgo', async () => { createComponent(); - await nextTick(); + await waitForPromises(); expect(wrapper.findAllComponents(TimeAgo)).toHaveLength(wrapper.vm.allVersions.length); }); diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js deleted file mode 100644 index 24c59ce1a75..00000000000 --- a/spec/frontend/design_management/components/upload/mock_data/all_versions.js +++ /dev/null @@ -1,20 +0,0 @@ -export default [ - { - id: 'gid://gitlab/DesignManagement::Version/1', - sha: 'b389071a06c153509e11da1f582005b316667001', - createdAt: '2021-08-09T06:05:00Z', - author: { - id: 'gid://gitlab/User/1', - name: 'Adminstrator', - }, - }, - { - id: 'gid://gitlab/DesignManagement::Version/2', - sha: 'b389071a06c153509e11da1f582005b316667021', - createdAt: '2021-08-09T06:05:00Z', - author: { - id: 'gid://gitlab/User/1', - name: 'Adminstrator', - }, - }, -]; diff --git a/spec/frontend/design_management/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js index f4026da7dfd..36f611247a9 100644 --- a/spec/frontend/design_management/mock_data/all_versions.js +++ b/spec/frontend/design_management/mock_data/all_versions.js @@ -1,20 +1,26 @@ export default [ { + __typename: 'DesignVersion', id: 'gid://gitlab/DesignManagement::Version/1', sha: 'b389071a06c153509e11da1f582005b316667001', createdAt: '2021-08-09T06:05:00Z', author: { + __typename: 'UserCore', id: 'gid://gitlab/User/1', name: 'Adminstrator', + avatarUrl: 'avatar.png', }, }, { - id: 'gid://gitlab/DesignManagement::Version/1', + __typename: 'DesignVersion', + id: 'gid://gitlab/DesignManagement::Version/2', sha: 'b389071a06c153509e11da1f582005b316667021', createdAt: '2021-08-09T06:05:00Z', author: { + __typename: 'UserCore', id: 'gid://gitlab/User/1', name: 'Adminstrator', + avatarUrl: 'avatar.png', }, }, ]; diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index 2b99dcf14da..18e08ecd729 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -1,4 +1,49 @@ -export const designListQueryResponse = { +export const designListQueryResponseNodes = [ + { + __typename: 'Design', + id: '1', + event: 'NONE', + filename: 'fox_1.jpg', + notesCount: 3, + image: 'image-1', + imageV432x230: 'image-1', + currentUserTodos: { + __typename: 'ToDo', + nodes: [], + }, + }, + { + __typename: 'Design', + id: '2', + event: 'NONE', + filename: 'fox_2.jpg', + notesCount: 2, + image: 'image-2', + imageV432x230: 'image-2', + currentUserTodos: { + __typename: 'ToDo', + nodes: [], + }, + }, + { + __typename: 'Design', + id: '3', + event: 'NONE', + filename: 'fox_3.jpg', + notesCount: 1, + image: 'image-3', + imageV432x230: 'image-3', + currentUserTodos: { + __typename: 'ToDo', + nodes: [], + }, + }, +]; + +export const getDesignListQueryResponse = ({ + versions = [], + designs = designListQueryResponseNodes, +} = {}) => ({ data: { project: { __typename: 'Project', @@ -11,57 +56,17 @@ export const designListQueryResponse = { copyState: 'READY', designs: { __typename: 'DesignConnection', - nodes: [ - { - __typename: 'Design', - id: '1', - event: 'NONE', - filename: 'fox_1.jpg', - notesCount: 3, - image: 'image-1', - imageV432x230: 'image-1', - currentUserTodos: { - __typename: 'ToDo', - nodes: [], - }, - }, - { - __typename: 'Design', - id: '2', - event: 'NONE', - filename: 'fox_2.jpg', - notesCount: 2, - image: 'image-2', - imageV432x230: 'image-2', - currentUserTodos: { - __typename: 'ToDo', - nodes: [], - }, - }, - { - __typename: 'Design', - id: '3', - event: 'NONE', - filename: 'fox_3.jpg', - notesCount: 1, - image: 'image-3', - imageV432x230: 'image-3', - currentUserTodos: { - __typename: 'ToDo', - nodes: [], - }, - }, - ], + nodes: designs, }, versions: { - __typename: 'DesignVersion', - nodes: [], + __typename: 'DesignVersionConnection', + nodes: versions, }, }, }, }, }, -}; +}); export const designUploadMutationCreatedResponse = { data: { @@ -212,64 +217,62 @@ export const getDesignQueryResponse = { }, }; -export const mockNoteSubmitSuccessMutationResponse = [ - { - data: { - createNote: { - note: { - id: 'gid://gitlab/DiffNote/468', - author: { - id: 'gid://gitlab/User/1', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - name: 'Administrator', - username: 'root', - webUrl: 'http://127.0.0.1:3000/root', - __typename: 'UserCore', - }, - body: 'New comment', - bodyHtml: "<p data-sourcepos='1:1-1:4' dir='auto'>asdd</p>", - createdAt: '2023-02-24T06:49:20Z', - resolved: false, - position: { - diffRefs: { - baseSha: 'f63ae53ed82d8765477c191383e1e6a000c10375', - startSha: 'f63ae53ed82d8765477c191383e1e6a000c10375', - headSha: 'f348c652f1a737151fc79047895e695fbe81464c', - __typename: 'DiffRefs', - }, - x: 441, - y: 128, - height: 152, - width: 695, - __typename: 'DiffPosition', - }, - userPermissions: { - adminNote: true, - repositionNote: true, - __typename: 'NotePermissions', +export const mockNoteSubmitSuccessMutationResponse = { + data: { + createNote: { + note: { + id: 'gid://gitlab/DiffNote/468', + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + body: 'New comment', + bodyHtml: "<p data-sourcepos='1:1-1:4' dir='auto'>asdd</p>", + createdAt: '2023-02-24T06:49:20Z', + resolved: false, + position: { + diffRefs: { + baseSha: 'f63ae53ed82d8765477c191383e1e6a000c10375', + startSha: 'f63ae53ed82d8765477c191383e1e6a000c10375', + headSha: 'f348c652f1a737151fc79047895e695fbe81464c', + __typename: 'DiffRefs', }, - discussion: { - id: 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8', - notes: { - nodes: [ - { - id: 'gid://gitlab/DiffNote/459', - __typename: 'Note', - }, - ], - __typename: 'NoteConnection', - }, - __typename: 'Discussion', + x: 441, + y: 128, + height: 152, + width: 695, + __typename: 'DiffPosition', + }, + userPermissions: { + adminNote: true, + repositionNote: true, + __typename: 'NotePermissions', + }, + discussion: { + id: 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8', + notes: { + nodes: [ + { + id: 'gid://gitlab/DiffNote/459', + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', }, - __typename: 'Note', + __typename: 'Discussion', }, - errors: [], - __typename: 'CreateNotePayload', + __typename: 'Note', }, + errors: [], + __typename: 'CreateNotePayload', }, }, -]; +}; export const mockNoteSubmitFailureMutationResponse = [ { diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index d86fbf81d20..18b63082e4a 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -2,7 +2,7 @@ exports[`Design management design index page renders design index 1`] = ` <div - class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" + class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" > <div class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative" @@ -115,7 +115,7 @@ exports[`Design management design index page renders design index 1`] = ` exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = ` <div - class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" + class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" > <div class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative" diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 6cec4036d40..fcb03ea3700 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -153,7 +153,7 @@ describe('Design management design index page', () => { }); describe('when navigating away from component', () => { - it('removes fullscreen layout class', async () => { + it('removes fullscreen layout class', () => { jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement); createComponent({ loading: true }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 1ddf757eb19..1a6403d3b87 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -32,7 +32,7 @@ import { import { createAlert } from '~/alert'; import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import { - designListQueryResponse, + getDesignListQueryResponse, designUploadMutationCreatedResponse, designUploadMutationUpdatedResponse, getPermissionsQueryResponse, @@ -100,6 +100,7 @@ describe('Design management index page', () => { let wrapper; let fakeApollo; let moveDesignHandler; + let permissionsQueryHandler; const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); const findSelectAllButton = () => wrapper.findByTestId('select-all-designs-button'); @@ -174,14 +175,16 @@ describe('Design management index page', () => { } function createComponentWithApollo({ + permissionsHandler = jest.fn().mockResolvedValue(getPermissionsQueryResponse()), moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse), }) { Vue.use(VueApollo); + permissionsQueryHandler = permissionsHandler; moveDesignHandler = moveHandler; const requestHandlers = [ - [getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)], - [permissionsQuery, jest.fn().mockResolvedValue(getPermissionsQueryResponse())], + [getDesignListQuery, jest.fn().mockResolvedValue(getDesignListQueryResponse())], + [permissionsQuery, permissionsQueryHandler], [moveDesignMutation, moveDesignHandler], ]; @@ -230,13 +233,6 @@ describe('Design management index page', () => { expect(findDesignUploadButton().exists()).toBe(true); }); - it('does not render toolbar when there is no permission', () => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false }); - - expect(findDesignToolbarWrapper().exists()).toBe(false); - expect(findDesignUploadButton().exists()).toBe(false); - }); - it('has correct classes applied to design dropzone', () => { createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); expect(dropzoneClasses()).toContain('design-list-item'); @@ -723,7 +719,7 @@ describe('Design management index page', () => { expect(mockMutate).not.toHaveBeenCalled(); }); - it('removes onPaste listener after mouseleave event', async () => { + it('removes onPaste listener after mouseleave event', () => { findDesignsWrapper().trigger('mouseleave'); document.dispatchEvent(event); @@ -744,6 +740,17 @@ describe('Design management index page', () => { }); }); + describe('when there is no permission to create a design', () => { + beforeEach(() => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false }); + }); + + it("doesn't render the design toolbar and dropzone", () => { + expect(findToolbar().exists()).toBe(false); + expect(findDropzoneWrapper().exists()).toBe(false); + }); + }); + describe('with mocked Apollo client', () => { it('has a design with id 1 as a first one', async () => { createComponentWithApollo({}); @@ -819,5 +826,17 @@ describe('Design management index page', () => { 'Something went wrong when reordering designs. Please try again', ); }); + + it("doesn't render the design toolbar and dropzone if the user can't edit", async () => { + createComponentWithApollo({ + permissionsHandler: jest.fn().mockResolvedValue(getPermissionsQueryResponse(false)), + }); + + await waitForPromises(); + + expect(permissionsQueryHandler).toHaveBeenCalled(); + expect(findToolbar().exists()).toBe(false); + expect(findDropzoneWrapper().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 06995706a2b..f24ce8ba4ce 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -11,6 +11,7 @@ import CommitWidget from '~/diffs/components/commit_widget.vue'; import CompareVersions from '~/diffs/components/compare_versions.vue'; import DiffFile from '~/diffs/components/diff_file.vue'; import NoChanges from '~/diffs/components/no_changes.vue'; +import findingsDrawer from '~/diffs/components/shared/findings_drawer.vue'; import TreeList from '~/diffs/components/tree_list.vue'; import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; @@ -174,7 +175,7 @@ describe('diffs/components/app', () => { }); describe('codequality diff', () => { - it('does not fetch code quality data on FOSS', async () => { + it('does not fetch code quality data on FOSS', () => { createComponent(); jest.spyOn(wrapper.vm, 'fetchCodequality'); wrapper.vm.fetchData(false); @@ -714,19 +715,27 @@ describe('diffs/components/app', () => { }); it.each` - currentDiffFileId | targetFile - ${'123'} | ${2} - ${'312'} | ${1} + currentDiffFileId | targetFile | newFileByFile + ${'123'} | ${2} | ${false} + ${'312'} | ${1} | ${true} `( 'calls navigateToDiffFileIndex with $index when $link is clicked', - async ({ currentDiffFileId, targetFile }) => { - createComponent({ fileByFileUserPreference: true }, ({ state }) => { - state.diffs.treeEntries = { - 123: { type: 'blob', fileHash: '123' }, - 312: { type: 'blob', fileHash: '312' }, - }; - state.diffs.currentDiffFileId = currentDiffFileId; - }); + async ({ currentDiffFileId, targetFile, newFileByFile }) => { + createComponent( + { fileByFileUserPreference: true }, + ({ state }) => { + state.diffs.treeEntries = { + 123: { type: 'blob', fileHash: '123', filePaths: { old: '1234', new: '123' } }, + 312: { type: 'blob', fileHash: '312', filePaths: { old: '3124', new: '312' } }, + }; + state.diffs.currentDiffFileId = currentDiffFileId; + }, + { + glFeatures: { + singleFileFileByFile: newFileByFile, + }, + }, + ); await nextTick(); @@ -736,9 +745,28 @@ describe('diffs/components/app', () => { await nextTick(); - expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(targetFile - 1); + expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith({ + index: targetFile - 1, + singleFile: newFileByFile, + }); }, ); }); }); + + describe('findings-drawer', () => { + it('does not render findings-drawer when codeQualityInlineDrawer flag is off', () => { + createComponent(); + expect(wrapper.findComponent(findingsDrawer).exists()).toBe(false); + }); + + it('does render findings-drawer when codeQualityInlineDrawer flag is on', () => { + createComponent({}, () => {}, { + glFeatures: { + codeQualityInlineDrawer: true, + }, + }); + expect(wrapper.findComponent(findingsDrawer).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 4b4b6351d3f..3c092296130 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlFormCheckbox } from '@gitlab/ui'; import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; @@ -28,6 +29,7 @@ describe('diffs/components/commit_item', () => { const getCommitterElement = () => wrapper.find('.committer'); const getCommitActionsElement = () => wrapper.find('.commit-actions'); const getCommitPipelineStatus = () => wrapper.findComponent(CommitPipelineStatus); + const getCommitCheckbox = () => wrapper.findComponent(GlFormCheckbox); const mountComponent = (propsData) => { wrapper = mount(Component, { @@ -168,4 +170,24 @@ describe('diffs/components/commit_item', () => { expect(getCommitPipelineStatus().exists()).toBe(true); }); }); + + describe('when commit is selectable', () => { + beforeEach(() => { + mountComponent({ + commit: { ...commit }, + isSelectable: true, + }); + }); + + it('renders checkbox', () => { + expect(getCommitCheckbox().exists()).toBe(true); + }); + + it('emits "handleCheckboxChange" event on change', () => { + expect(wrapper.emitted('handleCheckboxChange')).toBeUndefined(); + getCommitCheckbox().vm.$emit('change'); + + expect(wrapper.emitted('handleCheckboxChange')[0]).toEqual([true]); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_code_quality_item_spec.js b/spec/frontend/diffs/components/diff_code_quality_item_spec.js new file mode 100644 index 00000000000..be9fb61a77d --- /dev/null +++ b/spec/frontend/diffs/components/diff_code_quality_item_spec.js @@ -0,0 +1,66 @@ +import { GlIcon, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DiffCodeQualityItem from '~/diffs/components/diff_code_quality_item.vue'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { multipleFindingsArr } from '../mock_data/diff_code_quality'; + +let wrapper; + +const findIcon = () => wrapper.findComponent(GlIcon); +const findButton = () => wrapper.findComponent(GlLink); +const findDescriptionPlainText = () => wrapper.findByTestId('description-plain-text'); +const findDescriptionLinkSection = () => wrapper.findByTestId('description-button-section'); + +describe('DiffCodeQuality', () => { + const createWrapper = ({ glFeatures = {} } = {}) => { + return shallowMountExtended(DiffCodeQualityItem, { + propsData: { + finding: multipleFindingsArr[0], + }, + provide: { + glFeatures, + }, + }); + }; + + it('shows icon for given degradation', () => { + wrapper = createWrapper(); + expect(findIcon().exists()).toBe(true); + + expect(findIcon().attributes()).toMatchObject({ + class: `codequality-severity-icon ${SEVERITY_CLASSES[multipleFindingsArr[0].severity]}`, + name: SEVERITY_ICONS[multipleFindingsArr[0].severity], + size: '12', + }); + }); + + describe('with codeQualityInlineDrawer flag false', () => { + it('should render severity + description in plain text', () => { + wrapper = createWrapper({ + glFeatures: { + codeQualityInlineDrawer: false, + }, + }); + expect(findDescriptionPlainText().text()).toContain(multipleFindingsArr[0].severity); + expect(findDescriptionPlainText().text()).toContain(multipleFindingsArr[0].description); + }); + }); + + describe('with codeQualityInlineDrawer flag true', () => { + beforeEach(() => { + wrapper = createWrapper({ + glFeatures: { + codeQualityInlineDrawer: true, + }, + }); + }); + + it('should render severity as plain text', () => { + expect(findDescriptionLinkSection().text()).toContain(multipleFindingsArr[0].severity); + }); + + it('should render button with description text', () => { + expect(findButton().text()).toContain(multipleFindingsArr[0].description); + }); + }); +}); diff --git a/spec/frontend/diffs/components/diff_code_quality_spec.js b/spec/frontend/diffs/components/diff_code_quality_spec.js index e5ca90eb7c8..9ecfb62e1c5 100644 --- a/spec/frontend/diffs/components/diff_code_quality_spec.js +++ b/spec/frontend/diffs/components/diff_code_quality_spec.js @@ -1,13 +1,12 @@ -import { GlIcon } from '@gitlab/ui'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue'; -import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import DiffCodeQualityItem from '~/diffs/components/diff_code_quality_item.vue'; import { NEW_CODE_QUALITY_FINDINGS } from '~/diffs/i18n'; import { multipleFindingsArr } from '../mock_data/diff_code_quality'; let wrapper; -const findIcon = () => wrapper.findComponent(GlIcon); +const diffItems = () => wrapper.findAllComponents(DiffCodeQualityItem); const findHeading = () => wrapper.findByTestId(`diff-codequality-findings-heading`); describe('DiffCodeQuality', () => { @@ -28,37 +27,12 @@ describe('DiffCodeQuality', () => { expect(wrapper.emitted('hideCodeQualityFindings').length).toBe(1); }); - it('renders heading and correct amount of list items for codequality array and their description', async () => { - wrapper = createWrapper(multipleFindingsArr); - expect(findHeading().text()).toEqual(NEW_CODE_QUALITY_FINDINGS); - - const listItems = wrapper.findAll('li'); - expect(wrapper.findAll('li').length).toBe(5); + it('renders heading and correct amount of list items for codequality array and their description', () => { + wrapper = createWrapper(multipleFindingsArr, shallowMountExtended); - listItems.wrappers.map((e, i) => { - return expect(e.text()).toContain( - `${multipleFindingsArr[i].severity} - ${multipleFindingsArr[i].description}`, - ); - }); - }); - - it.each` - severity - ${'info'} - ${'minor'} - ${'major'} - ${'critical'} - ${'blocker'} - ${'unknown'} - `('shows icon for $severity degradation', ({ severity }) => { - wrapper = createWrapper([{ severity }], shallowMountExtended); - - expect(findIcon().exists()).toBe(true); + expect(findHeading().text()).toEqual(NEW_CODE_QUALITY_FINDINGS); - expect(findIcon().attributes()).toMatchObject({ - class: `codequality-severity-icon ${SEVERITY_CLASSES[severity]}`, - name: SEVERITY_ICONS[severity], - size: '12', - }); + expect(diffItems()).toHaveLength(multipleFindingsArr.length); + expect(diffItems().at(0).props().finding).toEqual(multipleFindingsArr[0]); }); }); diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index 4515a8e8926..900aa8d1469 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -85,7 +85,7 @@ describe('DiffFileHeader component', () => { const findExternalLink = () => wrapper.findComponent({ ref: 'externalLink' }); const findReplacedFileButton = () => wrapper.findComponent({ ref: 'replacedFileButton' }); const findViewFileButton = () => wrapper.findComponent({ ref: 'viewButton' }); - const findCollapseIcon = () => wrapper.findComponent({ ref: 'collapseIcon' }); + const findCollapseButton = () => wrapper.findComponent({ ref: 'collapseButton' }); const findEditButton = () => wrapper.findComponent({ ref: 'editButton' }); const findReviewFileCheckbox = () => wrapper.find("[data-testid='fileReviewCheckbox']"); @@ -111,7 +111,7 @@ describe('DiffFileHeader component', () => { ${'hidden'} | ${false} `('collapse toggle is $visibility if collapsible is $collapsible', ({ collapsible }) => { createComponent({ props: { collapsible } }); - expect(findCollapseIcon().exists()).toBe(collapsible); + expect(findCollapseButton().exists()).toBe(collapsible); }); it.each` @@ -120,7 +120,7 @@ describe('DiffFileHeader component', () => { ${false} | ${'chevron-right'} `('collapse icon is $icon if expanded is $expanded', ({ icon, expanded }) => { createComponent({ props: { expanded, collapsible: true } }); - expect(findCollapseIcon().props('name')).toBe(icon); + expect(findCollapseButton().props('icon')).toBe(icon); }); it('when header is clicked emits toggleFile', async () => { @@ -133,7 +133,7 @@ describe('DiffFileHeader component', () => { it('when collapseIcon is clicked emits toggleFile', async () => { createComponent({ props: { collapsible: true } }); - findCollapseIcon().vm.$emit('click', new Event('click')); + findCollapseButton().vm.$emit('click', new Event('click')); await nextTick(); expect(wrapper.emitted().toggleFile).toBeDefined(); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 93698396450..79cd2508757 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -306,7 +306,7 @@ describe('DiffFile', () => { markFileToBeRendered(store); }); - it('should have the file content', async () => { + it('should have the file content', () => { expect(wrapper.findComponent(DiffContentComponent).exists()).toBe(true); }); @@ -316,7 +316,7 @@ describe('DiffFile', () => { }); describe('toggle', () => { - it('should update store state', async () => { + it('should update store state', () => { jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(() => {}); toggleFile(wrapper); diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index bd0e3455872..eb895bd9057 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; -import Autosave from '~/autosave'; import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; import { createModules } from '~/mr_notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; @@ -11,7 +10,6 @@ import { noteableDataMock } from 'jest/notes/mock_data'; import { getDiffFileMock } from '../mock_data/diff_file'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); -jest.mock('~/autosave'); describe('DiffLineNoteForm', () => { let wrapper; @@ -77,7 +75,6 @@ describe('DiffLineNoteForm', () => { const findCommentForm = () => wrapper.findComponent(MultilineCommentForm); beforeEach(() => { - Autosave.mockClear(); createComponent(); }); @@ -100,19 +97,6 @@ describe('DiffLineNoteForm', () => { }); }); - it('should init autosave', () => { - // we're using shallow mount here so there's no element to pass to Autosave - expect(Autosave).toHaveBeenCalledWith(undefined, [ - 'Note', - 'Issue', - 98, - undefined, - 'DiffNote', - undefined, - '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', - ]); - }); - describe('when cancelling form', () => { afterEach(() => { confirmAction.mockReset(); @@ -146,7 +130,6 @@ describe('DiffLineNoteForm', () => { await nextTick(); expect(getSelectedLine().hasForm).toBe(false); - expect(Autosave.mock.instances[0].reset).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js index 9bff6bd14f1..cfc80e61b30 100644 --- a/spec/frontend/diffs/components/diff_view_spec.js +++ b/spec/frontend/diffs/components/diff_view_spec.js @@ -14,7 +14,7 @@ describe('DiffView', () => { const setSelectedCommentPosition = jest.fn(); const getDiffRow = (wrapper) => wrapper.findComponent(DiffRow).vm; - const createWrapper = (props, provide = {}) => { + const createWrapper = (props) => { Vue.use(Vuex); const batchComments = { @@ -48,7 +48,7 @@ describe('DiffView', () => { ...props, }; const stubs = { DiffExpansionCell, DiffRow, DiffCommentCell, DraftNote }; - return shallowMount(DiffView, { propsData, store, stubs, provide }); + return shallowMount(DiffView, { propsData, store, stubs }); }; it('does not render a diff-line component when there is no finding', () => { @@ -56,24 +56,13 @@ describe('DiffView', () => { expect(wrapper.findComponent(DiffLine).exists()).toBe(false); }); - it('does render a diff-line component with the correct props when there is a finding & refactorCodeQualityInlineFindings flag is true', async () => { - const wrapper = createWrapper(diffCodeQuality, { - glFeatures: { refactorCodeQualityInlineFindings: true }, - }); + it('does render a diff-line component with the correct props when there is a finding', async () => { + const wrapper = createWrapper(diffCodeQuality); wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2); await nextTick(); expect(wrapper.findComponent(DiffLine).props('line')).toBe(diffCodeQuality.diffLines[2]); }); - it('does not render a diff-line component when there is a finding & refactorCodeQualityInlineFindings flag is false', async () => { - const wrapper = createWrapper(diffCodeQuality, { - glFeatures: { refactorCodeQualityInlineFindings: false }, - }); - wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2); - await nextTick(); - expect(wrapper.findComponent(DiffLine).exists()).toBe(false); - }); - it.each` type | side | container | sides | total ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true }, right: { lineDrafts: [], renderDiscussion: true } }} | ${2} diff --git a/spec/frontend/diffs/components/hidden_files_warning_spec.js b/spec/frontend/diffs/components/hidden_files_warning_spec.js index d9359fb3c7b..9b748a3ed6f 100644 --- a/spec/frontend/diffs/components/hidden_files_warning_spec.js +++ b/spec/frontend/diffs/components/hidden_files_warning_spec.js @@ -37,7 +37,9 @@ describe('HiddenFilesWarning', () => { it('has a correct visible/total files text', () => { expect(wrapper.text()).toContain( - __('To preserve performance only 5 of 10 files are displayed.'), + __( + 'For a faster browsing experience, only 5 of 10 files are shown. Download one of the files below to see all changes', + ), ); }); }); diff --git a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap new file mode 100644 index 00000000000..ab330ffbb38 --- /dev/null +++ b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FindingsDrawer matches the snapshot 1`] = ` +<gl-drawer-stub + class="findings-drawer" + headerheight="" + open="true" + variant="default" + zindex="252" +> + <h2 + class="gl-font-size-h2 gl-mt-0 gl-mb-0" + data-testid="findings-drawer-heading" + > + + Unused method argument - \`c\`. If it's necessary, use \`_\` or \`_c\` as an argument name to indicate that it won't be used. + + </h2> + <ul + class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!" + > + <li + class="gl-mb-4" + data-testid="findings-drawer-severity" + > + <span + class="gl-font-weight-bold" + > + Severity: + </span> + + <gl-icon-stub + class="codequality-severity-icon gl-text-orange-200" + data-testid="findings-drawer-severity-icon" + name="severity-low" + size="12" + /> + + + minor + + </li> + + <li + class="gl-mb-4" + data-testid="findings-drawer-engine" + > + <span + class="gl-font-weight-bold" + > + Engine: + </span> + + testengine name + + </li> + + <li + class="gl-mb-4" + data-testid="findings-drawer-category" + > + <span + class="gl-font-weight-bold" + > + Category: + </span> + + testcategory 1 + + </li> + + <li + class="gl-mb-4" + data-testid="findings-drawer-other-locations" + > + <span + class="gl-font-weight-bold gl-mb-3 gl-display-block" + > + Other locations: + </span> + + <ul + class="gl-pl-6" + > + <li + class="gl-mb-1" + > + <gl-link-stub + data-testid="findings-drawer-other-locations-link" + href="http://testlink.com" + > + testpath + </gl-link-stub> + </li> + <li + class="gl-mb-1" + > + <gl-link-stub + data-testid="findings-drawer-other-locations-link" + href="http://testlink.com" + > + testpath 1 + </gl-link-stub> + </li> + <li + class="gl-mb-1" + > + <gl-link-stub + data-testid="findings-drawer-other-locations-link" + href="http://testlink.com" + > + testpath2 + </gl-link-stub> + </li> + </ul> + </li> + </ul> + + <span + class="drawer-body gl-display-block gl-px-3 gl-py-0!" + data-testid="findings-drawer-body" + > + Duplicated Code Duplicated code + </span> +</gl-drawer-stub> +`; diff --git a/spec/frontend/diffs/components/shared/findings_drawer_spec.js b/spec/frontend/diffs/components/shared/findings_drawer_spec.js new file mode 100644 index 00000000000..0af6e0f0e96 --- /dev/null +++ b/spec/frontend/diffs/components/shared/findings_drawer_spec.js @@ -0,0 +1,19 @@ +import FindingsDrawer from '~/diffs/components/shared/findings_drawer.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import mockFinding from '../../mock_data/findings_drawer'; + +let wrapper; +describe('FindingsDrawer', () => { + const createWrapper = () => { + return shallowMountExtended(FindingsDrawer, { + propsData: { + drawer: mockFinding, + }, + }); + }; + + it('matches the snapshot', () => { + wrapper = createWrapper(); + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/diffs/create_diffs_store.js b/spec/frontend/diffs/create_diffs_store.js index 307ebdaa4ac..92f38858ca5 100644 --- a/spec/frontend/diffs/create_diffs_store.js +++ b/spec/frontend/diffs/create_diffs_store.js @@ -3,6 +3,7 @@ import Vuex from 'vuex'; import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'; import diffsModule from '~/diffs/store/modules'; import notesModule from '~/notes/stores/modules'; +import findingsDrawer from '~/mr_notes/stores/drawer'; Vue.use(Vuex); @@ -18,6 +19,7 @@ export default function createDiffsStore() { diffs: diffsModule(), notes: notesModule(), batchComments: batchCommentsModule(), + findingsDrawer: findingsDrawer(), }, }); } diff --git a/spec/frontend/diffs/mock_data/diff_code_quality.js b/spec/frontend/diffs/mock_data/diff_code_quality.js index 7558592f6a4..29f16da8d89 100644 --- a/spec/frontend/diffs/mock_data/diff_code_quality.js +++ b/spec/frontend/diffs/mock_data/diff_code_quality.js @@ -24,6 +24,11 @@ export const multipleFindingsArr = [ description: 'mocked blocker Issue', line: 3, }, + { + severity: 'unknown', + description: 'mocked unknown Issue', + line: 3, + }, ]; export const fiveFindings = { diff --git a/spec/frontend/diffs/mock_data/findings_drawer.js b/spec/frontend/diffs/mock_data/findings_drawer.js new file mode 100644 index 00000000000..d7e7e957c83 --- /dev/null +++ b/spec/frontend/diffs/mock_data/findings_drawer.js @@ -0,0 +1,21 @@ +export default { + line: 7, + description: + "Unused method argument - `c`. If it's necessary, use `_` or `_c` as an argument name to indicate that it won't be used.", + severity: 'minor', + engineName: 'testengine name', + categories: ['testcategory 1', 'testcategory 2'], + content: { + body: 'Duplicated Code Duplicated code', + }, + location: { + path: 'workhorse/config_test.go', + lines: { begin: 221, end: 284 }, + }, + otherLocations: [ + { path: 'testpath', href: 'http://testlink.com' }, + { path: 'testpath 1', href: 'http://testlink.com' }, + { path: 'testpath2', href: 'http://testlink.com' }, + ], + type: 'issue', +}; diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index b00076504e3..f3581c3dd74 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import Cookies from '~/lib/utils/cookies'; +import waitForPromises from 'helpers/wait_for_promises'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; @@ -9,6 +10,7 @@ import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, } from '~/diffs/constants'; +import { LOAD_SINGLE_DIFF_FAILED } from '~/diffs/i18n'; import * as diffActions from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; @@ -24,10 +26,16 @@ import { } from '~/lib/utils/http_status'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '~/notes/event_hub'; +import diffsEventHub from '~/diffs/event_hub'; import { diffMetadata } from '../mock_data/diff_metadata'; jest.mock('~/alert'); +jest.mock('~/lib/utils/secret_detection', () => ({ + confirmSensitiveAction: jest.fn(() => Promise.resolve(false)), + containsSensitiveToken: jest.requireActual('~/lib/utils/secret_detection').containsSensitiveToken, +})); + describe('DiffsStoreActions', () => { let mock; @@ -135,6 +143,112 @@ describe('DiffsStoreActions', () => { }); }); + describe('fetchFileByFile', () => { + beforeEach(() => { + window.location.hash = 'e334a2a10f036c00151a04cea7938a5d4213a818'; + }); + + it('should do nothing if there is no tree entry for the file ID', () => { + return testAction(diffActions.fetchFileByFile, {}, { flatBlobsList: [] }, [], []); + }); + + it('should do nothing if the tree entry for the file ID has already been marked as loaded', () => { + return testAction( + diffActions.fetchFileByFile, + {}, + { + flatBlobsList: [ + { fileHash: 'e334a2a10f036c00151a04cea7938a5d4213a818', diffLoaded: true }, + ], + }, + [], + [], + ); + }); + + describe('when a tree entry exists for the file, but it has not been marked as loaded', () => { + let state; + let commit; + let hubSpy; + const endpointDiffForPath = '/diffs/set/endpoint/path'; + const diffForPath = mergeUrlParams( + { + old_path: 'old/123', + new_path: 'new/123', + w: '1', + view: 'inline', + }, + endpointDiffForPath, + ); + const treeEntry = { + fileHash: 'e334a2a10f036c00151a04cea7938a5d4213a818', + filePaths: { old: 'old/123', new: 'new/123' }, + }; + const fileResult = { + diff_files: [{ file_hash: 'e334a2a10f036c00151a04cea7938a5d4213a818' }], + }; + const getters = { + flatBlobsList: [treeEntry], + getDiffFileByHash(hash) { + return state.diffFiles?.find((entry) => entry.file_hash === hash); + }, + }; + + beforeEach(() => { + commit = jest.fn(); + state = { + endpointDiffForPath, + diffFiles: [], + }; + getters.flatBlobsList = [treeEntry]; + hubSpy = jest.spyOn(diffsEventHub, '$emit'); + }); + + it('does nothing if the file already exists in the loaded diff files', () => { + state.diffFiles = fileResult.diff_files; + + return testAction(diffActions.fetchFileByFile, state, getters, [], []); + }); + + it('does some standard work every time', async () => { + mock.onGet(diffForPath).reply(HTTP_STATUS_OK, fileResult); + + await diffActions.fetchFileByFile({ state, getters, commit }); + + expect(commit).toHaveBeenCalledWith(types.SET_BATCH_LOADING_STATE, 'loading'); + expect(commit).toHaveBeenCalledWith(types.SET_RETRIEVING_BATCHES, true); + + // wait for the mocked network request to return and start processing the .then + await waitForPromises(); + + expect(commit).toHaveBeenCalledWith(types.SET_DIFF_DATA_BATCH, fileResult); + expect(commit).toHaveBeenCalledWith(types.SET_BATCH_LOADING_STATE, 'loaded'); + + expect(hubSpy).toHaveBeenCalledWith('diffFilesModified'); + }); + + it.each` + urlHash | diffFiles | expected + ${treeEntry.fileHash} | ${[]} | ${''} + ${'abcdef1234567890'} | ${fileResult.diff_files} | ${'e334a2a10f036c00151a04cea7938a5d4213a818'} + `( + "sets the current file to the first diff file ('$id') if it's not a note hash and there isn't a current ID set", + async ({ urlHash, diffFiles, expected }) => { + window.location.hash = urlHash; + mock.onGet(diffForPath).reply(HTTP_STATUS_OK, fileResult); + state.diffFiles = diffFiles; + + await diffActions.fetchFileByFile({ state, getters, commit }); + + // wait for the mocked network request to return and start processing the .then + await waitForPromises(); + + expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, expected); + }, + ); + }); + }); + describe('fetchDiffFilesBatch', () => { it('should fetch batch diff files', () => { const endpointBatch = '/fetch/diffs_batch'; @@ -818,31 +932,32 @@ describe('DiffsStoreActions', () => { }); describe('saveDiffDiscussion', () => { - it('dispatches actions', () => { - const commitId = 'something'; - const formData = { - diffFile: getDiffFileMock(), - noteableData: {}, - }; - const note = {}; - const state = { - commit: { - id: commitId, - }, - }; - const dispatch = jest.fn((name) => { - switch (name) { - case 'saveNote': - return Promise.resolve({ - discussion: 'test', - }); - case 'updateDiscussion': - return Promise.resolve('discussion'); - default: - return Promise.resolve({}); - } - }); + const dispatch = jest.fn((name) => { + switch (name) { + case 'saveNote': + return Promise.resolve({ + discussion: 'test', + }); + case 'updateDiscussion': + return Promise.resolve('discussion'); + default: + return Promise.resolve({}); + } + }); + const commitId = 'something'; + const formData = { + diffFile: getDiffFileMock(), + noteableData: {}, + }; + const note = {}; + const state = { + commit: { + id: commitId, + }, + }; + + it('dispatches actions', () => { return diffActions.saveDiffDiscussion({ state, dispatch }, { note, formData }).then(() => { expect(dispatch).toHaveBeenCalledTimes(5); expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), { @@ -856,6 +971,16 @@ describe('DiffsStoreActions', () => { expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']); }); }); + + it('should not add note with sensitive token', async () => { + const sensitiveMessage = 'token: glpat-1234567890abcdefghij'; + + await diffActions.saveDiffDiscussion( + { state, dispatch }, + { note: sensitiveMessage, formData }, + ); + expect(dispatch).not.toHaveBeenCalled(); + }); }); describe('toggleTreeOpen', () => { @@ -870,6 +995,104 @@ describe('DiffsStoreActions', () => { }); }); + describe('goToFile', () => { + const getters = {}; + const file = { path: 'path' }; + const fileHash = 'test'; + let state; + let dispatch; + let commit; + + beforeEach(() => { + getters.isTreePathLoaded = () => false; + state = { + viewDiffsFileByFile: true, + treeEntries: { + path: { + fileHash, + }, + }, + }; + commit = jest.fn(); + dispatch = jest.fn().mockResolvedValue(); + }); + + it('immediately defers to scrollToFile if the app is not in file-by-file mode', () => { + state.viewDiffsFileByFile = false; + + diffActions.goToFile({ state, dispatch }, file); + + expect(dispatch).toHaveBeenCalledWith('scrollToFile', file); + }); + + describe('when the app is in fileByFile mode', () => { + describe('when the singleFileFileByFile feature flag is enabled', () => { + it('commits SET_CURRENT_DIFF_FILE', () => { + diffActions.goToFile( + { state, commit, dispatch, getters }, + { path: file.path, singleFile: true }, + ); + + expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash); + }); + + it('does nothing more if the path has already been loaded', () => { + getters.isTreePathLoaded = () => true; + + diffActions.goToFile( + { state, dispatch, getters, commit }, + { path: file.path, singleFile: true }, + ); + + expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash); + expect(dispatch).toHaveBeenCalledTimes(0); + }); + + describe('when the tree entry has not been loaded', () => { + it('updates location hash', () => { + diffActions.goToFile( + { state, commit, getters, dispatch }, + { path: file.path, singleFile: true }, + ); + + expect(document.location.hash).toBe('#test'); + }); + + it('loads the file and then scrolls to it', async () => { + diffActions.goToFile( + { state, commit, getters, dispatch }, + { path: file.path, singleFile: true }, + ); + + // Wait for the fetchFileByFile dispatch to return, to trigger scrollToFile + await waitForPromises(); + + expect(dispatch).toHaveBeenCalledWith('fetchFileByFile'); + expect(dispatch).toHaveBeenCalledWith('scrollToFile', file); + expect(dispatch).toHaveBeenCalledTimes(2); + }); + + it('shows an alert when there was an error fetching the file', async () => { + dispatch = jest.fn().mockRejectedValue(); + + diffActions.goToFile( + { state, commit, getters, dispatch }, + { path: file.path, singleFile: true }, + ); + + // Wait for the fetchFileByFile dispatch to return, to trigger the catch + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: expect.stringMatching(LOAD_SINGLE_DIFF_FAILED), + }); + }); + }); + }); + }); + }); + describe('scrollToFile', () => { let commit; const getters = { isVirtualScrollingEnabled: false }; @@ -1392,6 +1615,54 @@ describe('DiffsStoreActions', () => { ); }); + describe('rereadNoteHash', () => { + beforeEach(() => { + window.location.hash = 'note_123'; + }); + + it('dispatches setCurrentDiffFileIdFromNote if the hash is a note URL', () => { + window.location.hash = 'note_123'; + + return testAction( + diffActions.rereadNoteHash, + {}, + {}, + [], + [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }], + ); + }); + + it('dispatches fetchFileByFile if the app is in fileByFile mode', () => { + window.location.hash = 'note_123'; + + return testAction( + diffActions.rereadNoteHash, + {}, + { viewDiffsFileByFile: true }, + [], + [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }, { type: 'fetchFileByFile' }], + ); + }); + + it('does not try to fetch the diff file if the app is not in fileByFile mode', () => { + window.location.hash = 'note_123'; + + return testAction( + diffActions.rereadNoteHash, + {}, + { viewDiffsFileByFile: false }, + [], + [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }], + ); + }); + + it('does nothing if the hash is not a note URL', () => { + window.location.hash = 'abcdef1234567890'; + + return testAction(diffActions.rereadNoteHash, {}, {}, [], []); + }); + }); + describe('setCurrentDiffFileIdFromNote', () => { it('commits SET_CURRENT_DIFF_FILE', () => { const commit = jest.fn(); @@ -1436,12 +1707,22 @@ describe('DiffsStoreActions', () => { it('commits SET_CURRENT_DIFF_FILE', () => { return testAction( diffActions.navigateToDiffFileIndex, - 0, + { index: 0, singleFile: false }, { flatBlobsList: [{ fileHash: '123' }] }, [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], [], ); }); + + it('dispatches the fetchFileByFile action when the state value viewDiffsFileByFile is true and the single-file file-by-file feature flag is enabled', () => { + return testAction( + diffActions.navigateToDiffFileIndex, + { index: 0, singleFile: true }, + { viewDiffsFileByFile: true, flatBlobsList: [{ fileHash: '123' }] }, + [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], + [{ type: 'fetchFileByFile' }], + ); + }); }); describe('setFileByFile', () => { diff --git a/spec/frontend/diffs/utils/merge_request_spec.js b/spec/frontend/diffs/utils/merge_request_spec.js index c070e8c004d..21599a3be45 100644 --- a/spec/frontend/diffs/utils/merge_request_spec.js +++ b/spec/frontend/diffs/utils/merge_request_spec.js @@ -1,4 +1,8 @@ -import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request'; +import { + updateChangesTabCount, + getDerivedMergeRequestInformation, +} from '~/diffs/utils/merge_request'; +import { ZERO_CHANGES_ALT_DISPLAY } from '~/diffs/constants'; import { diffMetadata } from '../mock_data/diff_metadata'; describe('Merge Request utilities', () => { @@ -24,6 +28,56 @@ describe('Merge Request utilities', () => { ...noVersion, }; + describe('updateChangesTabCount', () => { + let dummyTab; + let badge; + + beforeEach(() => { + dummyTab = document.createElement('div'); + dummyTab.classList.add('js-diffs-tab'); + dummyTab.insertAdjacentHTML('afterbegin', '<span class="gl-badge">ERROR</span>'); + badge = dummyTab.querySelector('.gl-badge'); + }); + + afterEach(() => { + dummyTab.remove(); + dummyTab = null; + badge = null; + }); + + it('uses the alt hyphen display when the new changes are falsey', () => { + updateChangesTabCount({ count: 0, badge }); + + expect(dummyTab.textContent).toBe(ZERO_CHANGES_ALT_DISPLAY); + + updateChangesTabCount({ badge }); + + expect(dummyTab.textContent).toBe(ZERO_CHANGES_ALT_DISPLAY); + + updateChangesTabCount({ count: false, badge }); + + expect(dummyTab.textContent).toBe(ZERO_CHANGES_ALT_DISPLAY); + }); + + it('uses the actual value for display when the value is truthy', () => { + updateChangesTabCount({ count: 42, badge }); + + expect(dummyTab.textContent).toBe('42'); + + updateChangesTabCount({ count: '999+', badge }); + + expect(dummyTab.textContent).toBe('999+'); + }); + + it('selects the proper element to modify by default', () => { + document.body.insertAdjacentElement('afterbegin', dummyTab); + + updateChangesTabCount({ count: 42 }); + + expect(dummyTab.textContent).toBe('42'); + }); + }); + describe('getDerivedMergeRequestInformation', () => { let endpoint = `${diffMetadata.latest_version_path}.json?searchParam=irrelevant`; diff --git a/spec/frontend/drawio/drawio_editor_spec.js b/spec/frontend/drawio/drawio_editor_spec.js index 5ef26c04204..d7d75922e1e 100644 --- a/spec/frontend/drawio/drawio_editor_spec.js +++ b/spec/frontend/drawio/drawio_editor_spec.js @@ -108,7 +108,7 @@ describe('drawio/drawio_editor', () => { await waitForDrawioIFrameMessage(); }); - it('sends configure action to the draw.io iframe', async () => { + it('sends configure action to the draw.io iframe', () => { expectDrawioIframeMessage({ expectation: { action: 'configure', @@ -121,7 +121,7 @@ describe('drawio/drawio_editor', () => { }); }); - it('does not remove the iframe after the load error timeouts run', async () => { + it('does not remove the iframe after the load error timeouts run', () => { jest.runAllTimers(); expect(findDrawioIframe()).not.toBe(null); @@ -227,7 +227,7 @@ describe('drawio/drawio_editor', () => { postMessageToParentWindow({ event: 'init' }); }); - it('displays an error alert indicating that the image is not a diagram', async () => { + it('displays an error alert indicating that the image is not a diagram', () => { expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, error: expect.any(Error), @@ -248,7 +248,7 @@ describe('drawio/drawio_editor', () => { postMessageToParentWindow({ event: 'init' }); }); - it('displays an error alert indicating the failure', async () => { + it('displays an error alert indicating the failure', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'Cannot load the diagram into the diagrams.net editor', error: expect.any(Error), diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index fdd157dd09f..179ba917e7f 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -48,9 +48,9 @@ describe('dropzone_input', () => { }; beforeEach(() => { - loadHTMLFixture('issues/new-issue.html'); + loadHTMLFixture('milestones/new-milestone.html'); - form = $('#new_issue'); + form = $('#new_milestone'); form.data('uploads-path', TEST_UPLOAD_PATH); dropzoneInput(form); }); diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js index 79692ab4557..b5944a52af7 100644 --- a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js +++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js @@ -33,17 +33,17 @@ describe('Source Editor Toolbar button', () => { it('does not render the button if the props have not been passed', () => { createComponent({}); - expect(findButton().vm).toBeUndefined(); + expect(findButton().exists()).toBe(false); }); - it('renders a default button without props', async () => { + it('renders a default button without props', () => { createComponent(); const btn = findButton(); expect(btn.exists()).toBe(true); expect(btn.props()).toMatchObject(defaultProps); }); - it('renders a button based on the props passed', async () => { + it('renders a button based on the props passed', () => { createComponent({ button: customProps, }); @@ -107,34 +107,31 @@ describe('Source Editor Toolbar button', () => { }); describe('click handler', () => { - let clickEvent; - - beforeEach(() => { - clickEvent = new Event('click'); - }); - it('fires the click handler on the button when available', async () => { - const spy = jest.fn(); + const clickSpy = jest.fn(); + const clickEvent = new Event('click'); createComponent({ button: { - onClick: spy, + onClick: clickSpy, }, }); - expect(spy).not.toHaveBeenCalled(); + expect(wrapper.emitted('click')).toEqual(undefined); findButton().vm.$emit('click', clickEvent); await nextTick(); - expect(spy).toHaveBeenCalledWith(clickEvent); + + expect(wrapper.emitted('click')).toEqual([[clickEvent]]); + expect(clickSpy).toHaveBeenCalledWith(clickEvent); }); + it('emits the "click" event, passing the event itself', async () => { createComponent(); - jest.spyOn(wrapper.vm, '$emit'); - expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + expect(wrapper.emitted('click')).toEqual(undefined); - findButton().vm.$emit('click', clickEvent); + findButton().vm.$emit('click'); await nextTick(); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', clickEvent); + expect(wrapper.emitted('click')).toHaveLength(1); }); }); }); diff --git a/spec/frontend/editor/components/source_editor_toolbar_spec.js b/spec/frontend/editor/components/source_editor_toolbar_spec.js index f737340a317..95dc29c7916 100644 --- a/spec/frontend/editor/components/source_editor_toolbar_spec.js +++ b/spec/frontend/editor/components/source_editor_toolbar_spec.js @@ -104,19 +104,16 @@ describe('Source Editor Toolbar', () => { group: EDITOR_TOOLBAR_BUTTON_GROUPS.settings, }); createComponentWithApollo([item1, item2, item3]); - jest.spyOn(wrapper.vm, '$emit'); - expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + expect(wrapper.emitted('click')).toEqual(undefined); findButtons().at(0).vm.$emit('click'); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item1); + expect(wrapper.emitted('click')).toEqual([[item1]]); findButtons().at(1).vm.$emit('click'); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item2); + expect(wrapper.emitted('click')).toEqual([[item1], [item2]]); findButtons().at(2).vm.$emit('click'); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item3); - - expect(wrapper.vm.$emit.mock.calls).toHaveLength(3); + expect(wrapper.emitted('click')).toEqual([[item1], [item2], [item3]]); }); }); }); diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js index 895eb87833d..fb5fce92482 100644 --- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js @@ -1,6 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; import { Emitter } from 'monaco-editor'; -import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import { @@ -28,6 +27,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => { let panelSpy; let mockAxios; let extension; + let resizeCallback; const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown'; const firstLine = 'This is a'; const secondLine = 'multiline'; @@ -35,6 +35,8 @@ describe('Markdown Live Preview Extension for Source Editor', () => { const text = `${firstLine}\n${secondLine}\n${thirdLine}`; const markdownPath = 'foo.md'; const responseData = '<div>FooBar</div>'; + const observeSpy = jest.fn(); + const disconnectSpy = jest.fn(); const togglePreview = async () => { instance.togglePreview(); @@ -43,8 +45,22 @@ describe('Markdown Live Preview Extension for Source Editor', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - setHTMLFixture('<div id="editor" data-editor-loading></div>'); + setHTMLFixture( + '<div style="width: 500px; height: 500px"><div id="editor" data-editor-loading></div></div>', + ); editorEl = document.getElementById('editor'); + global.ResizeObserver = class { + constructor(callback) { + resizeCallback = callback; + this.observe = (node) => { + return observeSpy(node); + }; + this.disconnect = () => { + return disconnectSpy(); + }; + } + }; + editor = new SourceEditor(); instance = editor.createInstance({ el: editorEl, @@ -77,9 +93,6 @@ describe('Markdown Live Preview Extension for Source Editor', () => { actions: expect.any(Object), shown: false, modelChangeListener: undefined, - layoutChangeListener: { - dispose: expect.anything(), - }, path: previewMarkdownPath, actionShowPreviewCondition: expect.any(Object), eventEmitter: expect.any(Object), @@ -94,36 +107,64 @@ describe('Markdown Live Preview Extension for Source Editor', () => { expect(panelSpy).toHaveBeenCalled(); }); - describe('onDidLayoutChange', () => { - const emitter = new Emitter(); - let layoutSpy; + describe('ResizeObserver handler', () => { + it('sets a ResizeObserver to observe the container DOM node', () => { + observeSpy.mockClear(); + instance.togglePreview(); + expect(observeSpy).toHaveBeenCalledWith(instance.getContainerDomNode()); + }); - useFakeRequestAnimationFrame(); + describe('disconnects the ResizeObserver when…', () => { + beforeEach(() => { + instance.togglePreview(); + instance.markdownPreview.modelChangeListener = { + dispose: jest.fn(), + }; + }); - beforeEach(() => { - instance.unuse(extension); - instance.onDidLayoutChange = emitter.event; - extension = instance.use({ - definition: EditorMarkdownPreviewExtension, - setupOptions: { previewMarkdownPath }, + it('the preview gets closed', () => { + expect(disconnectSpy).not.toHaveBeenCalled(); + instance.togglePreview(); + expect(disconnectSpy).toHaveBeenCalled(); }); - layoutSpy = jest.spyOn(instance, 'layout'); - }); - it('does not trigger the layout when the preview is not active [default]', async () => { - expect(instance.markdownPreview.shown).toBe(false); - expect(layoutSpy).not.toHaveBeenCalled(); - await emitter.fire(); - expect(layoutSpy).not.toHaveBeenCalled(); + it('the extension is unused', () => { + expect(disconnectSpy).not.toHaveBeenCalled(); + instance.unuse(extension); + expect(disconnectSpy).toHaveBeenCalled(); + }); }); - it('triggers the layout if the preview panel is opened', async () => { - expect(layoutSpy).not.toHaveBeenCalled(); - instance.togglePreview(); - layoutSpy.mockReset(); + describe('layout behavior', () => { + let layoutSpy; + let instanceDimensions; + let newInstanceWidth; - await emitter.fire(); - expect(layoutSpy).toHaveBeenCalledTimes(1); + beforeEach(() => { + instanceDimensions = instance.getLayoutInfo(); + }); + + it('does not trigger the layout if the preview panel is closed', () => { + layoutSpy = jest.spyOn(instance, 'layout'); + newInstanceWidth = instanceDimensions.width + 100; + + // Manually trigger the resize event + resizeCallback([{ contentRect: { width: newInstanceWidth } }]); + expect(layoutSpy).not.toHaveBeenCalled(); + }); + + it('triggers the layout if the preview panel is opened, and width of the editor has changed', () => { + instance.togglePreview(); + layoutSpy = jest.spyOn(instance, 'layout'); + newInstanceWidth = instanceDimensions.width + 100; + + // Manually trigger the resize event + resizeCallback([{ contentRect: { width: newInstanceWidth } }]); + expect(layoutSpy).toHaveBeenCalledWith({ + width: newInstanceWidth * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + height: instanceDimensions.height, + }); + }); }); }); @@ -226,11 +267,10 @@ describe('Markdown Live Preview Extension for Source Editor', () => { expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); }); - it('disposes the layoutChange listener and does not re-layout on layout changes', () => { - expect(instance.markdownPreview.layoutChangeListener).toBeDefined(); + it('disconnects the ResizeObserver', () => { instance.unuse(extension); - expect(instance.markdownPreview?.layoutChangeListener).toBeUndefined(); + expect(disconnectSpy).toHaveBeenCalled(); }); it('does not trigger the re-layout after instance is unused', async () => { diff --git a/spec/frontend/editor/utils_spec.js b/spec/frontend/editor/utils_spec.js index 13b8a9804b0..c9d6cbcaaa6 100644 --- a/spec/frontend/editor/utils_spec.js +++ b/spec/frontend/editor/utils_spec.js @@ -1,6 +1,8 @@ import { editor as monacoEditor } from 'monaco-editor'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import * as utils from '~/editor/utils'; +import languages from '~/ide/lib/languages'; +import { registerLanguages } from '~/ide/utils'; import { DEFAULT_THEME } from '~/ide/lib/themes'; describe('Source Editor utils', () => { @@ -53,13 +55,19 @@ describe('Source Editor utils', () => { }); describe('getBlobLanguage', () => { + beforeEach(() => { + registerLanguages(...languages); + }); + it.each` - path | expectedLanguage - ${'foo.js'} | ${'javascript'} - ${'foo.js.rb'} | ${'ruby'} - ${'foo.bar'} | ${'plaintext'} - ${undefined} | ${'plaintext'} - ${'foo/bar/foo.js'} | ${'javascript'} + path | expectedLanguage + ${'foo.js'} | ${'javascript'} + ${'foo.js.rb'} | ${'ruby'} + ${'foo.bar'} | ${'plaintext'} + ${undefined} | ${'plaintext'} + ${'foo/bar/foo.js'} | ${'javascript'} + ${'CODEOWNERS'} | ${'codeowners'} + ${'.gitlab/CODEOWNERS'} | ${'codeowners'} `(`returns '$expectedLanguage' for '$path' path`, ({ path, expectedLanguage }) => { const language = utils.getBlobLanguage(path); diff --git a/spec/frontend/emoji/awards_app/store/actions_spec.js b/spec/frontend/emoji/awards_app/store/actions_spec.js index 3e9b49707ed..65f2e813f19 100644 --- a/spec/frontend/emoji/awards_app/store/actions_spec.js +++ b/spec/frontend/emoji/awards_app/store/actions_spec.js @@ -119,7 +119,7 @@ describe('Awards app actions', () => { mock.onPost(`${relativeRootUrl || ''}/awards`).reply(HTTP_STATUS_OK, { id: 1 }); }); - it('adds an optimistic award, removes it, and then commits ADD_NEW_AWARD', async () => { + it('adds an optimistic award, removes it, and then commits ADD_NEW_AWARD', () => { testAction(actions.toggleAward, null, { path: '/awards', awards: [] }, [ makeOptimisticAddMutation(), makeOptimisticRemoveMutation(), @@ -156,7 +156,7 @@ describe('Awards app actions', () => { mock.onDelete(`${relativeRootUrl || ''}/awards/1`).reply(HTTP_STATUS_OK); }); - it('commits REMOVE_AWARD', async () => { + it('commits REMOVE_AWARD', () => { testAction( actions.toggleAward, 'thumbsup', diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 7b160c48501..4e341b2bb2f 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -21,8 +21,17 @@ class CustomEnvironment extends TestEnvironment { // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39496#note_503084332 setGlobalDateToFakeDate(); + const { error: originalErrorFn } = context.console; Object.assign(context.console, { error(...args) { + if ( + args?.[0]?.includes('[Vue warn]: Missing required prop') || + args?.[0]?.includes('[Vue warn]: Invalid prop') + ) { + originalErrorFn.apply(context.console, args); + return; + } + throw new ErrorWithStack( `Unexpected call of console.error() with:\n\n${args.join(', ')}`, this.error, @@ -30,7 +39,7 @@ class CustomEnvironment extends TestEnvironment { }, warn(...args) { - if (args[0].includes('The updateQuery callback for fetchMore is deprecated')) { + if (args?.[0]?.includes('The updateQuery callback for fetchMore is deprecated')) { return; } throw new ErrorWithStack( diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js index 73a366457fb..f50efada91a 100644 --- a/spec/frontend/environments/deploy_board_component_spec.js +++ b/spec/frontend/environments/deploy_board_component_spec.js @@ -61,7 +61,7 @@ describe('Deploy Board', () => { const icon = iconSpan.findComponent(GlIcon); expect(tooltip.props('target')()).toBe(iconSpan.element); - expect(icon.props('name')).toBe('question'); + expect(icon.props('name')).toBe('question-o'); }); it('renders the canary weight selector', () => { @@ -116,7 +116,7 @@ describe('Deploy Board', () => { const icon = iconSpan.findComponent(GlIcon); expect(tooltip.props('target')()).toBe(iconSpan.element); - expect(icon.props('name')).toBe('question'); + expect(icon.props('name')).toBe('question-o'); }); it('renders the canary weight selector', () => { diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js index 3c9b4144e45..dcfefbb2072 100644 --- a/spec/frontend/environments/environment_actions_spec.js +++ b/spec/frontend/environments/environment_actions_spec.js @@ -1,14 +1,8 @@ -import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; +import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import EnvironmentActions from '~/environments/components/environment_actions.vue'; -import eventHub from '~/environments/event_hub'; -import actionMutation from '~/environments/graphql/mutations/action.mutation.graphql'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import createMockApollo from 'helpers/mock_apollo_helper'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); @@ -29,15 +23,9 @@ const expiredJobAction = { describe('EnvironmentActions Component', () => { let wrapper; - const findEnvironmentActionsButton = () => - wrapper.find('[data-testid="environment-actions-button"]'); - - function createComponent(props, { mountFn = shallowMount, options = {} } = {}) { - wrapper = mountFn(EnvironmentActions, { + function createComponent(props, { options = {} } = {}) { + wrapper = mount(EnvironmentActions, { propsData: { actions: [], ...props }, - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, ...options, }); } @@ -46,9 +34,10 @@ describe('EnvironmentActions Component', () => { return createComponent({ actions: [scheduledJobAction, expiredJobAction] }, opts); } + const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); const findDropdownItem = (action) => { - const buttons = wrapper.findAllComponents(GlDropdownItem); - return buttons.filter((button) => button.text().startsWith(action.name)).at(0); + const items = findDropdownItems(); + return items.filter((item) => item.text().startsWith(action.name)).at(0); }; afterEach(() => { @@ -56,19 +45,15 @@ describe('EnvironmentActions Component', () => { }); it('should render a dropdown button with 2 icons', () => { - createComponent({}, { mountFn: mount }); - expect(wrapper.findComponent(GlDropdown).findAllComponents(GlIcon).length).toBe(2); - }); - - it('should render a dropdown button with aria-label description', () => { createComponent(); - expect(wrapper.findComponent(GlDropdown).attributes('aria-label')).toBe('Deploy to...'); + expect(wrapper.findComponent(GlDisclosureDropdown).findAllComponents(GlIcon).length).toBe(2); }); - it('should render a tooltip', () => { + it('should render a dropdown button with aria-label description', () => { createComponent(); - const tooltip = getBinding(findEnvironmentActionsButton().element, 'gl-tooltip'); - expect(tooltip).toBeDefined(); + expect(wrapper.findComponent(GlDisclosureDropdown).attributes('aria-label')).toBe( + 'Deploy to...', + ); }); describe('manual actions', () => { @@ -93,96 +78,31 @@ describe('EnvironmentActions Component', () => { }); it('should render a dropdown with the provided list of actions', () => { - expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(actions.length); + expect(findDropdownItems()).toHaveLength(actions.length); }); it("should render a disabled action when it's not playable", () => { - const dropdownItems = wrapper.findAllComponents(GlDropdownItem); + const dropdownItems = findDropdownItems(); const lastDropdownItem = dropdownItems.at(dropdownItems.length - 1); - expect(lastDropdownItem.attributes('disabled')).toBe('true'); + expect(lastDropdownItem.find('button').attributes('disabled')).toBe('disabled'); }); }); describe('scheduled jobs', () => { - let emitSpy; - - const clickAndConfirm = async ({ confirm = true } = {}) => { - confirmAction.mockResolvedValueOnce(confirm); - - findDropdownItem(scheduledJobAction).vm.$emit('click'); - await nextTick(); - }; - beforeEach(() => { - emitSpy = jest.fn(); - eventHub.$on('postAction', emitSpy); jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); }); - describe('when postAction event is confirmed', () => { - beforeEach(async () => { - createComponentWithScheduledJobs({ mountFn: mount }); - clickAndConfirm(); - }); - - it('emits postAction event', () => { - expect(confirmAction).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath }); - }); - - it('should render a dropdown button with a loading icon', () => { - expect(wrapper.findComponent(GlLoadingIcon).isVisible()).toBe(true); - }); - }); - - describe('when postAction event is denied', () => { - beforeEach(async () => { - createComponentWithScheduledJobs({ mountFn: mount }); - clickAndConfirm({ confirm: false }); - }); - - it('does not emit postAction event if confirmation is cancelled', () => { - expect(confirmAction).toHaveBeenCalled(); - expect(emitSpy).not.toHaveBeenCalled(); - }); - }); - it('displays the remaining time in the dropdown', () => { + confirmAction.mockResolvedValueOnce(true); createComponentWithScheduledJobs(); expect(findDropdownItem(scheduledJobAction).text()).toContain('24:00:00'); }); it('displays 00:00:00 for expired jobs in the dropdown', () => { + confirmAction.mockResolvedValueOnce(true); createComponentWithScheduledJobs(); expect(findDropdownItem(expiredJobAction).text()).toContain('00:00:00'); }); }); - - describe('graphql', () => { - Vue.use(VueApollo); - - const action = { - name: 'bar', - play_path: 'https://gitlab.com/play', - }; - - let mockApollo; - - beforeEach(() => { - mockApollo = createMockApollo(); - createComponent( - { actions: [action], graphql: true }, - { options: { apolloProvider: mockApollo } }, - ); - }); - - it('should trigger a graphql mutation on click', () => { - jest.spyOn(mockApollo.defaultClient, 'mutate'); - findDropdownItem(action).vm.$emit('click'); - expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ - mutation: actionMutation, - variables: { action }, - }); - }); - }); }); diff --git a/spec/frontend/environments/environment_details/components/deployment_actions_spec.js b/spec/frontend/environments/environment_details/components/deployment_actions_spec.js index 725c8c6479e..a0eb4c494e6 100644 --- a/spec/frontend/environments/environment_details/components/deployment_actions_spec.js +++ b/spec/frontend/environments/environment_details/components/deployment_actions_spec.js @@ -1,8 +1,15 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton } from '@gitlab/ui'; import DeploymentActions from '~/environments/environment_details/components/deployment_actions.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { translations } from '~/environments/environment_details/constants'; import ActionsComponent from '~/environments/components/environment_actions.vue'; describe('~/environments/environment_details/components/deployment_actions.vue', () => { + Vue.use(VueApollo); let wrapper; const actionsData = [ @@ -14,34 +21,116 @@ describe('~/environments/environment_details/components/deployment_actions.vue', }, ]; - const createWrapper = ({ actions }) => { + const rollbackData = { + id: '123', + name: 'enironment-name', + lastDeployment: { + commit: { + shortSha: 'abcd1234', + }, + isLast: true, + }, + retryUrl: 'deployment/retry', + }; + + const mockSetEnvironmentToRollback = jest.fn(); + const mockResolvers = { + Mutation: { + setEnvironmentToRollback: mockSetEnvironmentToRollback, + }, + }; + const createWrapper = ({ actions, rollback, approvalEnvironment }) => { + const mockApollo = createMockApollo([], mockResolvers); return mountExtended(DeploymentActions, { + apolloProvider: mockApollo, + provide: { + projectPath: 'fullProjectPath', + }, propsData: { actions, + rollback, + approvalEnvironment, }, }); }; - describe('when there is no actions provided', () => { - beforeEach(() => { - wrapper = createWrapper({ actions: [] }); + const findRollbackButton = () => wrapper.findComponent(GlButton); + + describe('deployment actions', () => { + describe('when there is no actions provided', () => { + beforeEach(() => { + wrapper = createWrapper({ actions: [] }); + }); + + it('should not render actions component', () => { + const actionsComponent = wrapper.findComponent(ActionsComponent); + expect(actionsComponent.exists()).toBe(false); + }); }); - it('should not render actions component', () => { - const actionsComponent = wrapper.findComponent(ActionsComponent); - expect(actionsComponent.exists()).toBe(false); + describe('when there are actions provided', () => { + beforeEach(() => { + wrapper = createWrapper({ actions: actionsData }); + }); + + it('should render actions component', () => { + const actionsComponent = wrapper.findComponent(ActionsComponent); + expect(actionsComponent.exists()).toBe(true); + expect(actionsComponent.props().actions).toBe(actionsData); + }); }); }); - describe('when there are actions provided', () => { - beforeEach(() => { - wrapper = createWrapper({ actions: actionsData }); + describe('rollback action', () => { + describe('when there is no rollback data available', () => { + it('should not show a rollback button', () => { + wrapper = createWrapper({ actions: [] }); + const button = findRollbackButton(); + expect(button.exists()).toBe(false); + }); }); - it('should render actions component', () => { - const actionsComponent = wrapper.findComponent(ActionsComponent); - expect(actionsComponent.exists()).toBe(true); - expect(actionsComponent.props().actions).toBe(actionsData); - }); + describe.each([ + { isLast: true, buttonTitle: translations.redeployButtonTitle, icon: 'repeat' }, + { isLast: false, buttonTitle: translations.rollbackButtonTitle, icon: 'redo' }, + ])( + `when there is a rollback data available and the deployment isLast=$isLast`, + ({ isLast, buttonTitle, icon }) => { + let rollback; + beforeEach(() => { + const lastDeployment = { ...rollbackData.lastDeployment, isLast }; + rollback = { ...rollbackData }; + rollback.lastDeployment = lastDeployment; + wrapper = createWrapper({ actions: [], rollback }); + }); + + it('should show the rollback button', () => { + const button = findRollbackButton(); + expect(button.exists()).toBe(true); + }); + + it(`the rollback button should have "${icon}" icon`, () => { + const button = findRollbackButton(); + expect(button.props().icon).toBe(icon); + }); + + it(`the rollback button should have "${buttonTitle}" title`, () => { + const button = findRollbackButton(); + expect(button.attributes().title).toBe(buttonTitle); + }); + + it(`the rollback button click should send correct mutation`, async () => { + const button = findRollbackButton(); + button.vm.$emit('click'); + await waitForPromises(); + expect(mockSetEnvironmentToRollback).toHaveBeenCalledWith( + expect.anything(), + { environment: rollback }, + expect.anything(), + expect.anything(), + ); + }); + }, + ); }); }); diff --git a/spec/frontend/environments/environment_details/page_spec.js b/spec/frontend/environments/environment_details/page_spec.js index 3a1a3238abe..ed7e0feb6ed 100644 --- a/spec/frontend/environments/environment_details/page_spec.js +++ b/spec/frontend/environments/environment_details/page_spec.js @@ -15,19 +15,40 @@ describe('~/environments/environment_details/page.vue', () => { let wrapper; + const emptyEnvironmentToRollbackData = { id: '', name: '', lastDeployment: null, retryUrl: '' }; + const environmentToRollbackMock = jest.fn(); + + const mockResolvers = { + Query: { + environmentToRollback: environmentToRollbackMock, + }, + }; + const defaultWrapperParameters = { resolvedData: resolvedEnvironmentDetails, + environmentToRollbackData: emptyEnvironmentToRollbackData, }; - const createWrapper = ({ resolvedData } = defaultWrapperParameters) => { - const mockApollo = createMockApollo([ - [getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedData)], - ]); + const createWrapper = ({ + resolvedData, + environmentToRollbackData, + } = defaultWrapperParameters) => { + const mockApollo = createMockApollo( + [[getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedData)]], + mockResolvers, + ); + environmentToRollbackMock.mockReturnValue( + environmentToRollbackData || emptyEnvironmentToRollbackData, + ); + const projectFullPath = 'gitlab-group/test-project'; return mountExtended(EnvironmentsDetailPage, { apolloProvider: mockApollo, + provide: { + projectPath: projectFullPath, + }, propsData: { - projectFullPath: 'gitlab-group/test-project', + projectFullPath, environmentName: 'test-environment-name', }, }); @@ -48,7 +69,7 @@ describe('~/environments/environment_details/page.vue', () => { wrapper = createWrapper(); await waitForPromises(); }); - it('should render a table when query is loaded', async () => { + it('should render a table when query is loaded', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).not.toBe(true); expect(wrapper.findComponent(GlTableLite).exists()).toBe(true); }); @@ -60,7 +81,7 @@ describe('~/environments/environment_details/page.vue', () => { await waitForPromises(); }); - it('should render empty state component', async () => { + it('should render empty state component', () => { expect(wrapper.findComponent(GlTableLite).exists()).toBe(false); expect(wrapper.findComponent(EmptyState).exists()).toBe(true); }); diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js index 279ff32a13d..4716f807657 100644 --- a/spec/frontend/environments/environment_folder_spec.js +++ b/spec/frontend/environments/environment_folder_spec.js @@ -38,7 +38,7 @@ describe('~/environments/components/environments_folder.vue', () => { provide: { helpPagePath: '/help', projectId: '1' }, }); - beforeEach(async () => { + beforeEach(() => { environmentFolderMock = jest.fn(); [nestedEnvironment] = resolvedEnvironmentsApp.environments; environmentFolderMock.mockReturnValue(resolvedFolder); diff --git a/spec/frontend/environments/environment_stop_spec.js b/spec/frontend/environments/environment_stop_spec.js index 851e24c22cc..3e27b8822e1 100644 --- a/spec/frontend/environments/environment_stop_spec.js +++ b/spec/frontend/environments/environment_stop_spec.js @@ -73,7 +73,7 @@ describe('Stop Component', () => { }); }); - it('should show a loading icon if the environment is currently stopping', async () => { + it('should show a loading icon if the environment is currently stopping', () => { expect(findButton().props('loading')).toBe(true); }); }); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index a843f801da5..6f2ee6f06cd 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -422,7 +422,7 @@ describe('~/environments/components/environments_app.vue', () => { ); }); - it('should sync search term from query params on load', async () => { + it('should sync search term from query params on load', () => { expect(searchBox.element.value).toBe('prod'); }); }); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index b5435990042..8d91ffe5ffc 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -801,6 +801,14 @@ export const resolvedDeploymentDetails = { export const agent = { project: 'agent-project', - id: '1', + id: 'gid://gitlab/ClusterAgent/1', name: 'agent-name', + kubernetesNamespace: 'agent-namespace', }; + +const runningPod = { status: { phase: 'Running' } }; +const pendingPod = { status: { phase: 'Pending' } }; +const succeededPod = { status: { phase: 'Succeeded' } }; +const failedPod = { status: { phase: 'Failed' } }; + +export const k8sPodsMock = [runningPod, runningPod, pendingPod, succeededPod, failedPod, failedPod]; diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js index 2c223d3a1a7..c66844f5f24 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { CoreV1Api } from '@gitlab/cluster-client'; import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -17,6 +18,7 @@ import { resolvedEnvironment, folder, resolvedFolder, + k8sPodsMock, } from './mock_data'; const ENDPOINT = `${TEST_HOST}/environments`; @@ -143,6 +145,61 @@ describe('~/frontend/environments/graphql/resolvers', () => { expect(environmentFolder).toEqual(resolvedFolder); }); }); + describe('k8sPods', () => { + const namespace = 'default'; + const configuration = { + basePath: 'kas-proxy/', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const mockPodsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: k8sPodsMock, + }, + }); + }); + + const mockNamespacedPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + const mockAllPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedPod') + .mockImplementation(mockNamespacedPodsListFn); + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockImplementation(mockAllPodsListFn); + }); + + it('should request namespaced pods from the cluster_client library if namespace is specified', async () => { + const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace }); + + expect(mockNamespacedPodsListFn).toHaveBeenCalledWith(namespace); + expect(mockAllPodsListFn).not.toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + it('should request all pods from the cluster_client library if namespace is not specified', async () => { + const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace: '' }); + + expect(mockAllPodsListFn).toHaveBeenCalled(); + expect(mockNamespacedPodsListFn).not.toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + it('should throw an error if the API call fails', async () => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect(mockResolvers.Query.k8sPods(null, { configuration })).rejects.toThrow( + 'API error', + ); + }); + }); describe('stopEnvironment', () => { it('should post to the stop environment path', async () => { mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK); diff --git a/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap b/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap index 326a28bd769..ec0fe0c5541 100644 --- a/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap +++ b/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap @@ -26,11 +26,37 @@ Object { }, "created": "2022-10-17T07:44:17Z", "deployed": "2022-10-17T07:44:43Z", + "deploymentApproval": Object { + "isApprovalActionAvailable": false, + }, "id": "31", "job": Object { "label": "deploy-prod (#860)", "webPath": "/gitlab-org/pipelinestest/-/jobs/860", }, + "rollback": Object { + "id": "gid://gitlab/Deployment/76", + "lastDeployment": Object { + "commit": Object { + "author": Object { + "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", + "id": "gid://gitlab/User/1", + "name": "Administrator", + "webUrl": "http://gdk.test:3000/root", + }, + "authorEmail": "admin@example.com", + "authorGravatar": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "authorName": "Administrator", + "id": "gid://gitlab/CommitPresenter/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74", + "message": "Update .gitlab-ci.yml file", + "shortId": "0cb48dd5", + "webUrl": "http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74", + }, + "isLast": false, + }, + "name": undefined, + "retryUrl": "/gitlab-org/pipelinestest/-/jobs/860/retry", + }, "status": "success", "triggerer": Object { "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", @@ -60,8 +86,12 @@ Object { }, "created": "2022-10-17T07:44:17Z", "deployed": "2022-10-17T07:44:43Z", + "deploymentApproval": Object { + "isApprovalActionAvailable": false, + }, "id": "31", "job": undefined, + "rollback": null, "status": "success", "triggerer": Object { "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", @@ -91,8 +121,12 @@ Object { }, "created": "2022-10-17T07:44:17Z", "deployed": "", + "deploymentApproval": Object { + "isApprovalActionAvailable": false, + }, "id": "31", "job": null, + "rollback": null, "status": "success", "triggerer": Object { "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js index 8673c657760..1912fd4a82b 100644 --- a/spec/frontend/environments/kubernetes_overview_spec.js +++ b/spec/frontend/environments/kubernetes_overview_spec.js @@ -1,19 +1,28 @@ import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; -import { GlCollapse, GlButton } from '@gitlab/ui'; +import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui'; import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue'; - -const agent = { - project: 'agent-project', - id: '1', - name: 'agent-name', -}; +import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; +import { agent } from './graphql/mock_data'; +import { mockKasTunnelUrl } from './mock_data'; const propsData = { agentId: agent.id, agentName: agent.name, agentProjectPath: agent.project, + namespace: agent.kubernetesNamespace, +}; + +const provide = { + kasTunnelUrl: mockKasTunnelUrl, +}; + +const configuration = { + basePath: provide.kasTunnelUrl.replace(/\/$/, ''), + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, }; describe('~/environments/components/kubernetes_overview.vue', () => { @@ -22,10 +31,13 @@ describe('~/environments/components/kubernetes_overview.vue', () => { const findCollapse = () => wrapper.findComponent(GlCollapse); const findCollapseButton = () => wrapper.findComponent(GlButton); const findAgentInfo = () => wrapper.findComponent(KubernetesAgentInfo); + const findKubernetesPods = () => wrapper.findComponent(KubernetesPods); + const findAlert = () => wrapper.findComponent(GlAlert); const createWrapper = () => { wrapper = shallowMount(KubernetesOverview, { propsData, + provide, }); }; @@ -57,6 +69,7 @@ describe('~/environments/components/kubernetes_overview.vue', () => { it("doesn't render components when the collapse is not visible", () => { expect(findAgentInfo().exists()).toBe(false); + expect(findKubernetesPods().exists()).toBe(false); }); it('opens on click', async () => { @@ -70,15 +83,40 @@ describe('~/environments/components/kubernetes_overview.vue', () => { }); describe('when section is expanded', () => { - it('renders kubernetes agent info', async () => { + beforeEach(() => { createWrapper(); - await toggleCollapse(); + toggleCollapse(); + }); + it('renders kubernetes agent info', () => { expect(findAgentInfo().props()).toEqual({ agentName: agent.name, agentId: agent.id, agentProjectPath: agent.project, }); }); + + it('renders kubernetes pods', () => { + expect(findKubernetesPods().props()).toEqual({ + namespace: agent.kubernetesNamespace, + configuration, + }); + }); + }); + + describe('on cluster error', () => { + beforeEach(() => { + createWrapper(); + toggleCollapse(); + }); + + it('shows alert with the error message', async () => { + const error = 'Error message from pods'; + + findKubernetesPods().vm.$emit('cluster-error', error); + await nextTick(); + + expect(findAlert().text()).toBe(error); + }); }); }); diff --git a/spec/frontend/environments/kubernetes_pods_spec.js b/spec/frontend/environments/kubernetes_pods_spec.js new file mode 100644 index 00000000000..137309d7853 --- /dev/null +++ b/spec/frontend/environments/kubernetes_pods_spec.js @@ -0,0 +1,114 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; +import { mockKasTunnelUrl } from './mock_data'; +import { k8sPodsMock } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/kubernetes_pods.vue', () => { + let wrapper; + + const namespace = 'my-kubernetes-namespace'; + const configuration = { + basePath: mockKasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAllStats = () => wrapper.findAllComponents(GlSingleStat); + const findSingleStat = (at) => findAllStats().at(at); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + k8sPods: jest.fn().mockReturnValue(k8sPodsMock), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + const createWrapper = (apolloProvider = createApolloProvider()) => { + wrapper = shallowMount(KubernetesPods, { + propsData: { namespace, configuration }, + apolloProvider, + }); + }; + + describe('mounted', () => { + it('shows the loading icon', () => { + createWrapper(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('hides the loading icon when the list of pods loaded', async () => { + createWrapper(); + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when gets pods data', () => { + it('renders stats', async () => { + createWrapper(); + await waitForPromises(); + + expect(findAllStats()).toHaveLength(4); + }); + + it.each` + count | title | index + ${2} | ${KubernetesPods.i18n.runningPods} | ${0} + ${1} | ${KubernetesPods.i18n.pendingPods} | ${1} + ${1} | ${KubernetesPods.i18n.succeededPods} | ${2} + ${2} | ${KubernetesPods.i18n.failedPods} | ${3} + `( + 'renders stat with title "$title" and count "$count" at index $index', + async ({ count, title, index }) => { + createWrapper(); + await waitForPromises(); + + expect(findSingleStat(index).props()).toMatchObject({ + value: count, + title, + }); + }, + ); + }); + + describe('when gets an error from the cluster_client API', () => { + const error = new Error('Error from the cluster_client API'); + const createErroredApolloProvider = () => { + const mockResolvers = { + Query: { + k8sPods: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper(createErroredApolloProvider()); + await waitForPromises(); + }); + + it("doesn't show pods stats", () => { + expect(findAllStats()).toHaveLength(0); + }); + + it('emits an error message', () => { + expect(wrapper.emitted('cluster-error')).toMatchObject([[error]]); + }); + }); +}); diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index a6d67c26304..bd2c6b7c892 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -313,6 +313,8 @@ const createEnvironment = (data = {}) => ({ ...data, }); +const mockKasTunnelUrl = 'https://kas.gitlab.com/k8s-proxy'; + export { environment, environmentsList, @@ -321,4 +323,5 @@ export { tableData, deployBoardMockData, createEnvironment, + mockKasTunnelUrl, }; diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index c04ff896794..b4f5263a151 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -7,10 +7,12 @@ import { stubTransition } from 'helpers/stub_transition'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import EnvironmentItem from '~/environments/components/new_environment_item.vue'; +import EnvironmentActions from '~/environments/components/environment_actions.vue'; import Deployment from '~/environments/components/deployment.vue'; import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue'; import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; import { resolvedEnvironment, rolloutStatus, agent } from './graphql/mock_data'; +import { mockKasTunnelUrl } from './mock_data'; Vue.use(VueApollo); @@ -25,11 +27,18 @@ describe('~/environments/components/new_environment_item.vue', () => { mountExtended(EnvironmentItem, { apolloProvider, propsData: { environment: resolvedEnvironment, ...propsData }, - provide: { helpPagePath: '/help', projectId: '1', projectPath: '/1', ...provideData }, + provide: { + helpPagePath: '/help', + projectId: '1', + projectPath: '/1', + kasTunnelUrl: mockKasTunnelUrl, + ...provideData, + }, stubs: { transition: stubTransition() }, }); const findDeployment = () => wrapper.findComponent(Deployment); + const findActions = () => wrapper.findComponent(EnvironmentActions); const findKubernetesOverview = () => wrapper.findComponent(KubernetesOverview); const expandCollapsedSection = async () => { @@ -124,9 +133,7 @@ describe('~/environments/components/new_environment_item.vue', () => { it('shows a dropdown if there are actions to perform', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const actions = wrapper.findByRole('button', { name: __('Deploy to...') }); - - expect(actions.exists()).toBe(true); + expect(findActions().exists()).toBe(true); }); it('does not show a dropdown if there are no actions to perform', () => { @@ -140,17 +147,15 @@ describe('~/environments/components/new_environment_item.vue', () => { }, }); - const actions = wrapper.findByRole('button', { name: __('Deploy to...') }); - - expect(actions.exists()).toBe(false); + expect(findActions().exists()).toBe(false); }); it('passes all the actions down to the action component', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); - const action = wrapper.findByRole('menuitem', { name: 'deploy-staging' }); - - expect(action.exists()).toBe(true); + expect(findActions().props('actions')).toMatchObject( + resolvedEnvironment.lastDeployment.manualActions, + ); }); }); @@ -382,6 +387,7 @@ describe('~/environments/components/new_environment_item.vue', () => { const button = await expandCollapsedSection(); expect(button.attributes('aria-label')).toBe(__('Collapse')); + expect(button.props('category')).toBe('secondary'); expect(collapse.attributes('visible')).toBe('visible'); expect(icon.props('name')).toBe('chevron-lg-down'); expect(environmentName.classes('gl-font-weight-bold')).toBe(true); @@ -537,6 +543,7 @@ describe('~/environments/components/new_environment_item.vue', () => { agentProjectPath: agent.project, agentName: agent.name, agentId: agent.id, + namespace: agent.kubernetesNamespace, }); }); diff --git a/spec/frontend/environments/stop_stale_environments_modal_spec.js b/spec/frontend/environments/stop_stale_environments_modal_spec.js index ddf6670db12..3d28ceba318 100644 --- a/spec/frontend/environments/stop_stale_environments_modal_spec.js +++ b/spec/frontend/environments/stop_stale_environments_modal_spec.js @@ -40,12 +40,12 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => { jest.resetAllMocks(); }); - it('sets the correct min and max dates', async () => { + it('sets the correct min and max dates', () => { expect(before.props().minDate.toISOString()).toBe(TEN_YEARS_AGO.toISOString()); expect(before.props().maxDate.toISOString()).toBe(ONE_WEEK_AGO.toISOString()); }); - it('requests cleanup when submit is clicked', async () => { + it('requests cleanup when submit is clicked', () => { mock.onPost().replyOnce(HTTP_STATUS_OK); wrapper.findComponent(GlModal).vm.$emit('primary'); const url = STOP_STALE_ENVIRONMENTS_PATH.replace(':id', 1).replace(':version', 'v4'); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index 31473899145..c9a2551d11c 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -98,12 +98,6 @@ describe('ErrorTrackingList', () => { }); }); - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - }); - describe('loading', () => { beforeEach(() => { store.state.list.loading = true; @@ -452,32 +446,34 @@ describe('ErrorTrackingList', () => { describe('When pagination is required', () => { describe('and previous cursor is not available', () => { - beforeEach(async () => { + beforeEach(() => { store.state.list.loading = false; delete store.state.list.pagination.previous; mountComponent(); }); - it('disables Prev button in the pagination', async () => { + it('disables Prev button in the pagination', () => { expect(findPagination().props('prevPage')).toBe(null); expect(findPagination().props('nextPage')).not.toBe(null); }); }); describe('and next cursor is not available', () => { - beforeEach(async () => { + beforeEach(() => { store.state.list.loading = false; delete store.state.list.pagination.next; mountComponent(); }); - it('disables Next button in the pagination', async () => { + it('disables Next button in the pagination', () => { expect(findPagination().props('prevPage')).not.toBe(null); expect(findPagination().props('nextPage')).toBe(null); }); }); describe('and the user is not on the first page', () => { describe('and the previous button is clicked', () => { - beforeEach(async () => { + const currentPage = 2; + + beforeEach(() => { store.state.list.loading = false; mountComponent({ stubs: { @@ -485,15 +481,12 @@ describe('ErrorTrackingList', () => { GlPagination: false, }, }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ pageValue: 2 }); - await nextTick(); + findPagination().vm.$emit('input', currentPage); }); it('fetches the previous page of results', () => { expect(wrapper.find('.prev-page-item').attributes('aria-disabled')).toBe(undefined); - wrapper.vm.goToPrevPage(); + findPagination().vm.$emit('input', currentPage - 1); expect(actions.fetchPaginatedResults).toHaveBeenCalled(); expect(actions.fetchPaginatedResults).toHaveBeenLastCalledWith( expect.anything(), diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js index b06e0340991..a12c25c6897 100644 --- a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js +++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js @@ -128,11 +128,11 @@ describe('Configure Feature Flags Modal', () => { expect(findSecondaryAction()).toBe(null); }); - it('should not display regenerating instance ID', async () => { + it('should not display regenerating instance ID', () => { expect(findDangerGlAlert().exists()).toBe(false); }); - it('should disable the project name input', async () => { + it('should disable the project name input', () => { expect(findProjectNameInput().exists()).toBe(false); }); }); @@ -142,7 +142,7 @@ describe('Configure Feature Flags Modal', () => { factory({ hasRotateError: true }); }); - it('should display an error', async () => { + it('should display an error', () => { expect(wrapper.findByTestId('rotate-error').exists()).toBe(true); expect(wrapper.find('[name="warning"]').exists()).toBe(true); }); @@ -151,7 +151,7 @@ describe('Configure Feature Flags Modal', () => { describe('is rotating', () => { beforeEach(factory.bind(null, { isRotating: true })); - it('should disable the project name input', async () => { + it('should disable the project name input', () => { expect(findProjectNameInput().attributes('disabled')).toBe('true'); }); }); diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js index 23e86d0eb2f..8492fe7bdde 100644 --- a/spec/frontend/feature_flags/components/feature_flags_spec.js +++ b/spec/frontend/feature_flags/components/feature_flags_spec.js @@ -94,7 +94,7 @@ describe('Feature flags', () => { await limitAlert().vm.$emit('dismiss'); }); - it('hides the alert', async () => { + it('hides the alert', () => { expect(limitAlert().exists()).toBe(false); }); @@ -176,7 +176,7 @@ describe('Feature flags', () => { emptyState = wrapper.findComponent(GlEmptyState); }); - it('should render the empty state', async () => { + it('should render the empty state', () => { expect(emptyState.exists()).toBe(true); }); diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js index 02ef813883f..8ddf8390431 100644 --- a/spec/frontend/filtered_search/dropdown_user_spec.js +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -1,4 +1,5 @@ -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlMergeRequestList from 'test_fixtures/merge_requests/merge_request_list.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import DropdownUser from '~/filtered_search/dropdown_user'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; @@ -71,13 +72,11 @@ describe('Dropdown User', () => { }); describe('hideCurrentUser', () => { - const fixtureTemplate = 'merge_requests/merge_request_list.html'; - let dropdown; let authorFilterDropdownElement; beforeEach(() => { - loadHTMLFixture(fixtureTemplate); + setHTMLFixture(htmlMergeRequestList); authorFilterDropdownElement = document.querySelector('#js-dropdown-author'); const dummyInput = document.createElement('div'); dropdown = new DropdownUser({ diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js index 2030b45b44c..d8a5b493b7a 100644 --- a/spec/frontend/filtered_search/dropdown_utils_spec.js +++ b/spec/frontend/filtered_search/dropdown_utils_spec.js @@ -1,12 +1,11 @@ -import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlMergeRequestList from 'test_fixtures/merge_requests/merge_request_list.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; describe('Dropdown Utils', () => { - const issuableListFixture = 'merge_requests/merge_request_list.html'; - describe('getEscapedText', () => { it('should return same word when it has no space', () => { const escaped = DropdownUtils.getEscapedText('textWithoutSpace'); @@ -355,7 +354,7 @@ describe('Dropdown Utils', () => { let authorToken; beforeEach(() => { - loadHTMLFixture(issuableListFixture); + setHTMLFixture(htmlMergeRequestList); authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); diff --git a/spec/frontend/fixtures/api_projects.rb b/spec/frontend/fixtures/api_projects.rb index d1dfd223419..24c47d8d139 100644 --- a/spec/frontend/fixtures/api_projects.rb +++ b/spec/frontend/fixtures/api_projects.rb @@ -6,10 +6,11 @@ RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do include ApiHelpers include JavaScriptFixturesHelpers - let(:namespace) { create(:namespace, name: 'gitlab-test') } - let(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } - let(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') } - let(:user) { project.owner } + let_it_be(:namespace) { create(:namespace, name: 'gitlab-test') } + let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } + let_it_be(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') } + let_it_be(:user) { project.owner } + let_it_be(:personal_projects) { create_list(:project, 3, namespace: user.namespace, topics: create_list(:topic, 5)) } it 'api/projects/get.json' do get api("/projects/#{project.id}", user) @@ -28,4 +29,10 @@ RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do expect(response).to be_successful end + + it 'api/users/projects/get.json' do + get api("/users/#{user.id}/projects", user) + + expect(response).to be_successful + end end diff --git a/spec/frontend/fixtures/saved_replies.rb b/spec/frontend/fixtures/comment_templates.rb index 613e4a1b447..32f425d7ebd 100644 --- a/spec/frontend/fixtures/saved_replies.rb +++ b/spec/frontend/fixtures/comment_templates.rb @@ -13,9 +13,9 @@ RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile d sign_in(current_user) end - context 'when user has no saved replies' do - base_input_path = 'saved_replies/queries/' - base_output_path = 'graphql/saved_replies/' + context 'when user has no comment templates' do + base_input_path = 'comment_templates/queries/' + base_output_path = 'graphql/comment_templates/' query_name = 'saved_replies.query.graphql' it "#{base_output_path}saved_replies_empty.query.graphql.json" do @@ -27,9 +27,9 @@ RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile d end end - context 'when user has saved replies' do - base_input_path = 'saved_replies/queries/' - base_output_path = 'graphql/saved_replies/' + context 'when user has comment templates' do + base_input_path = 'comment_templates/queries/' + base_output_path = 'graphql/comment_templates/' query_name = 'saved_replies.query.graphql' it "#{base_output_path}saved_replies.query.graphql.json" do @@ -44,9 +44,9 @@ RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile d end end - context 'when user creates saved reply' do - base_input_path = 'saved_replies/queries/' - base_output_path = 'graphql/saved_replies/' + context 'when user creates comment template' do + base_input_path = 'comment_templates/queries/' + base_output_path = 'graphql/comment_templates/' query_name = 'create_saved_reply.mutation.graphql' it "#{base_output_path}#{query_name}.json" do @@ -58,9 +58,9 @@ RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile d end end - context 'when user creates saved reply and it errors' do - base_input_path = 'saved_replies/queries/' - base_output_path = 'graphql/saved_replies/' + context 'when user creates comment template and it errors' do + base_input_path = 'comment_templates/queries/' + base_output_path = 'graphql/comment_templates/' query_name = 'create_saved_reply.mutation.graphql' it "#{base_output_path}create_saved_reply_with_errors.mutation.graphql.json" do diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index 1e6baf30a76..e85e683b599 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -20,15 +20,6 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', :with_licens remove_repository(project) end - it 'issues/new-issue.html' do - get :new, params: { - namespace_id: project.namespace.to_param, - project_id: project - } - - expect(response).to be_successful - end - it 'issues/open-issue.html' do render_issue(create(:issue, project: project)) end diff --git a/spec/frontend/fixtures/job_artifacts.rb b/spec/frontend/fixtures/job_artifacts.rb index e53cdbbaaa5..6dadd6750f1 100644 --- a/spec/frontend/fixtures/job_artifacts.rb +++ b/spec/frontend/fixtures/job_artifacts.rb @@ -12,7 +12,7 @@ RSpec.describe 'Job Artifacts (GraphQL fixtures)' do let_it_be(:pipeline) { create(:ci_pipeline, project: project) } let_it_be(:user) { create(:user) } - job_artifacts_query_path = 'artifacts/graphql/queries/get_job_artifacts.query.graphql' + job_artifacts_query_path = 'ci/artifacts/graphql/queries/get_job_artifacts.query.graphql' it "graphql/#{job_artifacts_query_path}.json" do create(:ci_build, :failed, :artifacts, :trace_artifact, pipeline: pipeline) diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb index 3583beb83c2..376c04cd629 100644 --- a/spec/frontend/fixtures/jobs.rb +++ b/spec/frontend/fixtures/jobs.rb @@ -48,49 +48,49 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do let!(:with_artifact) { create(:ci_build, :success, name: 'with_artifact', job_artifacts: [artifact], pipeline: pipeline) } let!(:with_coverage) { create(:ci_build, :success, name: 'with_coverage', coverage: 40.0, pipeline: pipeline) } - fixtures_path = 'graphql/jobs/' - get_jobs_query = 'get_jobs.query.graphql' - full_path = 'frontend-fixtures/builds-project' + shared_examples 'graphql queries' do |path, jobs_query| + let_it_be(:variables) { {} } - let_it_be(:query) do - get_graphql_query_as_string("jobs/components/table/graphql/queries/#{get_jobs_query}") - end + let_it_be(:query) do + get_graphql_query_as_string("#{path}/#{jobs_query}") + end - it "#{fixtures_path}#{get_jobs_query}.json" do - post_graphql(query, current_user: user, variables: { - fullPath: full_path - }) + fixtures_path = 'graphql/jobs/' - expect_graphql_errors_to_be_empty - end + it "#{fixtures_path}#{jobs_query}.json" do + post_graphql(query, current_user: user, variables: variables) - it "#{fixtures_path}#{get_jobs_query}.as_guest.json" do - guest = create(:user) - project.add_guest(guest) + expect_graphql_errors_to_be_empty + end - post_graphql(query, current_user: guest, variables: { - fullPath: full_path - }) + it "#{fixtures_path}#{jobs_query}.as_guest.json" do + guest = create(:user) + project.add_guest(guest) - expect_graphql_errors_to_be_empty - end + post_graphql(query, current_user: guest, variables: variables) - it "#{fixtures_path}#{get_jobs_query}.paginated.json" do - post_graphql(query, current_user: user, variables: { - fullPath: full_path, - first: 2 - }) + expect_graphql_errors_to_be_empty + end - expect_graphql_errors_to_be_empty + it "#{fixtures_path}#{jobs_query}.paginated.json" do + post_graphql(query, current_user: user, variables: variables.merge({ first: 2 })) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{jobs_query}.empty.json" do + post_graphql(query, current_user: user, variables: variables.merge({ first: 0 })) + + expect_graphql_errors_to_be_empty + end end - it "#{fixtures_path}#{get_jobs_query}.empty.json" do - post_graphql(query, current_user: user, variables: { - fullPath: full_path, - first: 0 - }) + it_behaves_like 'graphql queries', 'jobs/components/table/graphql/queries', 'get_jobs.query.graphql' do + let(:variables) { { fullPath: 'frontend-fixtures/builds-project' } } + end - expect_graphql_errors_to_be_empty + it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_all_jobs.query.graphql' do + let(:user) { create(:admin) } end end diff --git a/spec/frontend/fixtures/milestones.rb b/spec/frontend/fixtures/milestones.rb new file mode 100644 index 00000000000..5e39dcf190a --- /dev/null +++ b/spec/frontend/fixtures/milestones.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::MilestonesController, '(JavaScript fixtures)', :with_license, feature_category: :team_planning, type: :controller do + include JavaScriptFixturesHelpers + + let_it_be(:user) { create(:user, feed_token: 'feedtoken:coldfeed') } + let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') } + let_it_be(:project) { create(:project_empty_repo, namespace: namespace, path: 'milestones-project') } + + render_views + + before do + project.add_maintainer(user) + sign_in(user) + end + + after do + remove_repository(project) + end + + it 'milestones/new-milestone.html' do + get :new, params: { + namespace_id: project.namespace.to_param, + project_id: project + } + + expect(response).to be_successful + end + + private + + def render_milestone(milestone) + get :show, params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: milestone.to_param + } + + expect(response).to be_successful + end +end diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index 768934d6278..24a6f6f7de6 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do + include ApiHelpers + include GraphqlHelpers include JavaScriptFixturesHelpers let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') } @@ -56,4 +58,27 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co expect(response).to be_successful end + + describe GraphQL::Query, type: :request do + fixtures_path = 'graphql/pipelines/' + get_pipeline_actions_query = 'get_pipeline_actions.query.graphql' + + let!(:pipeline_with_manual_actions) { create(:ci_pipeline, project: project, user: user) } + let!(:build_scheduled) { create(:ci_build, :scheduled, pipeline: pipeline_with_manual_actions, stage: 'test') } + let!(:build_manual) { create(:ci_build, :manual, pipeline: pipeline_with_manual_actions, stage: 'build') } + let!(:build_manual_cannot_play) do + create(:ci_build, :manual, :skipped, pipeline: pipeline_with_manual_actions, stage: 'build') + end + + let_it_be(:query) do + get_graphql_query_as_string("pipelines/graphql/queries/#{get_pipeline_actions_query}") + end + + it "#{fixtures_path}#{get_pipeline_actions_query}.json" do + post_graphql(query, current_user: user, + variables: { fullPath: project.full_path, iid: pipeline_with_manual_actions.iid }) + + expect_graphql_errors_to_be_empty + end + end end diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index 2ccf2c0392f..8cd651c5b36 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -67,7 +67,7 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do end end - describe 'Storage', feature_category: :subscription_cost_management do + describe 'Storage', feature_category: :consumables_cost_management do describe GraphQL::Query, type: :request do include GraphqlHelpers context 'project storage statistics query' do diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index 1581bc58289..099df607487 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Runner (JavaScript fixtures)' do +RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :runner_fleet do include AdminModeHelper include ApiHelpers include JavaScriptFixturesHelpers @@ -13,7 +13,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do let_it_be(:project) { create(:project, :repository, :public) } let_it_be(:project_2) { create(:project, :repository, :public) } - let_it_be(:runner) { create(:ci_runner, :instance, description: 'My Runner', version: '1.0.0') } + let_it_be(:runner) { create(:ci_runner, :instance, description: 'My Runner', creator: admin, version: '1.0.0') } let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], version: '2.0.0') } let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], version: '2.0.0') } let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project, project_2], version: '2.0.0') } @@ -58,6 +58,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do expect_graphql_errors_to_be_empty end + + it "#{fixtures_path}#{all_runners_query}.with_creator.json" do + # "last: 1" fetches the first runner created, with admin as "creator" + post_graphql(query, current_user: admin, variables: { last: 1 }) + + expect_graphql_errors_to_be_empty + end end describe 'all_runners_count.query.graphql', type: :request do @@ -169,14 +176,17 @@ RSpec.describe 'Runner (JavaScript fixtures)' do get_graphql_query_as_string("#{query_path}#{runner_create_mutation}") end - it "#{fixtures_path}#{runner_create_mutation}.json" do - post_graphql(query, current_user: admin, variables: { - input: { - description: 'My dummy runner' - } - }) + context 'with runnerType set to INSTANCE_TYPE' do + it "#{fixtures_path}#{runner_create_mutation}.json" do + post_graphql(query, current_user: admin, variables: { + input: { + runnerType: 'INSTANCE_TYPE', + description: 'My dummy runner' + } + }) - expect_graphql_errors_to_be_empty + expect_graphql_errors_to_be_empty + end end end end diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index 18a4aa58c00..5b09e1c9495 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -40,21 +40,6 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do expect(response).to be_successful end - # This Feature Flag is on by default - # This ensures that the correct css is generated - # When the feature flag is on, the general startup will capture it - # This will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/339348 - it "startup_css/project-#{type}-search-ff-off.html" do - stub_feature_flags(new_header_search: false) - - get :show, params: { - namespace_id: project.namespace.to_param, - id: project - } - - expect(response).to be_successful - end - # This Feature Flag is off by default # This ensures that the correct css is generated for super sidebar # When the feature flag is off, the general startup will capture it diff --git a/spec/frontend/fixtures/static/oauth_remember_me.html b/spec/frontend/fixtures/static/oauth_remember_me.html index 0b4d482925d..60277ecf66e 100644 --- a/spec/frontend/fixtures/static/oauth_remember_me.html +++ b/spec/frontend/fixtures/static/oauth_remember_me.html @@ -1,5 +1,5 @@ <div id="oauth-container"> - <input id="remember_me" type="checkbox" /> + <input id="remember_me_omniauth" type="checkbox" /> <form method="post" action="http://example.com/"> <button class="js-oauth-login twitter" type="submit"> diff --git a/spec/frontend/fixtures/static/search_autocomplete.html b/spec/frontend/fixtures/static/search_autocomplete.html deleted file mode 100644 index 29db9020424..00000000000 --- a/spec/frontend/fixtures/static/search_autocomplete.html +++ /dev/null @@ -1,15 +0,0 @@ -<div class="search search-form"> -<form class="form-inline"> -<div class="search-input-container"> -<div class="search-input-wrap"> -<div class="dropdown"> -<input class="search-input dropdown-menu-toggle" id="search"> -<div class="dropdown-menu dropdown-select"> -<div class="dropdown-content"></div> -</div> -</div> -</div> -</div> -<input class="js-search-project-options" type="hidden"> -</form> -</div> diff --git a/spec/frontend/fixtures/timelogs.rb b/spec/frontend/fixtures/timelogs.rb new file mode 100644 index 00000000000..c66e2447ea6 --- /dev/null +++ b/spec/frontend/fixtures/timelogs.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Timelogs (GraphQL fixtures)', feature_category: :team_planning do + describe GraphQL::Query, type: :request do + include ApiHelpers + include GraphqlHelpers + include JavaScriptFixturesHelpers + + let_it_be(:guest) { create(:user) } + let_it_be(:developer) { create(:user) } + + context 'for time tracking timelogs' do + let_it_be(:project) { create(:project_empty_repo, :public) } + let_it_be(:issue) { create(:issue, project: project) } + + let(:query_path) { 'time_tracking/components/queries/get_timelogs.query.graphql' } + let(:query) { get_graphql_query_as_string(query_path) } + + before_all do + project.add_guest(guest) + project.add_developer(developer) + end + + it "graphql/get_timelogs_empty_response.json" do + post_graphql(query, current_user: guest, variables: { username: guest.username }) + + expect_graphql_errors_to_be_empty + end + + context 'with 20 or less timelogs' do + let_it_be(:timelogs) { create_list(:timelog, 6, user: developer, issue: issue, time_spent: 4 * 60 * 60) } + + it "graphql/get_non_paginated_timelogs_response.json" do + post_graphql(query, current_user: guest, variables: { username: developer.username }) + + expect_graphql_errors_to_be_empty + end + end + + context 'with more than 20 timelogs' do + let_it_be(:timelogs) { create_list(:timelog, 30, user: developer, issue: issue, time_spent: 4 * 60 * 60) } + + it "graphql/get_paginated_timelogs_response.json" do + post_graphql(query, current_user: guest, variables: { username: developer.username, first: 25 }) + + expect_graphql_errors_to_be_empty + end + end + end + end +end diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js index 4f5788dcb77..a8ae72eb4b3 100644 --- a/spec/frontend/frequent_items/components/app_spec.js +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -33,7 +33,6 @@ describe('Frequent Items App Component', () => { const createComponent = (props = {}) => { const session = currentSession[TEST_NAMESPACE]; gon.api_version = session.apiVersion; - gon.features = { fullPathProjectSearch: true }; wrapper = mountExtended(App, { store, diff --git a/spec/frontend/frequent_items/components/frequent_items_list_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_spec.js index 87f8e131b77..dd6dd80af4f 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_spec.js @@ -48,7 +48,7 @@ describe('FrequentItemsListComponent', () => { }); describe('fetched item messages', () => { - it('should show default empty list message', async () => { + it('should show default empty list message', () => { createComponent({ items: [], }); diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js index c228bca4973..2feb488da2c 100644 --- a/spec/frontend/frequent_items/store/actions_spec.js +++ b/spec/frontend/frequent_items/store/actions_spec.js @@ -25,7 +25,6 @@ describe('Frequent Items Dropdown Store Actions', () => { mockedState.namespace = mockNamespace; mockedState.storageKey = mockStorageKey; - gon.features = { fullPathProjectSearch: true }; }); afterEach(() => { diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index e4fd8649263..73284fbe5e5 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -666,10 +666,11 @@ describe('GfmAutoComplete', () => { username: 'my-group', title: '', icon: '<i class="icon"/>', - availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>', + availabilityStatus: + '<span class="badge badge-warning badge-pill gl-badge sm gl-ml-2">Busy</span>', }), ).toBe( - '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>', + '<li>IMG my-group <small><span class="badge badge-warning badge-pill gl-badge sm gl-ml-2">Busy</span></small> <i class="icon"/></li>', ); }); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 98868de8475..7b42e50fee5 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -112,7 +112,7 @@ describe('AppComponent', () => { }); }); - it('should show alert error when request fails', () => { + it('should show an alert when request fails', () => { mock.onGet('/dashboard/groups.json').reply(HTTP_STATUS_BAD_REQUEST); jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); @@ -320,7 +320,7 @@ describe('AppComponent', () => { }); }); - it('should show error alert message if request failed to leave group', () => { + it('should show error alert if request failed to leave group', () => { const message = 'An error occurred. Please try again.'; jest .spyOn(vm.service, 'leaveGroup') @@ -337,7 +337,7 @@ describe('AppComponent', () => { }); }); - it('should show appropriate error alert message if request forbids to leave group', () => { + it('shows appropriate error alert if request forbids to leave group', () => { const message = 'Failed to leave the group. Please make sure you are not the only owner.'; jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: HTTP_STATUS_FORBIDDEN }); jest.spyOn(vm.store, 'removeGroup'); diff --git a/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js index be61ffa92b4..bb3c0bc1526 100644 --- a/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js +++ b/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js @@ -6,7 +6,7 @@ import ArchivedProjectsEmptyState from '~/groups/components/empty_states/archive let wrapper; const defaultProvide = { - newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg', + emptyProjectsIllustration: '/assets/llustrations/empty-state/empty-projects-md.svg', }; const createComponent = () => { @@ -21,7 +21,7 @@ describe('ArchivedProjectsEmptyState', () => { expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ title: ArchivedProjectsEmptyState.i18n.title, - svgPath: defaultProvide.newProjectIllustration, + svgPath: defaultProvide.emptyProjectsIllustration, }); }); }); diff --git a/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js index c4ace1be1f3..8ba1c480d5e 100644 --- a/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js +++ b/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js @@ -6,7 +6,7 @@ import SharedProjectsEmptyState from '~/groups/components/empty_states/shared_pr let wrapper; const defaultProvide = { - newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg', + emptyProjectsIllustration: '/assets/illustrations/empty-state/empty-projects-md.svg', }; const createComponent = () => { @@ -21,7 +21,7 @@ describe('SharedProjectsEmptyState', () => { expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ title: SharedProjectsEmptyState.i18n.title, - svgPath: defaultProvide.newProjectIllustration, + svgPath: defaultProvide.emptyProjectsIllustration, }); }); }); diff --git a/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js index dc4271b98ee..5ae4d0be7d6 100644 --- a/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js +++ b/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js @@ -10,6 +10,7 @@ const defaultProvide = { newProjectPath: '/projects/new?namespace_id=231', newSubgroupIllustration: '/assets/illustrations/group-new.svg', newSubgroupPath: '/groups/new?parent_id=231', + emptyProjectsIllustration: '/assets/illustrations/empty-state/empty-projects-md.svg', emptySubgroupIllustration: '/assets/illustrations/empty-state/empty-subgroup-md.svg', canCreateSubgroups: true, canCreateProjects: true, diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 9ee785d688a..c04eaa501ba 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -32,7 +32,7 @@ describe('GroupsComponent', () => { const findPaginationLinks = () => wrapper.findComponent(PaginationLinks); - beforeEach(async () => { + beforeEach(() => { Vue.component('GroupFolder', GroupFolderComponent); Vue.component('GroupItem', GroupItemComponent); }); diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js index 906609c97f9..101dd06d578 100644 --- a/spec/frontend/groups/components/overview_tabs_spec.js +++ b/spec/frontend/groups/components/overview_tabs_spec.js @@ -39,6 +39,7 @@ describe('OverviewTabs', () => { newProjectPath: 'projects/new', newSubgroupIllustration: '', newProjectIllustration: '', + emptyProjectsIllustration: '', emptySubgroupIllustration: '', canCreateSubgroups: false, canCreateProjects: false, diff --git a/spec/frontend/groups/components/transfer_group_form_spec.js b/spec/frontend/groups/components/transfer_group_form_spec.js index fd0c3907e04..4d4de1ae3d5 100644 --- a/spec/frontend/groups/components/transfer_group_form_spec.js +++ b/spec/frontend/groups/components/transfer_group_form_spec.js @@ -69,7 +69,7 @@ describe('Transfer group form', () => { expect(findHiddenInput().attributes('value')).toBeUndefined(); }); - it('does not render the alert message', () => { + it('does not render the alert', () => { expect(findAlert().exists()).toBe(false); }); diff --git a/spec/frontend/groups/settings/components/group_settings_readme_spec.js b/spec/frontend/groups/settings/components/group_settings_readme_spec.js new file mode 100644 index 00000000000..8d4da73934f --- /dev/null +++ b/spec/frontend/groups/settings/components/group_settings_readme_spec.js @@ -0,0 +1,112 @@ +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GroupSettingsReadme from '~/groups/settings/components/group_settings_readme.vue'; +import { GITLAB_README_PROJECT } from '~/groups/settings/constants'; +import { + MOCK_GROUP_PATH, + MOCK_GROUP_ID, + MOCK_PATH_TO_GROUP_README, + MOCK_PATH_TO_README_PROJECT, +} from '../mock_data'; + +describe('GroupSettingsReadme', () => { + let wrapper; + + const defaultProps = { + groupPath: MOCK_GROUP_PATH, + groupId: MOCK_GROUP_ID, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(GroupSettingsReadme, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlModal, + GlSprintf, + }, + }); + }; + + const findHasReadmeButtonLink = () => wrapper.findByText('README'); + const findAddReadmeButton = () => wrapper.findByTestId('group-settings-add-readme-button'); + const findModalBody = () => wrapper.findByTestId('group-settings-modal-readme-body'); + const findModalCreateReadmeButton = () => + wrapper.findByTestId('group-settings-modal-create-readme-button'); + + describe('Group has existing README', () => { + beforeEach(() => { + createComponent({ + groupReadmePath: MOCK_PATH_TO_GROUP_README, + readmeProjectPath: MOCK_PATH_TO_README_PROJECT, + }); + }); + + describe('template', () => { + it('renders README Button Link with correct path and text', () => { + expect(findHasReadmeButtonLink().exists()).toBe(true); + expect(findHasReadmeButtonLink().attributes('href')).toBe(MOCK_PATH_TO_GROUP_README); + }); + + it('does not render Add README Button', () => { + expect(findAddReadmeButton().exists()).toBe(false); + }); + }); + }); + + describe('Group has README project without README file', () => { + beforeEach(() => { + createComponent({ readmeProjectPath: MOCK_PATH_TO_README_PROJECT }); + }); + + describe('template', () => { + it('does not render README', () => { + expect(findHasReadmeButtonLink().exists()).toBe(false); + }); + + it('does render Add Readme Button with correct text', () => { + expect(findAddReadmeButton().exists()).toBe(true); + expect(findAddReadmeButton().text()).toBe('Add README'); + }); + + it('generates a hidden modal with correct body text', () => { + expect(findModalBody().text()).toMatchInterpolatedText( + `This will create a README.md for project ${MOCK_PATH_TO_README_PROJECT}.`, + ); + }); + + it('generates a hidden modal with correct button text', () => { + expect(findModalCreateReadmeButton().text()).toBe('Add README'); + }); + }); + }); + + describe('Group does not have README project', () => { + beforeEach(() => { + createComponent(); + }); + + describe('template', () => { + it('does not render README', () => { + expect(findHasReadmeButtonLink().exists()).toBe(false); + }); + + it('does render Add Readme Button with correct text', () => { + expect(findAddReadmeButton().exists()).toBe(true); + expect(findAddReadmeButton().text()).toBe('Add README'); + }); + + it('generates a hidden modal with correct body text', () => { + expect(findModalBody().text()).toMatchInterpolatedText( + `This will create a project ${MOCK_GROUP_PATH}/${GITLAB_README_PROJECT} and add a README.md.`, + ); + }); + + it('generates a hidden modal with correct button text', () => { + expect(findModalCreateReadmeButton().text()).toBe('Create and add README'); + }); + }); + }); +}); diff --git a/spec/frontend/groups/settings/mock_data.js b/spec/frontend/groups/settings/mock_data.js new file mode 100644 index 00000000000..4551ee3318b --- /dev/null +++ b/spec/frontend/groups/settings/mock_data.js @@ -0,0 +1,6 @@ +export const MOCK_GROUP_PATH = 'test-group'; +export const MOCK_GROUP_ID = '999'; + +export const MOCK_PATH_TO_GROUP_README = '/group/project/-/blob/main/README.md'; + +export const MOCK_PATH_TO_README_PROJECT = 'group/project'; diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index 8e84c672d90..ad56b2dde24 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -20,6 +20,7 @@ import { IS_NOT_FOCUSED, IS_FOCUSED, SEARCH_SHORTCUTS_MIN_CHARACTERS, + DROPDOWN_CLOSE_TIMEOUT, } from '~/header_search/constants'; import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; import { ENTER_KEY } from '~/lib/utils/keys'; @@ -43,6 +44,9 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('HeaderSearchApp', () => { let wrapper; + jest.useFakeTimers(); + jest.spyOn(global, 'setTimeout'); + const actionSpies = { setSearch: jest.fn(), fetchAutocompleteOptions: jest.fn(), @@ -131,7 +135,7 @@ describe('HeaderSearchApp', () => { beforeEach(() => { window.gon.current_username = username; createComponent(); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); + findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : ''); }); it(`should${showSearchDropdown ? '' : ' not'} render`, () => { @@ -153,7 +157,7 @@ describe('HeaderSearchApp', () => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent({ search }, {}); - findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focusin'); }); it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { @@ -174,6 +178,7 @@ describe('HeaderSearchApp', () => { it(`should close the dropdown when press escape key`, async () => { findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 })); + jest.runAllTimers(); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(false); expect(wrapper.emitted().expandSearchBar.length).toBe(1); @@ -192,7 +197,7 @@ describe('HeaderSearchApp', () => { beforeEach(() => { window.gon.current_username = username; createComponent(); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); + findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : ''); }); it(`sets description to ${expectedDesc}`, () => { @@ -224,7 +229,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); + findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : ''); }); it(`sets description to ${expectedDesc}`, () => { @@ -253,7 +258,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focusin'); }); it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${ @@ -287,7 +292,7 @@ describe('HeaderSearchApp', () => { window.gon.current_username = MOCK_USERNAME; createComponent({ search, searchContext }, { searchOptions: () => searchOptions }); if (isFocused) { - findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focusin'); } }); @@ -328,7 +333,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focusin'); }); it(`icon for data set type "${searchOptions[0]?.html_id}" ${ @@ -349,12 +354,12 @@ describe('HeaderSearchApp', () => { }); describe('events', () => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent(); - }); - describe('Header Search Input', () => { + beforeEach(() => { + window.gon.current_username = MOCK_USERNAME; + createComponent(); + }); + describe('when dropdown is closed', () => { let trackingSpy; @@ -362,9 +367,9 @@ describe('HeaderSearchApp', () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); - it('onFocus opens dropdown and triggers snowplow event', async () => { + it('onFocusin opens dropdown and triggers snowplow event', async () => { expect(findHeaderSearchDropdown().exists()).toBe(false); - findHeaderSearchInput().vm.$emit('focus'); + findHeaderSearchInput().vm.$emit('focusin'); await nextTick(); @@ -375,25 +380,19 @@ describe('HeaderSearchApp', () => { }); }); - it('onClick opens dropdown and triggers snowplow event', async () => { + it('onFocusout closes dropdown and triggers snowplow event', async () => { expect(findHeaderSearchDropdown().exists()).toBe(false); - findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focusout'); + jest.runAllTimers(); await nextTick(); - expect(findHeaderSearchDropdown().exists()).toBe(true); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { + expect(findHeaderSearchDropdown().exists()).toBe(false); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'blur_input', { label: 'global_search', property: 'navigation_top', }); }); - - it('onClick followed by onFocus only triggers a single snowplow event', async () => { - findHeaderSearchInput().vm.$emit('click'); - findHeaderSearchInput().vm.$emit('focus'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - }); }); describe('onInput', () => { @@ -435,18 +434,18 @@ describe('HeaderSearchApp', () => { }); }); - describe('Dropdown Keyboard Navigation', () => { + describe('onFocusout dropdown', () => { beforeEach(() => { - findHeaderSearchInput().vm.$emit('click'); + window.gon.current_username = MOCK_USERNAME; + createComponent({ search: 'tes' }, {}); + findHeaderSearchInput().vm.$emit('focusin'); }); - it('closes dropdown when @tab is emitted', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(true); - findDropdownKeyboardNavigation().vm.$emit('tab'); - - await nextTick(); + it('closes with timeout so click event gets emited', () => { + findHeaderSearchInput().vm.$emit('focusout'); - expect(findHeaderSearchDropdown().exists()).toBe(false); + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), DROPDOWN_CLOSE_TIMEOUT); }); }); }); @@ -461,7 +460,7 @@ describe('HeaderSearchApp', () => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent({ search }); - findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focusin'); }); it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => { @@ -502,10 +501,11 @@ describe('HeaderSearchApp', () => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent(); - findHeaderSearchInput().vm.$emit('click'); + findHeaderSearchInput().vm.$emit('focusin'); }); - it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => { + it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => { + await nextTick(); findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js index 40c1843d461..9ccc6919b81 100644 --- a/spec/frontend/header_search/init_spec.js +++ b/spec/frontend/header_search/init_spec.js @@ -33,7 +33,6 @@ describe('Header Search EventListener', () => { jest.mock('~/header_search', () => ({ initHeaderSearchApp: jest.fn() })); await eventHandler.apply( { - newHeaderSearchFeatureFlag: true, searchInputBox: document.querySelector('#search'), }, [cleanEventListeners], @@ -47,7 +46,6 @@ describe('Header Search EventListener', () => { jest.mock('~/header_search', () => ({ initHeaderSearchApp: mockVueApp })); await eventHandler.apply( { - newHeaderSearchFeatureFlag: true, searchInputBox: document.querySelector('#search'), }, () => {}, @@ -55,20 +53,4 @@ describe('Header Search EventListener', () => { expect(mockVueApp).toHaveBeenCalled(); }); - - it('attaches old vue dropdown when feature flag is disabled', async () => { - const mockLegacyApp = jest.fn(() => ({ - onSearchInputFocus: jest.fn(), - })); - jest.mock('~/search_autocomplete', () => mockLegacyApp); - await eventHandler.apply( - { - newHeaderSearchFeatureFlag: false, - searchInputBox: document.querySelector('#search'), - }, - () => {}, - ); - - expect(mockLegacyApp).toHaveBeenCalled(); - }); }); diff --git a/spec/frontend/helpers/init_simple_app_helper_spec.js b/spec/frontend/helpers/init_simple_app_helper_spec.js index 8dd3745e0ac..7938e3851d0 100644 --- a/spec/frontend/helpers/init_simple_app_helper_spec.js +++ b/spec/frontend/helpers/init_simple_app_helper_spec.js @@ -38,19 +38,19 @@ describe('helpers/init_simple_app_helper/initSimpleApp', () => { resetHTMLFixture(); }); - it('mounts the component if the selector exists', async () => { + it('mounts the component if the selector exists', () => { initMock('<div id="mount-here"></div>'); expect(findMock().exists()).toBe(true); }); - it('does not mount the component if selector does not exist', async () => { + it('does not mount the component if selector does not exist', () => { initMock('<div id="do-not-mount-here"></div>'); expect(didCreateApp()).toBe(false); }); - it('passes the prop to the component if the prop exists', async () => { + it('passes the prop to the component if the prop exists', () => { initMock(`<div id="mount-here" data-view-model={"someKey":"thing","count":123}></div>`); expect(findMock().props()).toEqual({ diff --git a/spec/frontend/ide/components/activity_bar_spec.js b/spec/frontend/ide/components/activity_bar_spec.js index ff04f9a84f1..95582aca8fd 100644 --- a/spec/frontend/ide/components/activity_bar_spec.js +++ b/spec/frontend/ide/components/activity_bar_spec.js @@ -1,14 +1,20 @@ +import { nextTick } from 'vue'; import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ActivityBar from '~/ide/components/activity_bar.vue'; import { leftSidebarViews } from '~/ide/constants'; import { createStore } from '~/ide/stores'; +const { edit, ...VIEW_OBJECTS_WITHOUT_EDIT } = leftSidebarViews; +const MODES_WITHOUT_EDIT = Object.keys(VIEW_OBJECTS_WITHOUT_EDIT); +const MODES = Object.keys(leftSidebarViews); + describe('IDE ActivityBar component', () => { let wrapper; let store; const findChangesBadge = () => wrapper.findComponent(GlBadge); + const findModeButton = (mode) => wrapper.findByTestId(`${mode}-mode-button`); const mountComponent = (state) => { store = createStore(); @@ -19,45 +25,43 @@ describe('IDE ActivityBar component', () => { ...state, }); - wrapper = shallowMount(ActivityBar, { store }); + wrapper = shallowMountExtended(ActivityBar, { store }); }; - describe('updateActivityBarView', () => { - beforeEach(() => { - mountComponent(); - jest.spyOn(wrapper.vm, 'updateActivityBarView').mockImplementation(() => {}); - }); + describe('active item', () => { + // Test that mode button does not have 'active' class before click, + // and does have 'active' class after click + const testSettingActiveItem = async (mode) => { + const button = findModeButton(mode); - it('calls updateActivityBarView with edit value on click', () => { - wrapper.find('.js-ide-edit-mode').trigger('click'); + expect(button.classes('active')).toBe(false); - expect(wrapper.vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.edit.name); - }); + button.trigger('click'); + await nextTick(); - it('calls updateActivityBarView with commit value on click', () => { - wrapper.find('.js-ide-commit-mode').trigger('click'); + expect(button.classes('active')).toBe(true); + }; - expect(wrapper.vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.commit.name); - }); + it.each(MODES)('is initially set to %s mode', (mode) => { + mountComponent({ currentActivityView: leftSidebarViews[mode].name }); - it('calls updateActivityBarView with review value on click', () => { - wrapper.find('.js-ide-review-mode').trigger('click'); + const button = findModeButton(mode); - expect(wrapper.vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.review.name); + expect(button.classes('active')).toBe(true); }); - }); - describe('active item', () => { - it('sets edit item active', () => { + it.each(MODES_WITHOUT_EDIT)('is correctly set after clicking %s mode button', (mode) => { mountComponent(); - expect(wrapper.find('.js-ide-edit-mode').classes()).toContain('active'); + testSettingActiveItem(mode); }); - it('sets commit item active', () => { - mountComponent({ currentActivityView: leftSidebarViews.commit.name }); + it('is correctly set after clicking edit mode button', () => { + // The default currentActivityView is leftSidebarViews.edit.name, + // so for the 'edit' mode, we pass a different currentActivityView. + mountComponent({ currentActivityView: leftSidebarViews.review.name }); - expect(wrapper.find('.js-ide-commit-mode').classes()).toContain('active'); + testSettingActiveItem('edit'); }); }); @@ -65,7 +69,6 @@ describe('IDE ActivityBar component', () => { it('is rendered when files are staged', () => { mountComponent({ stagedFiles: [{ path: '/path/to/file' }] }); - expect(findChangesBadge().exists()).toBe(true); expect(findChangesBadge().text()).toBe('1'); }); diff --git a/spec/frontend/ide/components/cannot_push_code_alert_spec.js b/spec/frontend/ide/components/cannot_push_code_alert_spec.js index d4db2246008..c72d8c5fccd 100644 --- a/spec/frontend/ide/components/cannot_push_code_alert_spec.js +++ b/spec/frontend/ide/components/cannot_push_code_alert_spec.js @@ -45,7 +45,7 @@ describe('ide/components/cannot_push_code_alert', () => { createComponent(); }); - it('shows alert with message', () => { + it('shows an alert with message', () => { expect(findAlert().props()).toMatchObject({ dismissible: false }); expect(findAlert().text()).toBe(TEST_MESSAGE); }); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index 0c0998c037a..04dd81d9fda 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -21,6 +21,7 @@ import { COMMIT_TO_NEW_BRANCH } from '~/ide/stores/modules/commit/constants'; describe('IDE commit form', () => { let wrapper; let store; + const showModalSpy = jest.fn(); const createComponent = () => { wrapper = shallowMount(CommitForm, { @@ -29,7 +30,11 @@ describe('IDE commit form', () => { GlTooltip: createMockDirective('gl-tooltip'), }, stubs: { - GlModal: stubComponent(GlModal), + GlModal: stubComponent(GlModal, { + methods: { + show: showModalSpy, + }, + }), }, }); }; @@ -57,6 +62,7 @@ describe('IDE commit form', () => { tooltip: getBinding(findCommitButtonTooltip().element, 'gl-tooltip').value.title, }); const findForm = () => wrapper.find('form'); + const findModal = () => wrapper.findComponent(GlModal); const submitForm = () => findForm().trigger('submit'); const findCommitMessageInput = () => wrapper.findComponent(CommitMessageField); const setCommitMessageInput = (val) => findCommitMessageInput().vm.$emit('input', val); @@ -83,7 +89,7 @@ describe('IDE commit form', () => { ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE} ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToCommitView} | ${findCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE} `('$desc', ({ stagedFiles, userPermissions, viewFn, buttonFn, disabled, tooltip }) => { - beforeEach(async () => { + beforeEach(() => { store.state.stagedFiles = stagedFiles; store.state.projects.abcproject.userPermissions = userPermissions; @@ -298,22 +304,19 @@ describe('IDE commit form', () => { ${() => createCodeownersCommitError('test message')} | ${{ actionPrimary: { text: 'Create new branch' } }} ${createUnexpectedCommitError} | ${{ actionPrimary: null }} `('opens error modal if commitError with $error', async ({ createError, props }) => { - const modal = wrapper.findComponent(GlModal); - modal.vm.show = jest.fn(); - const error = createError(); store.state.commit.commitError = error; await nextTick(); - expect(modal.vm.show).toHaveBeenCalled(); - expect(modal.props()).toMatchObject({ + expect(showModalSpy).toHaveBeenCalled(); + expect(findModal().props()).toMatchObject({ actionCancel: { text: 'Cancel' }, ...props, }); // Because of the legacy 'mountComponent' approach here, the only way to // test the text of the modal is by viewing the content of the modal added to the document. - expect(modal.html()).toContain(error.messageHTML); + expect(findModal().html()).toContain(error.messageHTML); }); }); @@ -339,7 +342,7 @@ describe('IDE commit form', () => { await nextTick(); - wrapper.findComponent(GlModal).vm.$emit('ok'); + findModal().vm.$emit('ok'); await waitForPromises(); diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js index 6b9ba939a87..c0b0cb0b732 100644 --- a/spec/frontend/ide/components/commit_sidebar/list_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js @@ -20,7 +20,7 @@ describe('Multi-file editor commit sidebar list', () => { }); describe('with a list of files', () => { - beforeEach(async () => { + beforeEach(() => { const f = file('file name'); f.changed = true; wrapper = mountComponent({ fileList: [f] }); diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js index e6fd018969f..7ae8cfac935 100644 --- a/spec/frontend/ide/components/ide_review_spec.js +++ b/spec/frontend/ide/components/ide_review_spec.js @@ -63,7 +63,7 @@ describe('IDE review mode', () => { await wrapper.vm.reactivate(); }); - it('updates viewer to "mrdiff"', async () => { + it('updates viewer to "mrdiff"', () => { expect(store.state.viewer).toBe('mrdiff'); }); }); diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index 1c8d570cdce..f2a684ab65e 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -64,7 +64,7 @@ describe('WebIDE', () => { }); }); - it('renders "New file" button in empty repo', async () => { + it('renders "New file" button in empty repo', () => { expect(wrapper.find('[title="New file"]').exists()).toBe(true); }); }); @@ -169,7 +169,7 @@ describe('WebIDE', () => { }); }); - it('when user cannot push code, shows alert', () => { + it('when user cannot push code, shows an alert', () => { store.state.links = { forkInfo: { ide_path: TEST_FORK_IDE_PATH, diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js index 9f452910496..bcfa6809eca 100644 --- a/spec/frontend/ide/components/ide_tree_spec.js +++ b/spec/frontend/ide/components/ide_tree_spec.js @@ -1,9 +1,9 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; -import { keepAlive } from 'helpers/keep_alive_component_helper'; +import { viewerTypes } from '~/ide/constants'; import IdeTree from '~/ide/components/ide_tree.vue'; -import { createStore } from '~/ide/stores'; +import { createStoreOptions } from '~/ide/stores'; import { file } from '../helpers'; import { projectData } from '../mock_data'; @@ -13,42 +13,72 @@ describe('IdeTree', () => { let store; let wrapper; - beforeEach(() => { - store = createStore(); - - store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'main'; - store.state.projects.abcproject = { ...projectData }; - Vue.set(store.state.trees, 'abcproject/main', { - tree: [file('fileName')], - loading: false, + const actionSpies = { + updateViewer: jest.fn(), + }; + + const testState = { + currentProjectId: 'abcproject', + currentBranchId: 'main', + projects: { + abcproject: { ...projectData }, + }, + trees: { + 'abcproject/main': { + tree: [file('fileName')], + loading: false, + }, + }, + }; + + const createComponent = (replaceState) => { + const defaultStore = createStoreOptions(); + + store = new Vuex.Store({ + ...defaultStore, + state: { + ...defaultStore.state, + ...testState, + replaceState, + }, + actions: { + ...defaultStore.actions, + ...actionSpies, + }, }); - wrapper = mount(keepAlive(IdeTree), { + wrapper = mount(IdeTree, { store, }); - }); + }; - it('renders list of files', () => { - expect(wrapper.text()).toContain('fileName'); + beforeEach(() => { + createComponent(); }); - describe('activated', () => { - let inititializeSpy; + afterEach(() => { + actionSpies.updateViewer.mockClear(); + }); - beforeEach(async () => { - inititializeSpy = jest.spyOn(wrapper.findComponent(IdeTree).vm, 'initialize'); - store.state.viewer = 'diff'; + describe('renders properly', () => { + it('renders list of files', () => { + expect(wrapper.text()).toContain('fileName'); + }); + }); - await wrapper.vm.reactivate(); + describe('activated', () => { + beforeEach(() => { + createComponent({ + viewer: viewerTypes.diff, + }); }); it('re initializes the component', () => { - expect(inititializeSpy).toHaveBeenCalled(); + expect(actionSpies.updateViewer).toHaveBeenCalled(); }); it('updates viewer to "editor" by default', () => { - expect(store.state.viewer).toBe('editor'); + expect(actionSpies.updateViewer).toHaveBeenCalledWith(expect.any(Object), viewerTypes.edit); }); }); }); diff --git a/spec/frontend/ide/components/new_dropdown/index_spec.js b/spec/frontend/ide/components/new_dropdown/index_spec.js index 01dcb174c41..a2371abe955 100644 --- a/spec/frontend/ide/components/new_dropdown/index_spec.js +++ b/spec/frontend/ide/components/new_dropdown/index_spec.js @@ -1,33 +1,50 @@ -import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import NewDropdown from '~/ide/components/new_dropdown/index.vue'; import Button from '~/ide/components/new_dropdown/button.vue'; -import { createStore } from '~/ide/stores'; +import Modal from '~/ide/components/new_dropdown/modal.vue'; +import { stubComponent } from 'helpers/stub_component'; + +Vue.use(Vuex); describe('new dropdown component', () => { let wrapper; + const openMock = jest.fn(); + const deleteEntryMock = jest.fn(); const findAllButtons = () => wrapper.findAllComponents(Button); - const mountComponent = () => { - const store = createStore(); - store.state.currentProjectId = 'abcproject'; - store.state.path = ''; - store.state.trees['abcproject/mybranch'] = { tree: [] }; + const mountComponent = (props = {}) => { + const fakeStore = () => { + return new Vuex.Store({ + actions: { + deleteEntry: deleteEntryMock, + }, + }); + }; - wrapper = mount(NewDropdown, { - store, + wrapper = mountExtended(NewDropdown, { + store: fakeStore(), propsData: { branch: 'main', path: '', mouseOver: false, type: 'tree', + ...props, + }, + stubs: { + NewModal: stubComponent(Modal, { + methods: { + open: openMock, + }, + }), }, }); }; beforeEach(() => { mountComponent(); - jest.spyOn(wrapper.vm.$refs.newModal, 'open').mockImplementation(() => {}); }); it('renders new file, upload and new directory links', () => { @@ -38,37 +55,34 @@ describe('new dropdown component', () => { describe('createNewItem', () => { it('opens modal for a blob when new file is clicked', () => { - findAllButtons().at(0).trigger('click'); + findAllButtons().at(0).vm.$emit('click'); - expect(wrapper.vm.$refs.newModal.open).toHaveBeenCalledWith('blob', ''); + expect(openMock).toHaveBeenCalledWith('blob', ''); }); it('opens modal for a tree when new directory is clicked', () => { - findAllButtons().at(2).trigger('click'); + findAllButtons().at(2).vm.$emit('click'); - expect(wrapper.vm.$refs.newModal.open).toHaveBeenCalledWith('tree', ''); + expect(openMock).toHaveBeenCalledWith('tree', ''); }); }); describe('isOpen', () => { it('scrolls dropdown into view', async () => { - jest.spyOn(wrapper.vm.$refs.dropdownMenu, 'scrollIntoView').mockImplementation(() => {}); + const dropdownMenu = wrapper.findByTestId('dropdown-menu'); + const scrollIntoViewSpy = jest.spyOn(dropdownMenu.element, 'scrollIntoView'); await wrapper.setProps({ isOpen: true }); - expect(wrapper.vm.$refs.dropdownMenu.scrollIntoView).toHaveBeenCalledWith({ - block: 'nearest', - }); + expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: 'nearest' }); }); }); describe('delete entry', () => { it('calls delete action', () => { - jest.spyOn(wrapper.vm, 'deleteEntry').mockImplementation(() => {}); - findAllButtons().at(4).trigger('click'); - expect(wrapper.vm.deleteEntry).toHaveBeenCalledWith(''); + expect(deleteEntryMock).toHaveBeenCalledWith(expect.anything(), ''); }); }); }); diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js index 92bb645b1c0..ead609421b7 100644 --- a/spec/frontend/ide/components/repo_commit_section_spec.js +++ b/spec/frontend/ide/components/repo_commit_section_spec.js @@ -157,21 +157,4 @@ describe('RepoCommitSection', () => { expect(wrapper.findComponent(EmptyState).exists()).toBe(false); }); }); - - describe('activated', () => { - let inititializeSpy; - - beforeEach(async () => { - createComponent(); - - inititializeSpy = jest.spyOn(wrapper.findComponent(RepoCommitSection).vm, 'initialize'); - store.state.viewer = 'diff'; - - await wrapper.vm.reactivate(); - }); - - it('re initializes the component', () => { - expect(inititializeSpy).toHaveBeenCalled(); - }); - }); }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 9253bfc7e71..6747ec97050 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -293,7 +293,7 @@ describe('RepoEditor', () => { }); describe('when file changes to non-markdown file', () => { - beforeEach(async () => { + beforeEach(() => { wrapper.setProps({ file: dummyFile.empty }); }); @@ -601,7 +601,7 @@ describe('RepoEditor', () => { const f = createRemoteFile('newFile'); Vue.set(vm.$store.state.entries, f.path, f); - jest.spyOn(service, 'getRawFileData').mockImplementation(async () => { + jest.spyOn(service, 'getRawFileData').mockImplementation(() => { expect(vm.file.loading).toBe(true); // switching from edit to diff mode usually triggers editor initialization @@ -609,7 +609,7 @@ describe('RepoEditor', () => { jest.runOnlyPendingTimers(); - return 'rawFileData123\n'; + return Promise.resolve('rawFileData123\n'); }); wrapper.setProps({ @@ -630,18 +630,18 @@ describe('RepoEditor', () => { jest .spyOn(service, 'getRawFileData') - .mockImplementation(async () => { + .mockImplementation(() => { // opening fileB while the content of fileA is still being fetched wrapper.setProps({ file: fileB, }); - return aContent; + return Promise.resolve(aContent); }) - .mockImplementationOnce(async () => { + .mockImplementationOnce(() => { // we delay returning fileB content // to make sure the editor doesn't initialize prematurely jest.advanceTimersByTime(30); - return bContent; + return Promise.resolve(bContent); }); wrapper.setProps({ diff --git a/spec/frontend/ide/components/shared/commit_message_field_spec.js b/spec/frontend/ide/components/shared/commit_message_field_spec.js index 186b1997497..ccf544b27b7 100644 --- a/spec/frontend/ide/components/shared/commit_message_field_spec.js +++ b/spec/frontend/ide/components/shared/commit_message_field_spec.js @@ -50,7 +50,7 @@ describe('CommitMessageField', () => { await nextTick(); }); - it('is added on textarea focus', async () => { + it('is added on textarea focus', () => { expect(wrapper.attributes('class')).toEqual( expect.stringContaining('gl-outline-none! gl-focus-ring-border-1-gray-900!'), ); diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index bfc87f17092..2666f06e8d8 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -26,6 +26,7 @@ const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/webpack/assets/gitlab-web-ide/publ const TEST_FILE_PATH = 'foo/README.md'; const TEST_MR_ID = '7'; const TEST_MR_TARGET_PROJECT = 'gitlab-org/the-real-gitlab'; +const TEST_SIGN_IN_PATH = 'sign-in'; const TEST_FORK_INFO = { fork_path: '/forky' }; const TEST_IDE_REMOTE_PATH = '/-/ide/remote/:remote_host/:remote_path'; const TEST_START_REMOTE_PARAMS = { @@ -56,6 +57,7 @@ describe('ide/init_gitlab_web_ide', () => { el.dataset.editorFontSrcUrl = TEST_EDITOR_FONT_SRC_URL; el.dataset.editorFontFormat = TEST_EDITOR_FONT_FORMAT; el.dataset.editorFontFamily = TEST_EDITOR_FONT_FAMILY; + el.dataset.signInPath = TEST_SIGN_IN_PATH; document.body.append(el); }; @@ -109,6 +111,7 @@ describe('ide/init_gitlab_web_ide', () => { links: { userPreferences: TEST_USER_PREFERENCES_PATH, feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, + signIn: TEST_SIGN_IN_PATH, }, editorFont: { srcUrl: TEST_EDITOR_FONT_SRC_URL, diff --git a/spec/frontend/ide/lib/languages/codeowners_spec.js b/spec/frontend/ide/lib/languages/codeowners_spec.js new file mode 100644 index 00000000000..aa0ab123c4b --- /dev/null +++ b/spec/frontend/ide/lib/languages/codeowners_spec.js @@ -0,0 +1,85 @@ +import { editor } from 'monaco-editor'; +import codeowners from '~/ide/lib/languages/codeowners'; +import { registerLanguages } from '~/ide/utils'; + +describe('tokenization for CODEOWNERS files', () => { + beforeEach(() => { + registerLanguages(codeowners); + }); + + it.each([ + ['## Foo bar comment', [[{ language: 'codeowners', offset: 0, type: 'comment.codeowners' }]]], + [ + '/foo/bar @gsamsa', + [ + [ + { language: 'codeowners', offset: 0, type: 'regexp.codeowners' }, + { language: 'codeowners', offset: 8, type: 'source.codeowners' }, + { language: 'codeowners', offset: 9, type: 'variable.value.codeowners' }, + ], + ], + ], + [ + '^[Section name]', + [ + [ + { language: 'codeowners', offset: 0, type: 'constant.numeric.codeowners' }, + { language: 'codeowners', offset: 1, type: 'namespace.codeowners' }, + ], + ], + ], + [ + '[Section name][3]', + [ + [ + { language: 'codeowners', offset: 0, type: 'namespace.codeowners' }, + { language: 'codeowners', offset: 14, type: 'constant.numeric.codeowners' }, + ], + ], + ], + [ + '[Section name][30]', + [ + [ + { language: 'codeowners', offset: 0, type: 'namespace.codeowners' }, + { language: 'codeowners', offset: 14, type: 'constant.numeric.codeowners' }, + ], + ], + ], + [ + '^[Section name][3]', + [ + [ + { language: 'codeowners', offset: 0, type: 'constant.numeric.codeowners' }, + { language: 'codeowners', offset: 1, type: 'namespace.codeowners' }, + { language: 'codeowners', offset: 15, type: 'constant.numeric.codeowners' }, + ], + ], + ], + [ + '^[Section-name-test][3]', + [ + [ + { language: 'codeowners', offset: 0, type: 'constant.numeric.codeowners' }, + { language: 'codeowners', offset: 1, type: 'namespace.codeowners' }, + { language: 'codeowners', offset: 20, type: 'constant.numeric.codeowners' }, + ], + ], + ], + [ + '[Section-name_test]', + [[{ language: 'codeowners', offset: 0, type: 'namespace.codeowners' }]], + ], + [ + '[2 Be or not 2 be][3]', + [ + [ + { language: 'codeowners', offset: 0, type: 'namespace.codeowners' }, + { language: 'codeowners', offset: 18, type: 'constant.numeric.codeowners' }, + ], + ], + ], + ])('%s', (string, tokens) => { + expect(editor.tokenize(string, 'codeowners')).toEqual(tokens); + }); +}); diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index 63b63af667c..f6925e78b6a 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -210,7 +210,7 @@ describe('Multi-file store actions', () => { expect(store.dispatch).toHaveBeenCalledWith('setFileActive', 'test'); }); - it('creates alert message if file already exists', async () => { + it('creates alert if file already exists', async () => { const f = file('test', '1', 'blob'); store.state.trees['abcproject/mybranch'].tree = [f]; store.state.entries[f.path] = f; @@ -440,7 +440,7 @@ describe('Multi-file store actions', () => { }); describe('setErrorMessage', () => { - it('commis error messsage', () => { + it('commis error message', () => { return testAction( setErrorMessage, 'error', diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js index 872aa9b6e6b..3eaff92d321 100644 --- a/spec/frontend/ide/stores/modules/commit/actions_spec.js +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -1,7 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { file } from 'jest/ide/helpers'; import { commitActionTypes, PERMISSION_CREATE_MR } from '~/ide/constants'; import eventHub from '~/ide/eventhub'; @@ -40,14 +39,12 @@ describe('IDE commit module actions', () => { let mock; let store; let router; - let trackingSpy; beforeEach(() => { store = createStore(); router = createRouter(store); gon.api_version = 'v1'; mock = new MockAdapter(axios); - trackingSpy = mockTracking(undefined, undefined, jest.spyOn); jest.spyOn(router, 'push').mockImplementation(); mock @@ -56,7 +53,6 @@ describe('IDE commit module actions', () => { }); afterEach(() => { - unmockTracking(); mock.restore(); }); @@ -426,28 +422,6 @@ describe('IDE commit module actions', () => { }); }); }); - - describe('learnGitlabSource', () => { - describe('learnGitlabSource is true', () => { - it('tracks commit', async () => { - store.state.learnGitlabSource = true; - - await store.dispatch('commit/commitChanges'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'commit', { - label: 'web_ide_learn_gitlab_source', - }); - }); - }); - - describe('learnGitlabSource is false', () => { - it('does not track commit', async () => { - await store.dispatch('commit/commitChanges'); - - expect(trackingSpy).not.toHaveBeenCalled(); - }); - }); - }); }); describe('success response with failed message', () => { @@ -465,26 +439,6 @@ describe('IDE commit module actions', () => { expect(alert.textContent.trim()).toBe('failed message'); }); - - describe('learnGitlabSource', () => { - describe('learnGitlabSource is true', () => { - it('does not track commit', async () => { - store.state.learnGitlabSource = true; - - await store.dispatch('commit/commitChanges'); - - expect(trackingSpy).not.toHaveBeenCalled(); - }); - }); - - describe('learnGitlabSource is false', () => { - it('does not track commit', async () => { - await store.dispatch('commit/commitChanges'); - - expect(trackingSpy).not.toHaveBeenCalled(); - }); - }); - }); }); describe('failed response', () => { @@ -504,26 +458,6 @@ describe('IDE commit module actions', () => { ['commit/SET_ERROR', createUnexpectedCommitError(), undefined], ]); }); - - describe('learnGitlabSource', () => { - describe('learnGitlabSource is true', () => { - it('does not track commit', async () => { - store.state.learnGitlabSource = true; - - await store.dispatch('commit/commitChanges').catch(() => {}); - - expect(trackingSpy).not.toHaveBeenCalled(); - }); - }); - - describe('learnGitlabSource is false', () => { - it('does not track commit', async () => { - await store.dispatch('commit/commitChanges').catch(() => {}); - - expect(trackingSpy).not.toHaveBeenCalled(); - }); - }); - }); }); describe('first commit of a branch', () => { diff --git a/spec/frontend/import/details/components/import_details_app_spec.js b/spec/frontend/import/details/components/import_details_app_spec.js new file mode 100644 index 00000000000..178ce071de0 --- /dev/null +++ b/spec/frontend/import/details/components/import_details_app_spec.js @@ -0,0 +1,23 @@ +import { shallowMount } from '@vue/test-utils'; +import ImportDetailsApp from '~/import/details/components/import_details_app.vue'; +import { mockProject } from '../mock_data'; + +describe('Import details app', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(ImportDetailsApp, { + propsData: { + project: mockProject, + }, + }); + }; + + describe('template', () => { + it('renders heading', () => { + createComponent(); + + expect(wrapper.find('h1').text()).toBe(ImportDetailsApp.i18n.pageTitle); + }); + }); +}); diff --git a/spec/frontend/import/details/components/import_details_table_spec.js b/spec/frontend/import/details/components/import_details_table_spec.js new file mode 100644 index 00000000000..43c9a66c00a --- /dev/null +++ b/spec/frontend/import/details/components/import_details_table_spec.js @@ -0,0 +1,33 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlEmptyState, GlTable } from '@gitlab/ui'; + +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; +import ImportDetailsTable from '~/import/details/components/import_details_table.vue'; + +describe('Import details table', () => { + let wrapper; + + const createComponent = ({ mountFn = shallowMount } = {}) => { + wrapper = mountFn(ImportDetailsTable); + }; + + const findGlTable = () => wrapper.findComponent(GlTable); + const findGlEmptyState = () => findGlTable().findComponent(GlEmptyState); + const findPaginationBar = () => wrapper.findComponent(PaginationBar); + + describe('template', () => { + describe('when no items are available', () => { + it('renders table with empty state', () => { + createComponent({ mountFn: mount }); + + expect(findGlEmptyState().exists()).toBe(true); + }); + + it('does not render pagination', () => { + createComponent(); + + expect(findPaginationBar().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/import/details/mock_data.js b/spec/frontend/import/details/mock_data.js new file mode 100644 index 00000000000..514fb3a923d --- /dev/null +++ b/spec/frontend/import/details/mock_data.js @@ -0,0 +1,31 @@ +export const mockProject = { + id: 26, + name: 'acl', + fullPath: '/root/acl', + fullName: 'Administrator / acl', + refsUrl: '/root/acl/refs', + importSource: 'namespace/acl', + importStatus: 'finished', + humanImportStatusName: 'finished', + providerLink: 'https://github.com/namespace/acl', + relationType: null, + stats: { + fetched: { + note: 1, + issue: 2, + label: 5, + collaborator: 2, + pullRequest: 1, + pullRequestMergedBy: 1, + }, + imported: { + note: 1, + issue: 2, + label: 6, + collaborator: 3, + pullRequest: 1, + pullRequestMergedBy: 1, + pullRequestReviewRequest: 1, + }, + }, +}; diff --git a/spec/frontend/import_entities/components/import_status_spec.js b/spec/frontend/import_entities/components/import_status_spec.js index 3488d9f60c8..8e569e3ec85 100644 --- a/spec/frontend/import_entities/components/import_status_spec.js +++ b/spec/frontend/import_entities/components/import_status_spec.js @@ -1,4 +1,4 @@ -import { GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui'; +import { GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ImportStatus from '~/import_entities/components/import_status.vue'; import { STATUSES } from '~/import_entities/constants'; @@ -6,12 +6,17 @@ import { STATUSES } from '~/import_entities/constants'; describe('Import entities status component', () => { let wrapper; - const createComponent = (propsData) => { + const mockStatItems = { label: 100, note: 200 }; + + const createComponent = (propsData, { provide } = {}) => { wrapper = shallowMount(ImportStatus, { propsData, + provide, }); }; + const findGlLink = () => wrapper.findComponent(GlLink); + describe('success status', () => { const getStatusText = () => wrapper.findComponent(GlBadge).text(); const getStatusIcon = () => wrapper.findComponent(GlBadge).props('icon'); @@ -24,13 +29,11 @@ describe('Import entities status component', () => { }); it('displays finished status as complete when all stats items were processed', () => { - const statItems = { label: 100, note: 200 }; - createComponent({ status: STATUSES.FINISHED, stats: { - fetched: { ...statItems }, - imported: { ...statItems }, + fetched: { ...mockStatItems }, + imported: { ...mockStatItems }, }, }); @@ -39,17 +42,15 @@ describe('Import entities status component', () => { }); it('displays finished status as partial when all stats items were processed', () => { - const statItems = { label: 100, note: 200 }; - createComponent({ status: STATUSES.FINISHED, stats: { - fetched: { ...statItems }, - imported: { ...statItems, label: 50 }, + fetched: { ...mockStatItems }, + imported: { ...mockStatItems, label: 50 }, }, }); - expect(getStatusText()).toBe('Partial import'); + expect(getStatusText()).toBe('Partially completed'); expect(getStatusIcon()).toBe('status-alert'); }); }); @@ -151,4 +152,53 @@ describe('Import entities status component', () => { expect(getStatusIcon()).toBe('status-success'); }); }); + + describe('show details link', () => { + const mockDetailsPath = 'details_path'; + const mockCompleteStats = { + fetched: { ...mockStatItems }, + imported: { ...mockStatItems }, + }; + const mockIncompleteStats = { + fetched: { ...mockStatItems }, + imported: { ...mockStatItems, label: 50 }, + }; + + describe.each` + detailsPath | importDetailsPage | partialImport | expectLink + ${undefined} | ${false} | ${false} | ${false} + ${undefined} | ${false} | ${true} | ${false} + ${undefined} | ${true} | ${false} | ${false} + ${undefined} | ${true} | ${true} | ${false} + ${mockDetailsPath} | ${false} | ${false} | ${false} + ${mockDetailsPath} | ${false} | ${true} | ${false} + ${mockDetailsPath} | ${true} | ${false} | ${false} + ${mockDetailsPath} | ${true} | ${true} | ${true} + `( + 'when detailsPath is $detailsPath, feature flag importDetailsPage is $importDetailsPage, partial import is $partialImport', + ({ detailsPath, importDetailsPage, partialImport, expectLink }) => { + beforeEach(() => { + createComponent( + { + status: STATUSES.FINISHED, + stats: partialImport ? mockIncompleteStats : mockCompleteStats, + }, + { + provide: { + detailsPath, + glFeatures: { importDetailsPage }, + }, + }, + ); + }); + + it(`${expectLink ? 'renders' : 'does not render'} import details link`, () => { + expect(findGlLink().exists()).toBe(expectLink); + if (expectLink) { + expect(findGlLink().attributes('href')).toBe(mockDetailsPath); + } + }); + }, + ); + }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js index 1a52485f779..4c13ec555c2 100644 --- a/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js @@ -94,14 +94,14 @@ describe('import actions cell', () => { ); }); - it('request migrate projects by default', async () => { + it('request migrate projects by default', () => { const dropdown = wrapper.findComponent(GlDropdown); dropdown.vm.$emit('click'); expect(wrapper.emitted('import-group')[0]).toStrictEqual([{ migrateProjects: true }]); }); - it('request not to migrate projects via dropdown option', async () => { + it('request not to migrate projects via dropdown option', () => { const dropdown = wrapper.findComponent(GlDropdown); dropdown.findComponent(GlDropdownItem).vm.$emit('click'); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index 205218fdabd..b1aa94cf418 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -687,7 +687,7 @@ describe('import table', () => { return waitForPromises(); }); - it('renders import all dropdown', async () => { + it('renders import all dropdown', () => { expect(findImportSelectedDropdown().exists()).toBe(true); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index 83566469176..540c42a2854 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -48,7 +48,7 @@ describe('Bulk import resolvers', () => { }; let results; - beforeEach(async () => { + beforeEach(() => { axiosMockAdapter = new MockAdapter(axios); client = createClient(); diff --git a/spec/frontend/import_entities/import_groups/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/services/status_poller_spec.js index 5ee2b2e698f..e1ed739a708 100644 --- a/spec/frontend/import_entities/import_groups/services/status_poller_spec.js +++ b/spec/frontend/import_entities/import_groups/services/status_poller_spec.js @@ -81,7 +81,7 @@ describe('Bulk import status poller', () => { expect(pollInstance.makeRequest).toHaveBeenCalled(); }); - it('when error occurs shows alert with error', () => { + it('when error occurs shows an alert with error', () => { const [[pollConfig]] = Poll.mock.calls; pollConfig.errorCallback(); expect(createAlert).toHaveBeenCalled(); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index e8d222dc2e9..6e64eeaf295 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -212,7 +212,7 @@ describe('Incidents List', () => { }); }); - it('contains a link to the incident details page', async () => { + it('contains a link to the incident details page', () => { findTableRows().at(0).trigger('click'); expect(visitUrl).toHaveBeenCalledWith( joinPaths(`/project/issues/incident`, mockIncidents[0].iid), diff --git a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js index c5c29b4bb19..9b11fe2bff0 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js @@ -33,7 +33,7 @@ describe('IncidentsSettingsService', () => { }); }); - it('should display an alert message on update error', () => { + it('should display an alert on update error', () => { mock.onPatch().reply(HTTP_STATUS_BAD_REQUEST); return service.updateSettings({}).then(() => { diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index 58fb456eb53..5aa3ee35379 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -495,7 +495,7 @@ describe('IntegrationForm', () => { expect(refreshCurrentPage).toHaveBeenCalledTimes(1); }); - it('resets `isResetting`', async () => { + it('resets `isResetting`', () => { expect(findFormActions().props('isResetting')).toBe(false); }); }); diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 90ee69ef2dc..82f70b8ede1 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -37,8 +37,7 @@ describe('JiraIssuesFields', () => { const findProjectKey = () => wrapper.findComponent(GlFormInput); const findProjectKeyFormGroup = () => wrapper.findByTestId('project-key-form-group'); const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities'); - const setEnableCheckbox = async (isEnabled = true) => - findEnableCheckbox().vm.$emit('input', isEnabled); + const setEnableCheckbox = (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); const assertProjectKeyState = (expectedStateValue) => { expect(findProjectKey().attributes('state')).toBe(expectedStateValue); @@ -178,7 +177,7 @@ describe('JiraIssuesFields', () => { }); describe('with no project key', () => { - it('sets Project Key `state` attribute to `undefined`', async () => { + it('sets Project Key `state` attribute to `undefined`', () => { assertProjectKeyState(undefined); }); }); diff --git a/spec/frontend/invite_members/components/invite_group_notification_spec.js b/spec/frontend/invite_members/components/invite_group_notification_spec.js index 3e6ba6da9f4..1da2e7b705d 100644 --- a/spec/frontend/invite_members/components/invite_group_notification_spec.js +++ b/spec/frontend/invite_members/components/invite_group_notification_spec.js @@ -2,7 +2,7 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { sprintf } from '~/locale'; import InviteGroupNotification from '~/invite_members/components/invite_group_notification.vue'; -import { GROUP_MODAL_ALERT_BODY } from '~/invite_members/constants'; +import { GROUP_MODAL_TO_GROUP_ALERT_BODY } from '~/invite_members/constants'; describe('InviteGroupNotification', () => { let wrapper; @@ -13,7 +13,11 @@ describe('InviteGroupNotification', () => { const createComponent = () => { wrapper = shallowMountExtended(InviteGroupNotification, { provide: { freeUsersLimit: 5 }, - propsData: { name: 'name' }, + propsData: { + name: 'name', + notificationLink: '_notification_link_', + notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY, + }, stubs: { GlSprintf }, }); }; @@ -28,15 +32,13 @@ describe('InviteGroupNotification', () => { }); it('shows the correct message', () => { - const message = sprintf(GROUP_MODAL_ALERT_BODY, { count: 5 }); + const message = sprintf(GROUP_MODAL_TO_GROUP_ALERT_BODY, { count: 5 }); expect(findAlert().text()).toMatchInterpolatedText(message); }); it('has a help link', () => { - expect(findLink().attributes('href')).toEqual( - 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group', - ); + expect(findLink().attributes('href')).toEqual('_notification_link_'); }); }); }); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js index 82b4717fbf1..4f082145562 100644 --- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -12,6 +12,12 @@ import { displaySuccessfulInvitationAlert, reloadOnInvitationSuccess, } from '~/invite_members/utils/trigger_successful_invite_alert'; +import { + GROUP_MODAL_TO_GROUP_ALERT_BODY, + GROUP_MODAL_TO_GROUP_ALERT_LINK, + GROUP_MODAL_TO_PROJECT_ALERT_BODY, + GROUP_MODAL_TO_PROJECT_ALERT_LINK, +} from '~/invite_members/constants'; import { propsData, sharedGroup } from '../mock_data/group_modal'; jest.mock('~/invite_members/utils/trigger_successful_invite_alert'); @@ -91,6 +97,26 @@ describe('InviteGroupsModal', () => { expect(findInviteGroupAlert().exists()).toBe(false); }); + + it('shows the user limit notification alert with correct link and text for group', () => { + createComponent({ freeUserCapEnabled: true }); + + expect(findInviteGroupAlert().props()).toMatchObject({ + name: propsData.name, + notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY, + notificationLink: GROUP_MODAL_TO_GROUP_ALERT_LINK, + }); + }); + + it('shows the user limit notification alert with correct link and text for project', () => { + createComponent({ freeUserCapEnabled: true, isProject: true }); + + expect(findInviteGroupAlert().props()).toMatchObject({ + name: propsData.name, + notificationText: GROUP_MODAL_TO_PROJECT_ALERT_BODY, + notificationLink: GROUP_MODAL_TO_PROJECT_ALERT_LINK, + }); + }); }); describe('submitting the invite form', () => { diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 39d5ddee723..e080e665a3b 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -6,7 +6,6 @@ import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; import ModalConfetti from '~/invite_members/components/confetti.vue'; @@ -18,11 +17,11 @@ import { MEMBERS_MODAL_CELEBRATE_TITLE, MEMBERS_PLACEHOLDER, MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, - LEARN_GITLAB, EXPANDED_ERRORS, EMPTY_INVITES_ALERT_TEXT, ON_CELEBRATION_TRACK_LABEL, INVITE_MEMBER_MODAL_TRACKING_CATEGORY, + INVALID_FEEDBACK_MESSAGE_DEFAULT, } from '~/invite_members/constants'; import eventHub from '~/invite_members/event_hub'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; @@ -40,7 +39,9 @@ import { import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses'; import { propsData, - inviteSource, + emailPostData, + postData, + singleUserPostData, newProjectPath, user1, user2, @@ -63,11 +64,12 @@ describe('InviteMembersModal', () => { let mock; let trackingSpy; - const expectTracking = ( - action, - label = undefined, - category = INVITE_MEMBER_MODAL_TRACKING_CATEGORY, - ) => expect(trackingSpy).toHaveBeenCalledWith(category, action, { label, category }); + const expectTracking = (action, label = undefined, property = undefined) => + expect(trackingSpy).toHaveBeenCalledWith(INVITE_MEMBER_MODAL_TRACKING_CATEGORY, action, { + label, + category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY, + property, + }); const createComponent = (props = {}, stubs = {}) => { wrapper = shallowMountExtended(InviteMembersModal, { @@ -211,15 +213,6 @@ describe('InviteMembersModal', () => { expect(findTasksToBeDone().exists()).toBe(false); }); - - describe('when opened from the Learn GitLab page', () => { - it('does render the tasks to be done', async () => { - await setupComponent({}, []); - await triggerOpenModal({ source: LEARN_GITLAB }); - - expect(findTasksToBeDone().exists()).toBe(true); - }); - }); }); describe('rendering the tasks', () => { @@ -278,38 +271,18 @@ describe('InviteMembersModal', () => { }); describe('tracking events', () => { - it('tracks the view for invite_members_for_task', async () => { - await setupComponentWithTasks(); - - expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - INVITE_MEMBERS_FOR_TASK.view, - ); - }); - it('tracks the submit for invite_members_for_task', async () => { await setupComponentWithTasks(); - await triggerMembersTokenSelect([user1]); - clickInviteButton(); + await triggerMembersTokenSelect([user1]); - expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name, { - label: 'selected_tasks_to_be_done', - property: 'ci,code', - }); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - INVITE_MEMBERS_FOR_TASK.submit, - ); - }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - it('does not track the submit for invite_members_for_task when invites have not been entered', async () => { - await setupComponentWithTasks(); clickInviteButton(); - expect(ExperimentTracking).not.toHaveBeenCalledWith( - INVITE_MEMBERS_FOR_TASK.name, - expect.any, - ); + expectTracking(INVITE_MEMBERS_FOR_TASK.submit, 'selected_tasks_to_be_done', 'ci,code'); + + unmockTracking(); }); }); }); @@ -472,7 +445,7 @@ describe('InviteMembersModal', () => { const expectedSyntaxError = 'email contains an invalid email address'; describe('when no invites have been entered in the form and then some are entered', () => { - beforeEach(async () => { + beforeEach(() => { createInviteMembersToGroupWrapper(); }); @@ -492,16 +465,6 @@ describe('InviteMembersModal', () => { }); describe('when inviting an existing user to group by user ID', () => { - const postData = { - user_id: '1,2', - access_level: propsData.defaultAccessLevel, - expires_at: undefined, - invite_source: inviteSource, - format: 'json', - tasks_to_be_done: [], - tasks_project_id: '', - }; - describe('when reloadOnSubmit is true', () => { beforeEach(async () => { createComponent({ reloadPageOnSubmit: true }); @@ -555,20 +518,6 @@ describe('InviteMembersModal', () => { expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); }); }); - - describe('when opened from a Learn GitLab page', () => { - it('emits the `showSuccessfulInvitationsAlert` event', async () => { - await triggerOpenModal({ source: LEARN_GITLAB }); - - jest.spyOn(eventHub, '$emit').mockImplementation(); - - clickInviteButton(); - - await waitForPromises(); - - expect(eventHub.$emit).toHaveBeenCalledWith('showSuccessfulInvitationsAlert'); - }); - }); }); describe('when member is not added successfully', () => { @@ -675,16 +624,6 @@ describe('InviteMembersModal', () => { }); describe('when inviting a new user by email address', () => { - const postData = { - access_level: propsData.defaultAccessLevel, - expires_at: undefined, - email: 'email@example.com', - invite_source: inviteSource, - tasks_to_be_done: [], - tasks_project_id: '', - format: 'json', - }; - describe('when invites are sent successfully', () => { beforeEach(async () => { createComponent(); @@ -692,7 +631,7 @@ describe('InviteMembersModal', () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: emailPostData }); }); describe('when triggered from regular mounting', () => { @@ -701,7 +640,7 @@ describe('InviteMembersModal', () => { }); it('calls Api inviteGroupMembers with the correct params', () => { - expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData); + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, emailPostData); }); it('displays the successful toastMessage', () => { @@ -719,96 +658,117 @@ describe('InviteMembersModal', () => { }); describe('when invites are not sent successfully', () => { - beforeEach(async () => { - createInviteMembersToGroupWrapper(); + describe('when api throws error', () => { + beforeEach(async () => { + jest.spyOn(axios, 'post').mockImplementation(() => { + throw new Error(); + }); - await triggerMembersTokenSelect([user3]); + createInviteMembersToGroupWrapper(); + + await triggerMembersTokenSelect([user3]); + clickInviteButton(); + }); + + it('displays the default error message', () => { + expect(membersFormGroupInvalidFeedback()).toBe(INVALID_FEEDBACK_MESSAGE_DEFAULT); + expect(findMembersSelect().props('exceptionState')).toBe(false); + expect(findActionButton().props('loading')).toBe(false); + }); }); - it('displays the api error for invalid email syntax', async () => { - mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); + describe('when api rejects promise', () => { + beforeEach(async () => { + createInviteMembersToGroupWrapper(); - clickInviteButton(); + await triggerMembersTokenSelect([user3]); + }); - await waitForPromises(); + it('displays the api error for invalid email syntax', async () => { + mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); - expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); - expect(findMembersSelect().props('exceptionState')).toBe(false); - expect(findActionButton().props('loading')).toBe(false); - }); + clickInviteButton(); - it('clears the error when the modal is hidden', async () => { - mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); + await waitForPromises(); - clickInviteButton(); + expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); + expect(findMembersSelect().props('exceptionState')).toBe(false); + expect(findActionButton().props('loading')).toBe(false); + }); - await waitForPromises(); + it('clears the error when the modal is hidden', async () => { + mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); - expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); - expect(findMembersSelect().props('exceptionState')).toBe(false); - expect(findActionButton().props('loading')).toBe(false); + clickInviteButton(); - findModal().vm.$emit('hidden'); + await waitForPromises(); - await nextTick(); + expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); + expect(findMembersSelect().props('exceptionState')).toBe(false); + expect(findActionButton().props('loading')).toBe(false); - expect(findMemberErrorAlert().exists()).toBe(false); - expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersSelect().props('exceptionState')).not.toBe(false); - }); + findModal().vm.$emit('hidden'); - it('displays the restricted email error when restricted email is invited', async () => { - mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED); + await nextTick(); - clickInviteButton(); + expect(findMemberErrorAlert().exists()).toBe(false); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); + }); - await waitForPromises(); + it('displays the restricted email error when restricted email is invited', async () => { + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED); - expect(findMemberErrorAlert().exists()).toBe(true); - expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError); - expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersSelect().props('exceptionState')).not.toBe(false); - expect(findActionButton().props('loading')).toBe(false); - }); + clickInviteButton(); - it('displays all errors when there are multiple emails that return a restricted error message', async () => { - mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); + await waitForPromises(); - clickInviteButton(); + expect(findMemberErrorAlert().exists()).toBe(true); + expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); + expect(findActionButton().props('loading')).toBe(false); + }); - await waitForPromises(); + it('displays all errors when there are multiple emails that return a restricted error message', async () => { + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); - expect(findMemberErrorAlert().exists()).toBe(true); - expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0], - ); - expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1], - ); - expect(findMemberErrorAlert().text()).toContain( - Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2], - ); - expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersSelect().props('exceptionState')).not.toBe(false); - }); + clickInviteButton(); - it('displays the invalid syntax error for bad request', async () => { - mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID); + await waitForPromises(); - clickInviteButton(); + expect(findMemberErrorAlert().exists()).toBe(true); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0], + ); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1], + ); + expect(findMemberErrorAlert().text()).toContain( + Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2], + ); + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersSelect().props('exceptionState')).not.toBe(false); + }); - await waitForPromises(); + it('displays the invalid syntax error for bad request', async () => { + mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID); - expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); - expect(findMembersSelect().props('exceptionState')).toBe(false); - }); + clickInviteButton(); - it('does not call displaySuccessfulInvitationAlert on mount', () => { - expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); - }); + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); + expect(findMembersSelect().props('exceptionState')).toBe(false); + }); - it('does not call reloadOnInvitationSuccess', () => { - expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); + it('does not call displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); + }); + + it('does not call reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); + }); }); }); @@ -892,17 +852,6 @@ describe('InviteMembersModal', () => { }); describe('when inviting members and non-members in same click', () => { - const postData = { - access_level: propsData.defaultAccessLevel, - expires_at: undefined, - invite_source: inviteSource, - format: 'json', - tasks_to_be_done: [], - tasks_project_id: '', - user_id: '1', - email: 'email@example.com', - }; - describe('when invites are sent successfully', () => { beforeEach(async () => { createComponent(); @@ -910,7 +859,7 @@ describe('InviteMembersModal', () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: singleUserPostData }); }); describe('when triggered from regular mounting', () => { @@ -922,7 +871,7 @@ describe('InviteMembersModal', () => { it('calls Api inviteGroupMembers with the correct params and invite source', () => { expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, { - ...postData, + ...singleUserPostData, invite_source: '_invite_source_', }); }); @@ -951,26 +900,9 @@ describe('InviteMembersModal', () => { clickInviteButton(); - expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData); + expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, singleUserPostData); }); }); }); - - describe('tracking', () => { - beforeEach(async () => { - createComponent(); - await triggerMembersTokenSelect([user3]); - - wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({}); - }); - - it('tracks the view for learn_gitlab source', () => { - eventHub.$emit('openModal', { source: LEARN_GITLAB }); - - expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(LEARN_GITLAB); - }); - }); }); }); diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js index 59d58f21bb0..67fb1dcbfbd 100644 --- a/spec/frontend/invite_members/mock_data/member_modal.js +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -45,4 +45,35 @@ export const user6 = { avatar_url: '', }; +export const postData = { + user_id: `${user1.id},${user2.id}`, + access_level: propsData.defaultAccessLevel, + expires_at: undefined, + invite_source: inviteSource, + format: 'json', + tasks_to_be_done: [], + tasks_project_id: '', +}; + +export const emailPostData = { + access_level: propsData.defaultAccessLevel, + expires_at: undefined, + email: `${user3.name}`, + invite_source: inviteSource, + tasks_to_be_done: [], + tasks_project_id: '', + format: 'json', +}; + +export const singleUserPostData = { + access_level: propsData.defaultAccessLevel, + expires_at: undefined, + user_id: `${user1.id}`, + email: `${user3.name}`, + invite_source: inviteSource, + tasks_to_be_done: [], + tasks_project_id: '', + format: 'json', +}; + export const GlEmoji = { template: '<img/>' }; diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js index eb76c9845d4..b6fc70038bb 100644 --- a/spec/frontend/invite_members/utils/member_utils_spec.js +++ b/spec/frontend/invite_members/utils/member_utils_spec.js @@ -1,4 +1,12 @@ -import { memberName } from '~/invite_members/utils/member_utils'; +import { + memberName, + triggerExternalAlert, + qualifiesForTasksToBeDone, +} from '~/invite_members/utils/member_utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { getParameterValues } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility'); describe('Member Name', () => { it.each([ @@ -10,3 +18,23 @@ describe('Member Name', () => { expect(memberName(member)).toBe(result); }); }); + +describe('Trigger External Alert', () => { + it('returns false', () => { + expect(triggerExternalAlert()).toBe(false); + }); +}); + +describe('Qualifies For Tasks To Be Done', () => { + it.each([ + ['invite_members_for_task', true], + ['blah', false], + ])(`returns name from supplied member token: %j`, (value, result) => { + setWindowLocation(`blah/blah?open_modal=${value}`); + getParameterValues.mockImplementation(() => { + return [value]; + }); + + expect(qualifiesForTasksToBeDone()).toBe(result); + }); +}); diff --git a/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js b/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js index fd011658f95..6192713f121 100644 --- a/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js +++ b/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js @@ -13,7 +13,7 @@ jest.mock('~/alert'); useLocalStorageSpy(); describe('Display Successful Invitation Alert', () => { - it('does not show alert if localStorage key not present', () => { + it('does not show an alert if localStorage key not present', () => { localStorage.removeItem(TOAST_MESSAGE_LOCALSTORAGE_KEY); displaySuccessfulInvitationAlert(); @@ -21,7 +21,7 @@ describe('Display Successful Invitation Alert', () => { expect(createAlert).not.toHaveBeenCalled(); }); - it('shows alert when localStorage key is present', () => { + it('shows an alert when localStorage key is present', () => { localStorage.setItem(TOAST_MESSAGE_LOCALSTORAGE_KEY, 'true'); displaySuccessfulInvitationAlert(); diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index 3e778e50fb8..d7e5f9083b0 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -3,11 +3,18 @@ import Autosave from '~/autosave'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableForm from '~/issuable/issuable_form'; import setWindowLocation from 'helpers/set_window_location_helper'; - +import { confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; import { getSaveableFormChildren } from './helpers'; jest.mock('~/autosave'); +jest.mock('~/lib/utils/secret_detection', () => { + return { + ...jest.requireActual('~/lib/utils/secret_detection'), + confirmSensitiveAction: jest.fn(() => Promise.resolve(false)), + }; +}); + const createIssuable = (form) => { return new IssuableForm(form); }; @@ -35,16 +42,13 @@ describe('IssuableForm', () => { describe('autosave', () => { let $title; - let $description; beforeEach(() => { $title = $form.find('input[name*="[title]"]').get(0); - $description = $form.find('textarea[name*="[description]"]').get(0); }); afterEach(() => { $title = null; - $description = null; }); describe('initAutosave', () => { @@ -64,11 +68,6 @@ describe('IssuableForm', () => { ['/foo', 'bar=true', 'title'], 'autosave//foo/bar=true=title', ); - expect(Autosave).toHaveBeenCalledWith( - $description, - ['/foo', 'bar=true', 'description'], - 'autosave//foo/bar=true=description', - ); }); it("creates autosave fields without the searchTerm if it's an issue new form", () => { @@ -81,11 +80,6 @@ describe('IssuableForm', () => { ['/issues/new', '', 'title'], 'autosave//issues/new/bar=true=title', ); - expect(Autosave).toHaveBeenCalledWith( - $description, - ['/issues/new', '', 'description'], - 'autosave//issues/new/bar=true=description', - ); }); it.each([ @@ -106,7 +100,9 @@ describe('IssuableForm', () => { const children = getSaveableFormChildren($form[0]); - expect(Autosave).toHaveBeenCalledTimes(children.length); + // description autosave is being handled separately + // hence we're using children.length - 1 + expect(Autosave).toHaveBeenCalledTimes(children.length - 1); expect(Autosave).toHaveBeenLastCalledWith( $input.get(0), ['/', '', id], @@ -116,13 +112,12 @@ describe('IssuableForm', () => { }); describe('resetAutosave', () => { - it('calls reset on title and description', () => { + it('calls reset on title', () => { instance = createIssuable($form); instance.resetAutosave(); expect(instance.autosaves.get('title').reset).toHaveBeenCalledTimes(1); - expect(instance.autosaves.get('description').reset).toHaveBeenCalledTimes(1); }); it('resets autosave when submit', () => { @@ -245,4 +240,44 @@ describe('IssuableForm', () => { ); }); }); + + describe('Checks for sensitive token', () => { + let issueDescription; + const sensitiveMessage = 'token: glpat-1234567890abcdefghij'; + + beforeEach(() => { + issueDescription = $form.find('textarea[name*="[description]"]').get(0); + }); + + afterEach(() => { + issueDescription = null; + }); + + it('submits the form when no token is present', () => { + issueDescription.value = 'sample message'; + + const handleSubmit = jest.spyOn(IssuableForm.prototype, 'handleSubmit'); + const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave'); + createIssuable($form); + + $form.submit(); + + expect(handleSubmit).toHaveBeenCalled(); + expect(resetAutosave).toHaveBeenCalled(); + }); + + it('prevents form submission when token is present', () => { + issueDescription.value = sensitiveMessage; + + const handleSubmit = jest.spyOn(IssuableForm.prototype, 'handleSubmit'); + const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave'); + createIssuable($form); + + $form.submit(); + + expect(handleSubmit).toHaveBeenCalled(); + expect(confirmSensitiveAction).toHaveBeenCalledWith(i18n.descriptionPrompt); + expect(resetAutosave).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/issuable/popover/components/mr_popover_spec.js b/spec/frontend/issuable/popover/components/mr_popover_spec.js index d9e113eeaae..5b29ecfc0ba 100644 --- a/spec/frontend/issuable/popover/components/mr_popover_spec.js +++ b/spec/frontend/issuable/popover/components/mr_popover_spec.js @@ -95,7 +95,7 @@ describe('MR Popover', () => { expect(wrapper.text()).toContain('foo/bar!1'); }); - it('shows CI Icon if there is pipeline data', async () => { + it('shows CI Icon if there is pipeline data', () => { expect(wrapper.findComponent(CiIcon).exists()).toBe(true); }); }); @@ -108,7 +108,7 @@ describe('MR Popover', () => { return waitForPromises(); }); - it('does not show CI icon if there is no pipeline data', async () => { + it('does not show CI icon if there is no pipeline data', () => { expect(wrapper.findComponent(CiIcon).exists()).toBe(false); }); }); diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js index f8e47bc0a4b..f90b9117688 100644 --- a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js +++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js @@ -230,7 +230,7 @@ describe('AddIssuableForm', () => { ]); }); - it('emits an event with a "is_blocked_by" link type when the "is blocked by" radio input selected', async () => { + it('emits an event with a "is_blocked_by" link type when the "is blocked by" radio input selected', () => { findRadioGroup().vm.$emit('input', linkedIssueTypesMap.IS_BLOCKED_BY); findAddIssuableForm().trigger('submit'); diff --git a/spec/frontend/issues/issue_spec.js b/spec/frontend/issues/issue_spec.js index f04e766a78c..3b8a09714a7 100644 --- a/spec/frontend/issues/issue_spec.js +++ b/spec/frontend/issues/issue_spec.js @@ -1,6 +1,8 @@ import { getByText } from '@testing-library/dom'; +import htmlOpenIssue from 'test_fixtures/issues/open-issue.html'; +import htmlClosedIssue from 'test_fixtures/issues/closed-issue.html'; import MockAdapter from 'axios-mock-adapter'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import Issue from '~/issues/issue'; import axios from '~/lib/utils/axios_utils'; @@ -40,9 +42,9 @@ describe('Issue', () => { `('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => { beforeEach(() => { if (isIssueInitiallyOpen) { - loadHTMLFixture('issues/open-issue.html'); + setHTMLFixture(htmlOpenIssue); } else { - loadHTMLFixture('issues/closed-issue.html'); + setHTMLFixture(htmlClosedIssue); } testContext.issueCounter = getIssueCounter(); diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index b28a08e2fce..15dde76f49b 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -201,7 +201,7 @@ describe('CE IssuesListApp component', () => { return waitForPromises(); }); - it('renders', async () => { + it('renders', () => { expect(findIssuableList().props()).toMatchObject({ namespace: defaultProvide.fullPath, recentSearchesStorageKey: 'issues', @@ -803,7 +803,7 @@ describe('CE IssuesListApp component', () => { describe('when "sort" event is emitted by IssuableList', () => { it.each(Object.keys(urlSortParams))( 'updates to the new sort when payload is `%s`', - async (sortKey) => { + (sortKey) => { // Ensure initial sort key is different so we can trigger an update when emitting a sort key wrapper = sortKey === CREATED_DESC diff --git a/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js index 7bbb5a954ae..81739f6ef1d 100644 --- a/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js +++ b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js @@ -94,7 +94,7 @@ describe('JiraIssuesImportStatus', () => { }); }); - describe('alert message', () => { + describe('alert', () => { it('is hidden when dismissed', async () => { wrapper = mountComponent({ shouldShowInProgressAlert: true, diff --git a/spec/frontend/issues/new/components/type_select_spec.js b/spec/frontend/issues/new/components/type_select_spec.js new file mode 100644 index 00000000000..a25ace10fe7 --- /dev/null +++ b/spec/frontend/issues/new/components/type_select_spec.js @@ -0,0 +1,141 @@ +import { GlCollapsibleListbox, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import * as urlUtility from '~/lib/utils/url_utility'; +import TypeSelect from '~/issues/new/components/type_select.vue'; +import { TYPE_ISSUE, TYPE_INCIDENT } from '~/issues/constants'; +import { __ } from '~/locale'; + +const issuePath = 'issues/new'; +const incidentPath = 'issues/new?issuable_template=incident'; +const tracking = { + action: 'select_issue_type_incident', + label: 'select_issue_type_incident_dropdown_option', +}; + +const defaultProps = { + selectedType: '', + isIssueAllowed: true, + isIncidentAllowed: true, + issuePath, + incidentPath, +}; + +const issue = { + value: TYPE_ISSUE, + text: __('Issue'), + icon: 'issue-type-issue', + href: issuePath, +}; +const incident = { + value: TYPE_INCIDENT, + text: __('Incident'), + icon: 'issue-type-incident', + href: incidentPath, + tracking, +}; + +describe('Issue type select component', () => { + let wrapper; + let trackingSpy; + let navigationSpy; + + const createComponent = (props = {}) => { + wrapper = mount(TypeSelect, { + propsData: { ...defaultProps, ...props }, + }); + }; + + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findAllIcons = () => wrapper.findAllComponents(GlIcon); + const findListboxItemIcon = () => findAllIcons().at(2); + + describe('initial state', () => { + it('renders listbox with the correct header text', () => { + createComponent(); + + expect(findListbox().props('headerText')).toBe(TypeSelect.i18n.selectType); + }); + + it.each` + selectedType | toggleText + ${''} | ${TypeSelect.i18n.selectType} + ${TYPE_ISSUE} | ${TypeSelect.i18n.issuableType[TYPE_ISSUE]} + ${TYPE_INCIDENT} | ${TypeSelect.i18n.issuableType[TYPE_INCIDENT]} + `( + 'renders listbox with the correct toggle text when selectedType is "$selectedType"', + ({ selectedType, toggleText }) => { + createComponent({ selectedType }); + + expect(findListbox().props('toggleText')).toBe(toggleText); + }, + ); + + it.each` + isIssueAllowed | isIncidentAllowed | items + ${true} | ${true} | ${[issue, incident]} + ${true} | ${false} | ${[issue]} + ${false} | ${true} | ${[incident]} + `( + 'renders listbox with the correct items when isIssueAllowed is "$isIssueAllowed" and isIncidentAllowed is "$isIncidentAllowed"', + ({ isIssueAllowed, isIncidentAllowed, items }) => { + createComponent({ isIssueAllowed, isIncidentAllowed }); + + expect(findListbox().props('items')).toMatchObject(items); + }, + ); + + it.each` + isIssueAllowed | isIncidentAllowed | icon + ${true} | ${false} | ${issue.icon} + ${false} | ${true} | ${incident.icon} + `( + 'renders listbox item with the correct $icon icon', + ({ isIssueAllowed, isIncidentAllowed, icon }) => { + createComponent({ isIssueAllowed, isIncidentAllowed }); + findListbox().vm.$emit('shown'); + + expect(findListboxItemIcon().props('name')).toBe(icon); + }, + ); + }); + + describe('on type selected', () => { + beforeEach(() => { + navigationSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + navigationSpy.mockRestore(); + }); + + it.each` + selectedType | expectedUrl + ${TYPE_ISSUE} | ${issuePath} + ${TYPE_INCIDENT} | ${incidentPath} + `('navigates to the $selectedType issuable page', ({ selectedType, expectedUrl }) => { + createComponent(); + findListbox().vm.$emit('select', selectedType); + + expect(navigationSpy).toHaveBeenCalledWith(expectedUrl); + }); + + it("doesn't call tracking APIs when tracking is not available for the issuable type", () => { + createComponent(); + findListbox().vm.$emit('select', TYPE_ISSUE); + + expect(trackingSpy).not.toHaveBeenCalled(); + }); + + it('calls tracking APIs when tracking is available for the issuable type', () => { + createComponent(); + findListbox().vm.$emit('select', TYPE_INCIDENT); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, tracking.action, { + label: tracking.label, + }); + }); + }); +}); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index 1006f54eeaf..a4ee9a62926 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -1,6 +1,5 @@ import { GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; -import { setHTMLFixture } from 'helpers/fixtures'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -100,22 +99,6 @@ describe('Issuable output', () => { }; beforeEach(() => { - setHTMLFixture(` - <div> - <title>Title</title> - <div class="detail-page-description content-block"> - <details open> - <summary>One</summary> - </details> - <details> - <summary>Two</summary> - </details> - </div> - <div class="flash-container"></div> - <span id="task_status"></span> - </div> - `); - jest.spyOn(eventHub, '$emit'); axiosMock = new MockAdapter(axios); @@ -348,7 +331,7 @@ describe('Issuable output', () => { }); describe('when title is not in view', () => { - beforeEach(async () => { + beforeEach(() => { wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); }); diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 740b2f782e4..c6869573b2e 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -1,8 +1,6 @@ -import $ from 'jquery'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; -import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -21,13 +19,13 @@ import { getIssueDetailsResponse, projectWorkItemTypesQueryResponse, } from 'jest/work_items/mock_data'; -import { descriptionProps as initialProps, descriptionHtmlWithList } from '../mock_data/mock_data'; +import { + descriptionProps as initialProps, + descriptionHtmlWithList, + descriptionHtmlWithDetailsTag, +} from '../mock_data/mock_data'; jest.mock('~/alert'); -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - updateHistory: jest.fn(), -})); jest.mock('~/task_list'); jest.mock('~/behaviors/markdown/render_gfm'); @@ -87,21 +85,6 @@ describe('Description component', () => { beforeEach(() => { window.gon = { sprite_icons: mockSpriteIcons }; - - setWindowLocation(TEST_HOST); - - if (!document.querySelector('.issuable-meta')) { - const metaData = document.createElement('div'); - metaData.classList.add('issuable-meta'); - metaData.innerHTML = - '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>'; - - document.body.appendChild(metaData); - } - }); - - afterAll(() => { - $('.issuable-meta .flash-container').remove(); }); it('doesnt animate first description changes', async () => { @@ -132,6 +115,19 @@ describe('Description component', () => { expect(findGfmContent().classes()).toContain('issue-realtime-trigger-pulse'); }); + it('doesnt animate expand/collapse of details elements', async () => { + createComponent(); + + await wrapper.setProps({ descriptionHtml: descriptionHtmlWithDetailsTag.collapsed }); + expect(findGfmContent().classes()).not.toContain('issue-realtime-pre-pulse'); + + await wrapper.setProps({ descriptionHtml: descriptionHtmlWithDetailsTag.expanded }); + expect(findGfmContent().classes()).not.toContain('issue-realtime-pre-pulse'); + + await wrapper.setProps({ descriptionHtml: descriptionHtmlWithDetailsTag.collapsed }); + expect(findGfmContent().classes()).not.toContain('issue-realtime-pre-pulse'); + }); + it('applies syntax highlighting and math when description changed', async () => { createComponent(); @@ -166,7 +162,7 @@ describe('Description component', () => { expect(TaskList).toHaveBeenCalled(); }); - it('does not re-init the TaskList when canUpdate is false', async () => { + it('does not re-init the TaskList when canUpdate is false', () => { createComponent({ props: { issuableType: 'issuableType', @@ -202,46 +198,6 @@ describe('Description component', () => { }); }); - describe('taskStatus', () => { - it('adds full taskStatus', async () => { - createComponent({ - props: { - taskStatus: '1 of 1', - }, - }); - await nextTick(); - - expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe( - '1 of 1', - ); - }); - - it('adds short taskStatus', async () => { - createComponent({ - props: { - taskStatus: '1 of 1', - }, - }); - await nextTick(); - - expect(document.querySelector('.issuable-meta #task_status_short').textContent.trim()).toBe( - '1/1 checklist item', - ); - }); - - it('clears task status text when no tasks are present', async () => { - createComponent({ - props: { - taskStatus: '0 of 0', - }, - }); - - await nextTick(); - - expect(document.querySelector('.issuable-meta #task_status').textContent.trim()).toBe(''); - }); - }); - describe('with list', () => { beforeEach(async () => { createComponent({ diff --git a/spec/frontend/issues/show/components/edited_spec.js b/spec/frontend/issues/show/components/edited_spec.js index a509627c347..dc0c7f5be46 100644 --- a/spec/frontend/issues/show/components/edited_spec.js +++ b/spec/frontend/issues/show/components/edited_spec.js @@ -1,41 +1,84 @@ +import { GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { getTimeago } from '~/lib/utils/datetime_utility'; import Edited from '~/issues/show/components/edited.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -const timeago = getTimeago(); - describe('Edited component', () => { let wrapper; - const findAuthorLink = () => wrapper.find('a'); + const timeago = getTimeago(); + const updatedAt = '2017-05-15T12:31:04.428Z'; + + const findAuthorLink = () => wrapper.findComponent(GlLink); const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); const formatText = (text) => text.trim().replace(/\s\s+/g, ' '); const mountComponent = (propsData) => mount(Edited, { propsData }); - const updatedAt = '2017-05-15T12:31:04.428Z'; - it('renders an edited at+by string', () => { - wrapper = mountComponent({ - updatedAt, - updatedByName: 'Some User', - updatedByPath: '/some_user', + describe('task status section', () => { + describe('task status text', () => { + it('renders when there is a task status', () => { + wrapper = mountComponent({ taskCompletionStatus: { completed_count: 1, count: 3 } }); + + expect(wrapper.text()).toContain('1 of 3 checklist items completed'); + }); + + it('does not render when task count is 0', () => { + wrapper = mountComponent({ taskCompletionStatus: { completed_count: 0, count: 0 } }); + + expect(wrapper.text()).not.toContain('0 of 0 checklist items completed'); + }); }); - expect(formatText(wrapper.text())).toBe(`Edited ${timeago.format(updatedAt)} by Some User`); - expect(findAuthorLink().attributes('href')).toBe('/some_user'); - expect(findTimeAgoTooltip().exists()).toBe(true); + describe('checkmark', () => { + it('renders when all tasks are completed', () => { + wrapper = mountComponent({ taskCompletionStatus: { completed_count: 3, count: 3 } }); + + expect(wrapper.text()).toContain('✓'); + }); + + it('does not render when tasks are incomplete', () => { + wrapper = mountComponent({ taskCompletionStatus: { completed_count: 2, count: 3 } }); + + expect(wrapper.text()).not.toContain('✓'); + }); + + it('does not render when task count is 0', () => { + wrapper = mountComponent({ taskCompletionStatus: { completed_count: 0, count: 0 } }); + + expect(wrapper.text()).not.toContain('✓'); + }); + }); + + describe('middot', () => { + it('renders when there is also "Edited by" text', () => { + wrapper = mountComponent({ + taskCompletionStatus: { completed_count: 3, count: 3 }, + updatedAt, + }); + + expect(wrapper.text()).toContain('·'); + }); + + it('does not render when there is no "Edited by" text', () => { + wrapper = mountComponent({ taskCompletionStatus: { completed_count: 3, count: 3 } }); + + expect(wrapper.text()).not.toContain('·'); + }); + }); }); - it('if no updatedAt is provided, no time element will be rendered', () => { + it('renders an edited at+by string', () => { wrapper = mountComponent({ + updatedAt, updatedByName: 'Some User', updatedByPath: '/some_user', }); - expect(formatText(wrapper.text())).toBe('Edited by Some User'); + expect(formatText(wrapper.text())).toBe(`Edited ${timeago.format(updatedAt)} by Some User`); expect(findAuthorLink().attributes('href')).toBe('/some_user'); - expect(findTimeAgoTooltip().exists()).toBe(false); + expect(findTimeAgoTooltip().exists()).toBe(true); }); it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => { diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index 5c145ed4707..c7116f380a1 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -81,11 +81,8 @@ describe('Description field component', () => { autofocus: true, supportsQuickActions: true, quickActionsDocsPath: expect.any(String), - }); - - expect(findMarkdownEditor().vm.$attrs).toMatchObject({ - 'enable-autocomplete': true, - 'markdown-docs-path': '/', + markdownDocsPath: '/', + enableAutocomplete: true, }); }); diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index 58ec7387851..db3435855f6 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -440,7 +440,7 @@ describe('HeaderActions component', () => { wrapper = mountComponent({ props: { isIssueAuthor: false } }); }); - it("doesn't render", async () => { + it("doesn't render", () => { expect(findAbuseCategorySelector().exists()).toEqual(false); }); diff --git a/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js index 6b68e7a0da6..b13a1041eda 100644 --- a/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js +++ b/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js @@ -86,7 +86,6 @@ describe('Create Timeline events', () => { provide: { fullPath: 'group/project', issuableId: '1', - glFeatures: { incidentEventTags: true }, }, apolloProvider, }); diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js index 0f4fb02a40b..5a49b29c458 100644 --- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js @@ -163,7 +163,7 @@ describe('Incident Tabs component', () => { mountComponent({ mount: mountExtended }); }); - it('shows only the summary tab by default', async () => { + it('shows only the summary tab by default', () => { expect(findActiveTabs()).toHaveLength(1); expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.summaryTitle); }); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js index af01fd34336..9c4662ce38f 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js @@ -113,17 +113,7 @@ describe('Timeline events form', () => { ]); }); - describe('with incident_event_tag feature flag enabled', () => { - beforeEach(() => { - mountComponent( - {}, - {}, - { - incidentEventTags: true, - }, - ); - }); - + describe('Event Tags', () => { describe('event tags listbox', () => { it('should render option list from provided array', () => { expect(findTagsListbox().props('items')).toEqual(mockTags); @@ -255,7 +245,7 @@ describe('Timeline events form', () => { expect(findMinuteInput().element.value).toBe('0'); }); - it('should disable the save buttons when event content does not exist', async () => { + it('should disable the save buttons when event content does not exist', () => { expect(findSubmitButton().props('disabled')).toBe(true); expect(findSubmitAndAddButton().props('disabled')).toBe(true); }); diff --git a/spec/frontend/issues/show/components/locked_warning_spec.js b/spec/frontend/issues/show/components/locked_warning_spec.js index f8a8c999632..2e786b665d0 100644 --- a/spec/frontend/issues/show/components/locked_warning_spec.js +++ b/spec/frontend/issues/show/components/locked_warning_spec.js @@ -35,11 +35,11 @@ describe('LockedWarning component', () => { expect(alert.props('dismissible')).toBe(false); }); - it(`displays correct message`, async () => { + it(`displays correct message`, () => { expect(alert.text()).toMatchInterpolatedText(sprintf(i18n.alertMessage, { issuableType })); }); - it(`displays a link with correct text`, async () => { + it(`displays a link with correct text`, () => { expect(link.exists()).toBe(true); expect(link.text()).toBe(`the ${issuableType}`); }); diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index 86d09665947..ed969a08ac5 100644 --- a/spec/frontend/issues/show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -5,7 +5,7 @@ export const initialRequest = { title_text: 'this is a title', description: '<p>this is a description!</p>', description_text: 'this is a description', - task_status: '2 of 4 completed', + task_completion_status: { completed_count: 2, count: 4 }, updated_at: '2015-05-15T12:31:04.428Z', updated_by_name: 'Some User', updated_by_path: '/some_user', @@ -17,7 +17,7 @@ export const secondRequest = { title_text: '2', description: '<p>42</p>', description_text: '42', - task_status: '0 of 0 completed', + task_completion_status: { completed_count: 0, count: 0 }, updated_at: '2016-05-15T12:31:04.428Z', updated_by_name: 'Other User', updated_by_path: '/other_user', @@ -30,7 +30,7 @@ export const putRequest = { title_text: 'PUT', description: '<p>PUT_DESC</p>', description_text: 'PUT_DESC', - task_status: '0 of 0 completed', + task_completion_status: { completed_count: 0, count: 0 }, updated_at: '2016-05-15T12:31:04.428Z', updated_by_name: 'Other User', updated_by_path: '/other_user', @@ -41,7 +41,6 @@ export const descriptionProps = { canUpdate: true, descriptionHtml: 'test', descriptionText: 'test', - taskStatus: '', updateUrl: TEST_HOST, }; @@ -60,6 +59,7 @@ export const appProps = { initialTitleText: '', initialDescriptionHtml: 'test', initialDescriptionText: 'test', + initialTaskCompletionStatus: { completed_count: 2, count: 4 }, lockVersion: 1, issueType: 'issue', markdownPreviewPath: '/', @@ -79,3 +79,16 @@ export const descriptionHtmlWithList = ` <li data-sourcepos="3:1-3:8">todo 3</li> </ul> `; + +export const descriptionHtmlWithDetailsTag = { + expanded: ` + <details open="true"> + <summary>Section 1</summary> + <p>Data</p> + </details>'`, + collapsed: ` + <details> + <summary>Section 1</summary> + <p>Data</p> + </details>'`, +}; diff --git a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js index 701512953df..a3bc8e861b2 100644 --- a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js +++ b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js @@ -147,7 +147,7 @@ describe('SourceBranchDropdown', () => { }); describe('when selecting a listbox item', () => { - it('emits `change` event with the selected branch name', async () => { + it('emits `change` event with the selected branch name', () => { const mockBranchName = mockProject.repository.branchNames[1]; findListbox().vm.$emit('select', mockBranchName); expect(wrapper.emitted('change')[1]).toEqual([mockBranchName]); @@ -157,7 +157,7 @@ describe('SourceBranchDropdown', () => { describe('when `selectedBranchName` prop is specified', () => { const mockBranchName = mockProject.repository.branchNames[2]; - beforeEach(async () => { + beforeEach(() => { wrapper.setProps({ selectedBranchName: mockBranchName, }); diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js index d99d8986296..ce6861ff460 100644 --- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js @@ -6,7 +6,7 @@ import SignInGitlabMultiversion from '~/jira_connect/subscriptions/pages/sign_in import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue'; import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue'; -import { updateInstallation } from '~/jira_connect/subscriptions/api'; +import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/api'; import { reloadPage, persistBaseUrl, retrieveBaseUrl } from '~/jira_connect/subscriptions/utils'; import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants'; @@ -72,6 +72,10 @@ describe('SignInGitlabMultiversion', () => { expect(findSetupInstructions().exists()).toBe(true); }); + it('calls setApiBaseURL with correct params', () => { + expect(setApiBaseURL).toHaveBeenCalledWith(mockBasePath); + }); + describe('when SetupInstructions emits `next` event', () => { beforeEach(async () => { findSetupInstructions().vm.$emit('next'); @@ -102,6 +106,10 @@ describe('SignInGitlabMultiversion', () => { expect(findSignInOauthButton().props('gitlabBasePath')).toBe(GITLAB_COM_BASE_PATH); }); + it('does not call setApiBaseURL', () => { + expect(setApiBaseURL).not.toHaveBeenCalled(); + }); + describe('when button emits `sign-in` event', () => { it('emits `sign-in-oauth` event', () => { const button = findSignInOauthButton(); diff --git a/spec/frontend/jira_import/components/jira_import_form_spec.js b/spec/frontend/jira_import/components/jira_import_form_spec.js index c7db9f429de..7fd6398aaa4 100644 --- a/spec/frontend/jira_import/components/jira_import_form_spec.js +++ b/spec/frontend/jira_import/components/jira_import_form_spec.js @@ -304,7 +304,7 @@ describe('JiraImportForm', () => { expect(getContinueButton().text()).toBe('Continue'); }); - it('is in loading state when the form is submitting', async () => { + it('is in loading state when the form is submitting', () => { wrapper = mountComponent({ isSubmitting: true }); expect(getContinueButton().props('loading')).toBe(true); @@ -416,7 +416,7 @@ describe('JiraImportForm', () => { wrapper = mountComponent({ hasMoreUsers: true }); }); - it('calls the GraphQL user mapping mutation', async () => { + it('calls the GraphQL user mapping mutation', () => { const mutationArguments = { mutation: getJiraUserMappingMutation, variables: { diff --git a/spec/frontend/jobs/components/job/manual_variables_form_spec.js b/spec/frontend/jobs/components/job/manual_variables_form_spec.js index 98b9ca78a45..c8c865dd28e 100644 --- a/spec/frontend/jobs/components/job/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/job/manual_variables_form_spec.js @@ -54,7 +54,7 @@ describe('Manual Variables Form', () => { }); }; - const createComponentWithApollo = async ({ props = {} } = {}) => { + const createComponentWithApollo = ({ props = {} } = {}) => { const requestHandlers = [[getJobQuery, getJobQueryResponse]]; mockApollo = createMockApollo(requestHandlers); @@ -309,7 +309,7 @@ describe('Manual Variables Form', () => { await createComponentWithApollo(); }); - it('delete variable button placeholder should only exist when a user cannot remove', async () => { + it('delete variable button placeholder should only exist when a user cannot remove', () => { expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); }); diff --git a/spec/frontend/jobs/components/job/sidebar_header_spec.js b/spec/frontend/jobs/components/job/sidebar_header_spec.js index da97945f9bf..cf182330578 100644 --- a/spec/frontend/jobs/components/job/sidebar_header_spec.js +++ b/spec/frontend/jobs/components/job/sidebar_header_spec.js @@ -31,7 +31,7 @@ describe('Sidebar Header', () => { }); }; - const createComponentWithApollo = async ({ props = {}, restJob = {} } = {}) => { + const createComponentWithApollo = ({ props = {}, restJob = {} } = {}) => { const getJobQueryResponse = jest.fn().mockResolvedValue(mockJobResponse); const requestHandlers = [[getJobQuery, getJobQueryResponse]]; diff --git a/spec/frontend/jobs/components/job/sidebar_spec.js b/spec/frontend/jobs/components/job/sidebar_spec.js index cefa4582c15..fbff64b4d78 100644 --- a/spec/frontend/jobs/components/job/sidebar_spec.js +++ b/spec/frontend/jobs/components/job/sidebar_spec.js @@ -139,7 +139,7 @@ describe('Sidebar details block', () => { return store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); }); - it('renders list of jobs', async () => { + it('renders list of jobs', () => { expect(findJobsContainer().exists()).toBe(true); }); }); @@ -147,7 +147,7 @@ describe('Sidebar details block', () => { describe('when job data changes', () => { const stageArg = job.pipeline.details.stages.find((stage) => stage.name === job.stage); - beforeEach(async () => { + beforeEach(() => { jest.spyOn(store, 'dispatch'); }); diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js index 55fe534aa3b..79bc765f181 100644 --- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js +++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js @@ -122,7 +122,7 @@ describe('Job actions cell', () => { ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${playableJob.id} ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${retryableJob.id} ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} | ${cancelableJob.id} - `('performs the $action mutation', async ({ button, jobType, mutationFile, handler, jobId }) => { + `('performs the $action mutation', ({ button, jobType, mutationFile, handler, jobId }) => { createComponent(jobType, [[mutationFile, handler]]); button().vm.$emit('click'); diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 6247cfcc640..19033c227d9 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -275,5 +275,42 @@ describe('Job table app', () => { url: `${TEST_HOST}/?statuses=FAILED`, }); }); + + it('resets query param after clearing tokens', () => { + createComponent(); + + jest.spyOn(urlUtils, 'updateHistory'); + + findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(successHandler).toHaveBeenCalledWith({ + first: 30, + fullPath: 'gitlab-org/gitlab', + statuses: 'FAILED', + }); + expect(countSuccessHandler).toHaveBeenCalledWith({ + fullPath: 'gitlab-org/gitlab', + statuses: 'FAILED', + }); + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?statuses=FAILED`, + }); + + findFilteredSearch().vm.$emit('filterJobsBySearch', []); + + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/`, + }); + + expect(successHandler).toHaveBeenCalledWith({ + first: 30, + fullPath: 'gitlab-org/gitlab', + statuses: null, + }); + expect(countSuccessHandler).toHaveBeenCalledWith({ + fullPath: 'gitlab-org/gitlab', + statuses: null, + }); + }); }); }); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index 483b4ca711f..fb1ded7b4ef 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -1,7 +1,9 @@ import mockJobsCount from 'test_fixtures/graphql/jobs/get_jobs_count.query.graphql.json'; import mockJobsEmpty from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.empty.json'; import mockJobsPaginated from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.paginated.json'; +import mockAllJobsPaginated from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.paginated.json'; import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json'; +import mockAllJobs from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.json'; import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json'; import { TEST_HOST } from 'spec/test_constants'; import { TOKEN_TYPE_STATUS } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -11,8 +13,10 @@ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); // Fixtures generated at spec/frontend/fixtures/jobs.rb export const mockJobsResponsePaginated = mockJobsPaginated; +export const mockAllJobsResponsePaginated = mockAllJobsPaginated; export const mockJobsResponseEmpty = mockJobsEmpty; export const mockJobsNodes = mockJobs.data.project.jobs.nodes; +export const mockAllJobsNodes = mockAllJobs.data.jobs.nodes; export const mockJobsNodesAsGuest = mockJobsAsGuest.data.project.jobs.nodes; export const mockJobsCountResponse = mockJobsCount; @@ -922,6 +926,14 @@ export const stages = [ }, ]; +export const statuses = { + success: 'SUCCESS', + failed: 'FAILED', + canceled: 'CANCELED', + pending: 'PENDING', + running: 'RUNNING', +}; + export default { id: 4757, artifact: { diff --git a/spec/frontend/labels/components/delete_label_modal_spec.js b/spec/frontend/labels/components/delete_label_modal_spec.js index 7654d218209..19aef42528a 100644 --- a/spec/frontend/labels/components/delete_label_modal_spec.js +++ b/spec/frontend/labels/components/delete_label_modal_spec.js @@ -1,60 +1,55 @@ import { GlModal } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import DeleteLabelModal from '~/labels/components/delete_label_modal.vue'; -const MOCK_MODAL_DATA = { - labelName: 'label 1', - subjectName: 'GitLab Org', - destroyPath: `${TEST_HOST}/1`, -}; - describe('~/labels/components/delete_label_modal', () => { let wrapper; - const createComponent = () => { - wrapper = extendedWrapper( - mount(DeleteLabelModal, { - propsData: { - selector: '.js-test-btn', - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: - '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', - }), - }, - }), - ); + const mountComponent = () => { + const button = document.createElement('button'); + button.classList.add('js-test-btn'); + button.dataset.destroyPath = `${TEST_HOST}/1`; + button.dataset.labelName = 'label 1'; + button.dataset.subjectName = 'GitLab Org'; + document.body.append(button); + + wrapper = mountExtended(DeleteLabelModal, { + propsData: { + selector: '.js-test-btn', + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }); + + button.click(); }; const findModal = () => wrapper.findComponent(GlModal); - const findPrimaryModalButton = () => wrapper.findByTestId('delete-button'); - - describe('template', () => { - describe('when modal data is set', () => { - beforeEach(() => { - createComponent(); - wrapper.vm.labelName = MOCK_MODAL_DATA.labelName; - wrapper.vm.subjectName = MOCK_MODAL_DATA.subjectName; - wrapper.vm.destroyPath = MOCK_MODAL_DATA.destroyPath; - }); - - it('renders GlModal', () => { - expect(findModal().exists()).toBe(true); - }); - - it('displays the label name and subject name', () => { - expect(findModal().text()).toContain( - `${MOCK_MODAL_DATA.labelName} will be permanently deleted from ${MOCK_MODAL_DATA.subjectName}. This cannot be undone`, - ); - }); - - it('passes the destroyPath to the button', () => { - expect(findPrimaryModalButton().attributes('href')).toBe(MOCK_MODAL_DATA.destroyPath); - }); + const findDeleteButton = () => wrapper.findByRole('link', { name: 'Delete label' }); + + describe('when modal data is set', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders GlModal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('displays the label name and subject name', () => { + expect(findModal().text()).toContain( + `label 1 will be permanently deleted from GitLab Org. This cannot be undone`, + ); + }); + + it('passes the destroyPath to the button', () => { + expect(findDeleteButton().attributes('href')).toBe('http://test.host/1'); }); }); }); diff --git a/spec/frontend/lib/apollo/indexed_db_persistent_storage_spec.js b/spec/frontend/lib/apollo/indexed_db_persistent_storage_spec.js new file mode 100644 index 00000000000..f96364a918e --- /dev/null +++ b/spec/frontend/lib/apollo/indexed_db_persistent_storage_spec.js @@ -0,0 +1,90 @@ +import { IndexedDBPersistentStorage } from '~/lib/apollo/indexed_db_persistent_storage'; +import { db } from '~/lib/apollo/local_db'; +import CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS from './mock_data/cache_with_persist_directive_and_field.json'; + +describe('IndexedDBPersistentStorage', () => { + let subject; + + const seedData = async (cacheKey, data = CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS) => { + const { ROOT_QUERY, ...rest } = data; + + await db.table('queries').put(ROOT_QUERY, cacheKey); + + const asyncPuts = Object.entries(rest).map(async ([key, value]) => { + const { + groups: { type, gid }, + } = /^(?<type>.+?):(?<gid>.+)$/.exec(key); + const tableName = type.toLowerCase(); + + if (tableName !== 'projectmember' && tableName !== 'groupmember') { + await db.table(tableName).put(value, gid); + } + }); + + await Promise.all(asyncPuts); + }; + + beforeEach(async () => { + subject = await IndexedDBPersistentStorage.create(); + }); + + afterEach(() => { + db.close(); + }); + + it('returns empty response if there is nothing stored in the DB', async () => { + const result = await subject.getItem('some-query'); + + expect(result).toEqual({}); + }); + + it('returns stored cache if cache was persisted in IndexedDB', async () => { + await seedData('issues_list', CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS); + + const result = await subject.getItem('issues_list'); + expect(result).toEqual(CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS); + }); + + it('puts the results in database on `setItem` call', async () => { + await subject.setItem( + 'issues_list', + JSON.stringify({ + ROOT_QUERY: 'ROOT_QUERY_KEY', + 'Project:gid://gitlab/Project/6': { + __typename: 'Project', + id: 'gid://gitlab/Project/6', + }, + }), + ); + + await expect(db.table('queries').get('issues_list')).resolves.toEqual('ROOT_QUERY_KEY'); + await expect(db.table('project').get('gid://gitlab/Project/6')).resolves.toEqual({ + __typename: 'Project', + id: 'gid://gitlab/Project/6', + }); + }); + + it('does not put results into non-existent table', async () => { + const queryId = 'issues_list'; + + await subject.setItem( + queryId, + JSON.stringify({ + ROOT_QUERY: 'ROOT_QUERY_KEY', + 'DNE:gid://gitlab/DNE/1': {}, + }), + ); + + expect(db.tables.map((x) => x.name)).not.toContain('dne'); + }); + + it('when removeItem is called, clears all data', async () => { + await seedData('issues_list', CACHE_WITH_PERSIST_DIRECTIVE_AND_FIELDS); + + await subject.removeItem(); + + const actual = await Promise.all(db.tables.map((x) => x.toArray())); + + expect(actual).toEqual(db.tables.map(() => [])); + }); +}); diff --git a/spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json b/spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json index c0651517986..0dfc2240cc3 100644 --- a/spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json +++ b/spec/frontend/lib/apollo/mock_data/cache_with_persist_directive_and_field.json @@ -171,38 +171,6 @@ } ] }, - "projectMembers({\"relations\":[\"DIRECT\",\"INHERITED\",\"INVITED_GROUPS\"],\"search\":\"\"})": { - "__typename": "MemberInterfaceConnection", - "nodes": [ - { - "__ref": "ProjectMember:gid://gitlab/ProjectMember/54" - }, - { - "__ref": "ProjectMember:gid://gitlab/ProjectMember/53" - }, - { - "__ref": "ProjectMember:gid://gitlab/ProjectMember/52" - }, - { - "__ref": "GroupMember:gid://gitlab/GroupMember/26" - }, - { - "__ref": "GroupMember:gid://gitlab/GroupMember/25" - }, - { - "__ref": "GroupMember:gid://gitlab/GroupMember/11" - }, - { - "__ref": "GroupMember:gid://gitlab/GroupMember/10" - }, - { - "__ref": "GroupMember:gid://gitlab/GroupMember/9" - }, - { - "__ref": "GroupMember:gid://gitlab/GroupMember/1" - } - ] - }, "milestones({\"includeAncestors\":true,\"searchTitle\":\"\",\"sort\":\"EXPIRED_LAST_DUE_DATE_ASC\",\"state\":\"active\"})": { "__typename": "MilestoneConnection", "nodes": [ @@ -1999,125 +1967,6 @@ "healthStatus": null, "weight": null }, - "UserCore:gid://gitlab/User/9": { - "__typename": "UserCore", - "id": "gid://gitlab/User/9", - "avatarUrl": "https://secure.gravatar.com/avatar/175e76e391370beeb21914ab74c2efd4?s=80&d=identicon", - "name": "Kiyoko Bahringer", - "username": "jamie" - }, - "ProjectMember:gid://gitlab/ProjectMember/54": { - "__typename": "ProjectMember", - "id": "gid://gitlab/ProjectMember/54", - "user": { - "__ref": "UserCore:gid://gitlab/User/9" - } - }, - "UserCore:gid://gitlab/User/19": { - "__typename": "UserCore", - "id": "gid://gitlab/User/19", - "avatarUrl": "https://secure.gravatar.com/avatar/3126153e3301ebf7cc8f7c99e57007f2?s=80&d=identicon", - "name": "Cecile Hermann", - "username": "jeannetta_breitenberg" - }, - "ProjectMember:gid://gitlab/ProjectMember/53": { - "__typename": "ProjectMember", - "id": "gid://gitlab/ProjectMember/53", - "user": { - "__ref": "UserCore:gid://gitlab/User/19" - } - }, - "UserCore:gid://gitlab/User/2": { - "__typename": "UserCore", - "id": "gid://gitlab/User/2", - "avatarUrl": "https://secure.gravatar.com/avatar/a138e401136c90561f949297387a3bb9?s=80&d=identicon", - "name": "Tish Treutel", - "username": "liana.larkin" - }, - "ProjectMember:gid://gitlab/ProjectMember/52": { - "__typename": "ProjectMember", - "id": "gid://gitlab/ProjectMember/52", - "user": { - "__ref": "UserCore:gid://gitlab/User/2" - } - }, - "UserCore:gid://gitlab/User/13": { - "__typename": "UserCore", - "id": "gid://gitlab/User/13", - "avatarUrl": "https://secure.gravatar.com/avatar/0ce8057f452296a13b5620bb2d9ede57?s=80&d=identicon", - "name": "Tammy Gusikowski", - "username": "xuan_oreilly" - }, - "GroupMember:gid://gitlab/GroupMember/26": { - "__typename": "GroupMember", - "id": "gid://gitlab/GroupMember/26", - "user": { - "__ref": "UserCore:gid://gitlab/User/13" - } - }, - "UserCore:gid://gitlab/User/21": { - "__typename": "UserCore", - "id": "gid://gitlab/User/21", - "avatarUrl": "https://secure.gravatar.com/avatar/415b09d256f26403384363d7948c4d77?s=80&d=identicon", - "name": "Twanna Hegmann", - "username": "jamaal" - }, - "GroupMember:gid://gitlab/GroupMember/25": { - "__typename": "GroupMember", - "id": "gid://gitlab/GroupMember/25", - "user": { - "__ref": "UserCore:gid://gitlab/User/21" - } - }, - "UserCore:gid://gitlab/User/14": { - "__typename": "UserCore", - "id": "gid://gitlab/User/14", - "avatarUrl": "https://secure.gravatar.com/avatar/e99697c6664381b0351b7617717dd49b?s=80&d=identicon", - "name": "Francie Cole", - "username": "greg.wisoky" - }, - "GroupMember:gid://gitlab/GroupMember/11": { - "__typename": "GroupMember", - "id": "gid://gitlab/GroupMember/11", - "user": { - "__ref": "UserCore:gid://gitlab/User/14" - } - }, - "UserCore:gid://gitlab/User/7": { - "__typename": "UserCore", - "id": "gid://gitlab/User/7", - "avatarUrl": "https://secure.gravatar.com/avatar/3a382857e362d6cce60d3806dd173444?s=80&d=identicon", - "name": "Ivan Carter", - "username": "ethyl" - }, - "GroupMember:gid://gitlab/GroupMember/10": { - "__typename": "GroupMember", - "id": "gid://gitlab/GroupMember/10", - "user": { - "__ref": "UserCore:gid://gitlab/User/7" - } - }, - "UserCore:gid://gitlab/User/15": { - "__typename": "UserCore", - "id": "gid://gitlab/User/15", - "avatarUrl": "https://secure.gravatar.com/avatar/79653006ff557e081db02deaa4ca281c?s=80&d=identicon", - "name": "Danuta Dare", - "username": "maddie_hintz" - }, - "GroupMember:gid://gitlab/GroupMember/9": { - "__typename": "GroupMember", - "id": "gid://gitlab/GroupMember/9", - "user": { - "__ref": "UserCore:gid://gitlab/User/15" - } - }, - "GroupMember:gid://gitlab/GroupMember/1": { - "__typename": "GroupMember", - "id": "gid://gitlab/GroupMember/1", - "user": { - "__ref": "UserCore:gid://gitlab/User/1" - } - }, "Milestone:gid://gitlab/Milestone/30": { "__typename": "Milestone", "id": "gid://gitlab/Milestone/30", diff --git a/spec/frontend/lib/apollo/persist_link_spec.js b/spec/frontend/lib/apollo/persist_link_spec.js index ddb861bcee0..f3afc4ba8cd 100644 --- a/spec/frontend/lib/apollo/persist_link_spec.js +++ b/spec/frontend/lib/apollo/persist_link_spec.js @@ -56,7 +56,7 @@ describe('~/lib/apollo/persist_link', () => { expect(childFields.some((field) => field.name.value === '__persist')).toBe(false); }); - it('decorates the response with `__persist: true` is there is `__persist` field in the query', async () => { + it('decorates the response with `__persist: true` is there is `__persist` field in the query', () => { const link = getPersistLink().concat(terminatingLink); subscription = execute(link, { query: QUERY_WITH_PERSIST_FIELD }).subscribe(({ data }) => { @@ -64,7 +64,7 @@ describe('~/lib/apollo/persist_link', () => { }); }); - it('does not decorate the response with `__persist: true` is there if query is not persistent', async () => { + it('does not decorate the response with `__persist: true` is there if query is not persistent', () => { const link = getPersistLink().concat(terminatingLink); subscription = execute(link, { query: DEFAULT_QUERY }).subscribe(({ data }) => { diff --git a/spec/frontend/lib/utils/chart_utils_spec.js b/spec/frontend/lib/utils/chart_utils_spec.js index 65bb68c5017..3b34b0ef672 100644 --- a/spec/frontend/lib/utils/chart_utils_spec.js +++ b/spec/frontend/lib/utils/chart_utils_spec.js @@ -1,4 +1,8 @@ -import { firstAndLastY } from '~/lib/utils/chart_utils'; +import { firstAndLastY, getToolboxOptions } from '~/lib/utils/chart_utils'; +import { __ } from '~/locale'; +import * as iconUtils from '~/lib/utils/icon_utils'; + +jest.mock('~/lib/utils/icon_utils'); describe('Chart utils', () => { describe('firstAndLastY', () => { @@ -12,4 +16,53 @@ describe('Chart utils', () => { expect(firstAndLastY(data)).toEqual([1, 3]); }); }); + + describe('getToolboxOptions', () => { + describe('when icons are successfully fetched', () => { + beforeEach(() => { + iconUtils.getSvgIconPathContent.mockImplementation((name) => + Promise.resolve(`${name}-svg-path-mock`), + ); + }); + + it('returns toolbox config', async () => { + await expect(getToolboxOptions()).resolves.toEqual({ + toolbox: { + feature: { + dataZoom: { + icon: { + zoom: 'path://marquee-selection-svg-path-mock', + back: 'path://redo-svg-path-mock', + }, + }, + restore: { + icon: 'path://repeat-svg-path-mock', + }, + saveAsImage: { + icon: 'path://download-svg-path-mock', + }, + }, + }, + }); + }); + }); + + describe('when icons are not successfully fetched', () => { + const error = new Error(); + + beforeEach(() => { + iconUtils.getSvgIconPathContent.mockRejectedValue(error); + jest.spyOn(console, 'warn').mockImplementation(); + }); + + it('returns empty object and calls `console.warn`', async () => { + await expect(getToolboxOptions()).resolves.toEqual({}); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + __('SVG could not be rendered correctly: '), + error, + ); + }); + }); + }); }); diff --git a/spec/frontend/lib/utils/color_utils_spec.js b/spec/frontend/lib/utils/color_utils_spec.js index 87966cf9fba..a5580a3d8d6 100644 --- a/spec/frontend/lib/utils/color_utils_spec.js +++ b/spec/frontend/lib/utils/color_utils_spec.js @@ -63,7 +63,7 @@ describe('Color utils', () => { ${'groups:issues:index'} | ${'gl-dark'} | ${'monokai-light'} | ${true} `( 'is $expected on $page with $bodyClass body class and $ideTheme IDE theme', - async ({ page, bodyClass, ideTheme, expected }) => { + ({ page, bodyClass, ideTheme, expected }) => { document.body.outerHTML = `<body class="${bodyClass}" data-page="${page}"></body>`; window.gon = { user_color_scheme: ideTheme, diff --git a/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js new file mode 100644 index 00000000000..15e056e45d0 --- /dev/null +++ b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js @@ -0,0 +1,25 @@ +import { formatTimeSpent } from '~/lib/utils/datetime/time_spent_utility'; + +describe('Time spent utils', () => { + describe('formatTimeSpent', () => { + describe('with limitToHours false', () => { + it('formats 34500 seconds to `1d 1h 35m`', () => { + expect(formatTimeSpent(34500)).toEqual('1d 1h 35m'); + }); + + it('formats -34500 seconds to `- 1d 1h 35m`', () => { + expect(formatTimeSpent(-34500)).toEqual('- 1d 1h 35m'); + }); + }); + + describe('with limitToHours true', () => { + it('formats 34500 seconds to `9h 35m`', () => { + expect(formatTimeSpent(34500, true)).toEqual('9h 35m'); + }); + + it('formats -34500 seconds to `- 9h 35m`', () => { + expect(formatTimeSpent(-34500, true)).toEqual('- 9h 35m'); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/error_message_spec.js b/spec/frontend/lib/utils/error_message_spec.js index 17b5168c32f..d55a6de06c3 100644 --- a/spec/frontend/lib/utils/error_message_spec.js +++ b/spec/frontend/lib/utils/error_message_spec.js @@ -1,65 +1,48 @@ -import { parseErrorMessage, USER_FACING_ERROR_MESSAGE_PREFIX } from '~/lib/utils/error_message'; +import { parseErrorMessage } from '~/lib/utils/error_message'; -const defaultErrorMessage = 'Something caused this error'; -const userFacingErrorMessage = 'User facing error message'; -const nonUserFacingErrorMessage = 'NonUser facing error message'; -const genericErrorMessage = 'Some error message'; +const defaultErrorMessage = 'Default error message'; +const errorMessage = 'Returned error message'; -describe('error message', () => { - describe('when given an errormessage object', () => { - const errorMessageObject = { - options: { - cause: defaultErrorMessage, - }, - filename: 'error.js', - linenumber: 7, - }; +const generateErrorWithMessage = (message) => { + return { + message, + }; +}; - it('returns the correct values for userfacing errors', () => { - const userFacingObject = errorMessageObject; - userFacingObject.message = `${USER_FACING_ERROR_MESSAGE_PREFIX} ${userFacingErrorMessage}`; - - expect(parseErrorMessage(userFacingObject)).toEqual({ - message: userFacingErrorMessage, - userFacing: true, - }); - }); - - it('returns the correct values for non userfacing errors', () => { - const nonUserFacingObject = errorMessageObject; - nonUserFacingObject.message = nonUserFacingErrorMessage; - - expect(parseErrorMessage(nonUserFacingObject)).toEqual({ - message: nonUserFacingErrorMessage, - userFacing: false, - }); - }); +describe('parseErrorMessage', () => { + const ufErrorPrefix = 'Foo:'; + beforeEach(() => { + gon.uf_error_prefix = ufErrorPrefix; }); - describe('when given an errormessage string', () => { - it('returns the correct values for userfacing errors', () => { - expect( - parseErrorMessage(`${USER_FACING_ERROR_MESSAGE_PREFIX} ${genericErrorMessage}`), - ).toEqual({ - message: genericErrorMessage, - userFacing: true, - }); - }); - - it('returns the correct values for non userfacing errors', () => { - expect(parseErrorMessage(genericErrorMessage)).toEqual({ - message: genericErrorMessage, - userFacing: false, - }); - }); - }); - - describe('when given nothing', () => { - it('returns an empty error message', () => { - expect(parseErrorMessage()).toEqual({ - message: '', - userFacing: false, - }); - }); - }); + it.each` + error | expectedResult + ${`${ufErrorPrefix} ${errorMessage}`} | ${errorMessage} + ${`${errorMessage} ${ufErrorPrefix}`} | ${defaultErrorMessage} + ${errorMessage} | ${defaultErrorMessage} + ${undefined} | ${defaultErrorMessage} + ${''} | ${defaultErrorMessage} + `( + 'properly parses "$error" error object and returns "$expectedResult"', + ({ error, expectedResult }) => { + const errorObject = generateErrorWithMessage(error); + expect(parseErrorMessage(errorObject, defaultErrorMessage)).toEqual(expectedResult); + }, + ); + + it.each` + error | defaultMessage | expectedResult + ${undefined} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${''} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${{}} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${generateErrorWithMessage(errorMessage)} | ${undefined} | ${''} + ${generateErrorWithMessage(`${ufErrorPrefix} ${errorMessage}`)} | ${undefined} | ${errorMessage} + ${generateErrorWithMessage(errorMessage)} | ${''} | ${''} + ${generateErrorWithMessage(`${ufErrorPrefix} ${errorMessage}`)} | ${''} | ${errorMessage} + `( + 'properly handles the edge case of error="$error" and defaultMessage="$defaultMessage"', + ({ error, defaultMessage, expectedResult }) => { + expect(parseErrorMessage(error, defaultMessage)).toEqual(expectedResult); + }, + ); }); diff --git a/spec/frontend/lib/utils/intersection_observer_spec.js b/spec/frontend/lib/utils/intersection_observer_spec.js index 71b1daffe0d..8eef403f0ae 100644 --- a/spec/frontend/lib/utils/intersection_observer_spec.js +++ b/spec/frontend/lib/utils/intersection_observer_spec.js @@ -57,7 +57,7 @@ describe('IntersectionObserver Utility', () => { ${true} | ${'IntersectionAppear'} `( 'should emit the correct event on the entry target based on the computed Intersection', - async ({ isIntersecting, event }) => { + ({ isIntersecting, event }) => { const target = document.createElement('div'); observer.addEntry({ target, isIntersecting }); diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js index 63eeb54e850..096a92305dc 100644 --- a/spec/frontend/lib/utils/poll_spec.js +++ b/spec/frontend/lib/utils/poll_spec.js @@ -121,7 +121,7 @@ describe('Poll', () => { }); describe('with delayed initial request', () => { - it('delays the first request', async () => { + it('delays the first request', () => { mockServiceCall({ status: HTTP_STATUS_OK, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js new file mode 100644 index 00000000000..7bde6cc4a8e --- /dev/null +++ b/spec/frontend/lib/utils/secret_detection_spec.js @@ -0,0 +1,68 @@ +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +const mockConfirmAction = ({ confirmed }) => { + confirmAction.mockResolvedValueOnce(confirmed); +}; + +describe('containsSensitiveToken', () => { + describe('when message does not contain sensitive tokens', () => { + const nonSensitiveMessages = [ + 'This is a normal message', + '1234567890', + '!@#$%^&*()_+', + 'https://example.com', + ]; + + it.each(nonSensitiveMessages)('returns false for message: %s', (message) => { + expect(containsSensitiveToken(message)).toBe(false); + }); + }); + + describe('when message contains sensitive tokens', () => { + const sensitiveMessages = [ + 'token: glpat-cgyKc1k_AsnEpmP-5fRL', + 'token: GlPat-abcdefghijklmnopqrstuvwxyz', + 'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'https://example.com/feed?feed_token=123456789_abcdefghij', + 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + ]; + + it.each(sensitiveMessages)('returns true for message: %s', (message) => { + expect(containsSensitiveToken(message)).toBe(true); + }); + }); +}); + +describe('confirmSensitiveAction', () => { + afterEach(() => { + confirmAction.mockReset(); + }); + + it('should call confirmAction with correct parameters', async () => { + const prompt = 'Are you sure you want to delete this item?'; + const expectedParams = { + primaryBtnVariant: 'danger', + primaryBtnText: i18n.primaryBtnText, + }; + await confirmSensitiveAction(prompt); + + expect(confirmAction).toHaveBeenCalledWith(prompt, expectedParams); + }); + + it('should return true when confirmed is true', async () => { + mockConfirmAction({ confirmed: true }); + + const result = await confirmSensitiveAction(); + expect(result).toBe(true); + }); + + it('should return false when confirmed is false', async () => { + mockConfirmAction({ confirmed: false }); + + const result = await confirmSensitiveAction(); + expect(result).toBe(false); + }); +}); diff --git a/spec/frontend/lib/utils/web_ide_navigator_spec.js b/spec/frontend/lib/utils/web_ide_navigator_spec.js new file mode 100644 index 00000000000..0f5cd09d50e --- /dev/null +++ b/spec/frontend/lib/utils/web_ide_navigator_spec.js @@ -0,0 +1,38 @@ +import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility'; +import { openWebIDE } from '~/lib/utils/web_ide_navigator'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + webIDEUrl: jest.fn().mockImplementation((path) => `/-/ide/projects${path}`), +})); + +describe('openWebIDE', () => { + it('when called without projectPath throws TypeError and does not call visitUrl', () => { + expect(() => { + openWebIDE(); + }).toThrow(new TypeError('projectPath parameter is required')); + expect(visitUrl).not.toHaveBeenCalled(); + }); + + it('when called with projectPath and without fileName calls visitUrl with correct path', () => { + const params = { projectPath: 'project-path' }; + const expectedNonIDEPath = `/${params.projectPath}/edit/main/-/`; + const expectedIDEPath = `/-/ide/projects${expectedNonIDEPath}`; + + openWebIDE(params.projectPath); + + expect(webIDEUrl).toHaveBeenCalledWith(expectedNonIDEPath); + expect(visitUrl).toHaveBeenCalledWith(expectedIDEPath); + }); + + it('when called with projectPath and fileName calls visitUrl with correct path', () => { + const params = { projectPath: 'project-path', fileName: 'README' }; + const expectedNonIDEPath = `/${params.projectPath}/edit/main/-/${params.fileName}/`; + const expectedIDEPath = `/-/ide/projects${expectedNonIDEPath}`; + + openWebIDE(params.projectPath, params.fileName); + + expect(webIDEUrl).toHaveBeenCalledWith(expectedNonIDEPath); + expect(visitUrl).toHaveBeenCalledWith(expectedIDEPath); + }); +}); diff --git a/spec/frontend/members/components/table/expiration_datepicker_spec.js b/spec/frontend/members/components/table/expiration_datepicker_spec.js index 15812ee6572..9176a02a447 100644 --- a/spec/frontend/members/components/table/expiration_datepicker_spec.js +++ b/spec/frontend/members/components/table/expiration_datepicker_spec.js @@ -93,7 +93,7 @@ describe('ExpirationDatepicker', () => { }); describe('when datepicker is changed', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); findDatepicker().vm.$emit('input', new Date('2020-03-17')); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index d6e63b1930f..1045e3f9849 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -1,5 +1,6 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import * as Sentry from '@sentry/browser'; import { within } from '@testing-library/dom'; import { mount, createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -12,6 +13,7 @@ import { member } from '../../mock_data'; Vue.use(Vuex); jest.mock('ee_else_ce/members/guest_overage_confirm_action'); +jest.mock('@sentry/browser'); describe('RoleDropdown', () => { let wrapper; @@ -20,9 +22,9 @@ describe('RoleDropdown', () => { show: jest.fn(), }; - const createStore = () => { + const createStore = ({ updateMemberRoleReturn = Promise.resolve() } = {}) => { actions = { - updateMemberRole: jest.fn(() => Promise.resolve()), + updateMemberRole: jest.fn(() => updateMemberRoleReturn), }; return new Vuex.Store({ @@ -32,7 +34,7 @@ describe('RoleDropdown', () => { }); }; - const createComponent = (propsData = {}) => { + const createComponent = (propsData = {}, store = createStore()) => { wrapper = mount(RoleDropdown, { provide: { namespace: MEMBER_TYPES.user, @@ -46,7 +48,7 @@ describe('RoleDropdown', () => { permissions: {}, ...propsData, }, - store: createStore(), + store, mocks: { $toast, }, @@ -75,11 +77,11 @@ describe('RoleDropdown', () => { }); describe('when dropdown is open', () => { - beforeEach(() => { + beforeEach(async () => { guestOverageConfirmAction.mockReturnValue(true); createComponent(); - return findDropdownToggle().trigger('click'); + await findDropdownToggle().trigger('click'); }); it('renders all valid roles', () => { @@ -113,26 +115,74 @@ describe('RoleDropdown', () => { }); }); - it('displays toast when successful', async () => { - await getDropdownItemByText('Developer').trigger('click'); + describe('when updateMemberRole is successful', () => { + it('displays toast', async () => { + await getDropdownItemByText('Developer').trigger('click'); - await nextTick(); + await nextTick(); - expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); - }); + expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); + }); - it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => { - await getDropdownItemByText('Developer').trigger('click'); + it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => { + await getDropdownItemByText('Developer').trigger('click'); + + expect(findDropdown().props('loading')).toBe(true); + }); + + it('enables dropdown after `updateMemberRole` resolves', async () => { + await getDropdownItemByText('Developer').trigger('click'); - expect(findDropdown().props('loading')).toBe(true); + await waitForPromises(); + + expect(findDropdown().props('disabled')).toBe(false); + }); + + it('does not log error to Sentry', async () => { + await getDropdownItemByText('Developer').trigger('click'); + + await waitForPromises(); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); }); - it('enables dropdown after `updateMemberRole` resolves', async () => { - await getDropdownItemByText('Developer').trigger('click'); + describe('when updateMemberRole is not successful', () => { + const reason = 'Rejected ☹️'; - await waitForPromises(); + beforeEach(() => { + createComponent({}, createStore({ updateMemberRoleReturn: Promise.reject(reason) })); + }); + + it('does not display toast', async () => { + await getDropdownItemByText('Developer').trigger('click'); + + await nextTick(); + + expect($toast.show).not.toHaveBeenCalled(); + }); + + it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => { + await getDropdownItemByText('Developer').trigger('click'); + + expect(findDropdown().props('loading')).toBe(true); + }); - expect(findDropdown().props('disabled')).toBe(false); + it('enables dropdown after `updateMemberRole` resolves', async () => { + await getDropdownItemByText('Developer').trigger('click'); + + await waitForPromises(); + + expect(findDropdown().props('disabled')).toBe(false); + }); + + it('logs error to Sentry', async () => { + await getDropdownItemByText('Developer').trigger('click'); + + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(reason); + }); }); }); }); diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 4f276e8c9df..c4357e9c1f0 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -213,7 +213,7 @@ describe('Members Utils', () => { ${'recent_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: false }} ${'oldest_sign_in'} | ${{ sortByKey: 'lastSignIn', sortDesc: true }} `('when `sort` query string param is `$sortParam`', ({ sortParam, expected }) => { - it(`returns ${JSON.stringify(expected)}`, async () => { + it(`returns ${JSON.stringify(expected)}`, () => { setWindowLocation(`?sort=${sortParam}`); expect(parseSortParam(['account', 'granted', 'expires', 'maxRole', 'lastSignIn'])).toEqual( diff --git a/spec/frontend/ml/experiment_tracking/components/delete_button_spec.js b/spec/frontend/ml/experiment_tracking/components/delete_button_spec.js new file mode 100644 index 00000000000..0243cbeb7bf --- /dev/null +++ b/spec/frontend/ml/experiment_tracking/components/delete_button_spec.js @@ -0,0 +1,68 @@ +import { GlModal, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; + +const csrfToken = 'mock-csrf-token'; +jest.mock('~/lib/utils/csrf', () => ({ token: csrfToken })); + +const MODAL_BODY = 'MODAL_BODY'; +const MODAL_TITLE = 'MODAL_TITLE'; + +describe('DeleteButton', () => { + let wrapper; + + const findModal = () => wrapper.findComponent(GlModal); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + const findForm = () => wrapper.find('form'); + const findModalText = () => wrapper.findByText(MODAL_BODY); + + beforeEach(() => { + wrapper = shallowMountExtended(DeleteButton, { + propsData: { + deletePath: '/delete', + deleteConfirmationText: MODAL_BODY, + actionPrimaryText: 'Delete!', + modalTitle: MODAL_TITLE, + }, + }); + }); + + it('mounts the modal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('mounts the dropdown', () => { + expect(findDropdown().exists()).toBe(true); + }); + + it('mounts the button', () => { + expect(findDeleteButton().exists()).toBe(true); + }); + + describe('when modal is opened', () => { + it('displays modal title', () => { + expect(findModal().props('title')).toBe(MODAL_TITLE); + }); + + it('displays modal body', () => { + expect(findModalText().exists()).toBe(true); + }); + + it('submits the form when primary action is clicked', () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + findModal().vm.$emit('primary'); + + expect(submitSpy).toHaveBeenCalled(); + }); + + it('displays form with correct action and inputs', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe('/delete'); + expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(csrfToken); + }); + }); +}); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/__snapshots__/ml_candidates_show_spec.js.snap b/spec/frontend/ml/experiment_tracking/routes/candidates/show/__snapshots__/ml_candidates_show_spec.js.snap index dc21db39259..6fd3dce1941 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/__snapshots__/ml_candidates_show_spec.js.snap +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/__snapshots__/ml_candidates_show_spec.js.snap @@ -2,99 +2,36 @@ exports[`MlCandidatesShow renders correctly 1`] = ` <div> + <incubation-alert-stub + featurename="Machine learning experiment tracking" + linktofeedbackissue="https://gitlab.com/gitlab-org/gitlab/-/issues/381660" + /> + <div - class="gl-alert gl-alert-warning" + class="detail-page-header gl-flex-wrap" > - <svg - aria-hidden="true" - class="gl-icon s16 gl-alert-icon" - data-testid="warning-icon" - role="img" - > - <use - href="#warning" - /> - </svg> - <div - aria-live="assertive" - class="gl-alert-content" - role="alert" + class="detail-page-header-body" > - <h2 - class="gl-alert-title" - > - Machine learning experiment tracking is in incubating phase - </h2> - - <div - class="gl-alert-body" + <h1 + class="page-title gl-font-size-h-display flex-fill" > - GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited. - - <a - class="gl-link" - href="https://about.gitlab.com/handbook/engineering/incubation/" - rel="noopener noreferrer" - target="_blank" - > - Learn more about incubating features - </a> - </div> + Model candidate details + + </h1> - <div - class="gl-alert-actions" - > - <a - class="btn gl-alert-action btn-confirm btn-md gl-button" - href="https://gitlab.com/gitlab-org/gitlab/-/issues/381660" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Give feedback on this feature - - </span> - </a> - </div> + <delete-button-stub + actionprimarytext="Delete candidate" + deleteconfirmationtext="Deleting this candidate will delete the associated parameters, metrics, and metadata." + deletepath="path_to_candidate" + modaltitle="Delete candidate?" + /> </div> - - <button - aria-label="Dismiss" - class="btn gl-dismiss-btn btn-default btn-sm gl-button btn-default-tertiary btn-icon" - type="button" - > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="close-icon" - role="img" - > - <use - href="#close" - /> - </svg> - - <!----> - </button> </div> - <h3> - - Model candidate details - - </h3> - <table - class="candidate-details" + class="candidate-details gl-w-full" > <tbody> <tr @@ -143,12 +80,9 @@ exports[`MlCandidatesShow renders correctly 1`] = ` </td> <td> - <a - class="gl-link" - href="#" - > + <gl-link-stub> The Experiment - </a> + </gl-link-stub> </td> </tr> @@ -162,12 +96,11 @@ exports[`MlCandidatesShow renders correctly 1`] = ` </td> <td> - <a - class="gl-link" + <gl-link-stub href="path_to_artifact" > Artifacts - </a> + </gl-link-stub> </td> </tr> diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js index 36455339041..7d03ab3b509 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js @@ -1,6 +1,7 @@ -import { GlAlert } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; +import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; describe('MlCandidatesShow', () => { let wrapper; @@ -25,23 +26,31 @@ describe('MlCandidatesShow', () => { experiment_name: 'The Experiment', experiment_path: 'path/to/experiment', status: 'SUCCESS', + path: 'path_to_candidate', }, }; - return mountExtended(MlCandidatesShow, { propsData: { candidate } }); + wrapper = shallowMountExtended(MlCandidatesShow, { propsData: { candidate } }); }; - const findAlert = () => wrapper.findComponent(GlAlert); + beforeEach(createWrapper); - it('shows incubation warning', () => { - wrapper = createWrapper(); + const findAlert = () => wrapper.findComponent(IncubationAlert); + const findDeleteButton = () => wrapper.findComponent(DeleteButton); + it('shows incubation warning', () => { expect(findAlert().exists()).toBe(true); }); - it('renders correctly', () => { - wrapper = createWrapper(); + it('shows delete button', () => { + expect(findDeleteButton().exists()).toBe(true); + }); + it('passes the delete path to delete button', () => { + expect(findDeleteButton().props('deletePath')).toBe('path_to_candidate'); + }); + + it('renders correctly', () => { expect(wrapper.element).toMatchSnapshot(); }); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/show/components/experiment_header_spec.js b/spec/frontend/ml/experiment_tracking/routes/experiments/show/components/experiment_header_spec.js new file mode 100644 index 00000000000..b56755043fb --- /dev/null +++ b/spec/frontend/ml/experiment_tracking/routes/experiments/show/components/experiment_header_spec.js @@ -0,0 +1,55 @@ +import { GlButton } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ExperimentHeader from '~/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import * as urlHelpers from '~/lib/utils/url_utility'; +import { MOCK_EXPERIMENT } from '../mock_data'; + +const DELETE_INFO = { + deletePath: '/delete', + deleteConfirmationText: 'MODAL_BODY', + actionPrimaryText: 'Delete!', + modalTitle: 'MODAL_TITLE', +}; + +describe('~/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue', () => { + let wrapper; + + const createWrapper = () => { + wrapper = mountExtended(ExperimentHeader, { + propsData: { title: MOCK_EXPERIMENT.name, deleteInfo: DELETE_INFO }, + }); + }; + + const findDeleteButton = () => wrapper.findComponent(DeleteButton); + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(createWrapper); + + describe('Delete', () => { + it('shows delete button', () => { + expect(findDeleteButton().exists()).toBe(true); + }); + + it('passes the right props', () => { + expect(findDeleteButton().props()).toMatchObject(DELETE_INFO); + }); + }); + + describe('CSV download', () => { + it('shows download CSV button', () => { + expect(findDeleteButton().exists()).toBe(true); + }); + + it('calls the action to download the CSV', () => { + setWindowLocation('https://blah.com/something/1?name=query&orderBy=name'); + jest.spyOn(urlHelpers, 'visitUrl').mockImplementation(() => {}); + + findButton().vm.$emit('click'); + + expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1); + expect(urlHelpers.visitUrl).toHaveBeenCalledWith('/something/1.csv?name=query&orderBy=name'); + }); + }); +}); diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js index 97a5049ea88..38b3d96ed11 100644 --- a/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js @@ -1,11 +1,12 @@ import { GlAlert, GlTableLite, GlLink, GlEmptyState } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import MlExperimentsShow from '~/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue'; +import ExperimentHeader from '~/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import Pagination from '~/vue_shared/components/incubation/pagination.vue'; import setWindowLocation from 'helpers/set_window_location_helper'; import * as urlHelpers from '~/lib/utils/url_utility'; -import { MOCK_START_CURSOR, MOCK_PAGE_INFO, MOCK_CANDIDATES } from './mock_data'; +import { MOCK_START_CURSOR, MOCK_PAGE_INFO, MOCK_CANDIDATES, MOCK_EXPERIMENT } from './mock_data'; describe('MlExperimentsShow', () => { let wrapper; @@ -15,9 +16,10 @@ describe('MlExperimentsShow', () => { metricNames = [], paramNames = [], pageInfo = MOCK_PAGE_INFO, + experiment = MOCK_EXPERIMENT, ) => { wrapper = mountExtended(MlExperimentsShow, { - propsData: { candidates, metricNames, paramNames, pageInfo }, + propsData: { experiment, candidates, metricNames, paramNames, pageInfo }, }); }; @@ -34,6 +36,8 @@ describe('MlExperimentsShow', () => { const findTableRows = () => findTable().findAll('tbody > tr'); const findNthTableRow = (idx) => findTableRows().at(idx); const findColumnInRow = (row, col) => findNthTableRow(row).findAll('td').at(col); + const findExperimentHeader = () => wrapper.findComponent(ExperimentHeader); + const hrefInRowAndColumn = (row, col) => findColumnInRow(row, col).findComponent(GlLink).attributes().href; @@ -44,7 +48,7 @@ describe('MlExperimentsShow', () => { }); describe('default inputs', () => { - beforeEach(async () => { + beforeEach(() => { createWrapper(); }); @@ -56,6 +60,14 @@ describe('MlExperimentsShow', () => { expect(findPagination().exists()).toBe(false); }); + it('shows experiment header', () => { + expect(findExperimentHeader().exists()).toBe(true); + }); + + it('passes the correct title to experiment header', () => { + expect(findExperimentHeader().props('title')).toBe(MOCK_EXPERIMENT.name); + }); + it('does not show table', () => { expect(findTable().exists()).toBe(false); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js b/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js index 66378cd3f0d..adfb3dbf773 100644 --- a/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js +++ b/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js @@ -7,6 +7,8 @@ export const MOCK_PAGE_INFO = { hasPreviousPage: true, }; +export const MOCK_EXPERIMENT = { name: 'experiment', path: '/path/to/experiment' }; + export const MOCK_CANDIDATES = [ { rmse: 1, diff --git a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js index 96b228fd3b2..e6c5569fa19 100644 --- a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js +++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js @@ -53,7 +53,7 @@ describe('Custom variable component', () => { expect(findDropdown().exists()).toBe(true); }); - it('changing dropdown items triggers update', async () => { + it('changing dropdown items triggers update', () => { createShallowWrapper(); findDropdownItems().at(1).vm.$emit('click'); diff --git a/spec/frontend/monitoring/pages/dashboard_page_spec.js b/spec/frontend/monitoring/pages/dashboard_page_spec.js index c5a8b50ee60..3de99673e71 100644 --- a/spec/frontend/monitoring/pages/dashboard_page_spec.js +++ b/spec/frontend/monitoring/pages/dashboard_page_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; import { createStore } from '~/monitoring/stores'; +import { assertProps } from 'helpers/assert_props'; import { dashboardProps } from '../fixture_data'; describe('monitoring/pages/dashboard_page', () => { @@ -45,7 +46,7 @@ describe('monitoring/pages/dashboard_page', () => { }); it('throws errors if dashboard props are not passed', () => { - expect(() => buildWrapper()).toThrow('Missing required prop: "dashboardProps"'); + expect(() => assertProps(DashboardPage, {})).toThrow('Missing required prop: "dashboardProps"'); }); it('renders the dashboard page with dashboard component', () => { diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index 8097857f226..b3b198d6b51 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -260,7 +260,7 @@ describe('Monitoring store actions', () => { }); }); - it('does not show an alert error when showErrorBanner is disabled', async () => { + it('does not show an alert when showErrorBanner is disabled', async () => { state.showErrorBanner = false; await result(); diff --git a/spec/frontend/nav/components/new_nav_toggle_spec.js b/spec/frontend/nav/components/new_nav_toggle_spec.js index fe543a346b5..cf8e59d6522 100644 --- a/spec/frontend/nav/components/new_nav_toggle_spec.js +++ b/spec/frontend/nav/components/new_nav_toggle_spec.js @@ -9,6 +9,7 @@ import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import { s__ } from '~/locale'; +import { mockTracking } from 'helpers/tracking_helper'; jest.mock('~/alert'); @@ -18,6 +19,7 @@ describe('NewNavToggle', () => { useMockLocationHelper(); let wrapper; + let trackingSpy; const findToggle = () => wrapper.findComponent(GlToggle); const findDisclosureItem = () => wrapper.findComponent(GlDisclosureDropdownItem); @@ -29,6 +31,8 @@ describe('NewNavToggle', () => { ...propsData, }, }); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }; const getByText = (text, options) => @@ -61,15 +65,17 @@ describe('NewNavToggle', () => { }); describe.each` - desc | actFn - ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} - ${'on menu item action'} | ${() => findDisclosureItem().vm.$emit('action')} - `('$desc', ({ actFn }) => { + desc | actFn | toggleValue | trackingLabel | trackingProperty + ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'} + ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'} + ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'} + ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'} + `('$desc', ({ actFn, toggleValue, trackingLabel, trackingProperty }) => { let mock; beforeEach(() => { mock = new MockAdapter(axios); - createComponent({ enabled: false, newNavigation: true }); + createComponent({ enabled: toggleValue }); }); it('reloads the page on success', async () => { @@ -100,7 +106,17 @@ describe('NewNavToggle', () => { it('changes the toggle', async () => { await actFn(); - expect(findToggle().props('value')).toBe(true); + expect(findToggle().props('value')).toBe(!toggleValue); + }); + + it('tracks the Snowplow event', async () => { + mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK); + await actFn(); + await waitForPromises(); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', { + label: trackingLabel, + property: trackingProperty, + }); }); afterEach(() => { @@ -136,15 +152,17 @@ describe('NewNavToggle', () => { }); describe.each` - desc | actFn - ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} - ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} - `('$desc', ({ actFn }) => { + desc | actFn | toggleValue | trackingLabel | trackingProperty + ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'} + ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'} + ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'} + ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'} + `('$desc', ({ actFn, toggleValue, trackingLabel, trackingProperty }) => { let mock; beforeEach(() => { mock = new MockAdapter(axios); - createComponent({ enabled: false }); + createComponent({ enabled: toggleValue }); }); it('reloads the page on success', async () => { @@ -175,7 +193,17 @@ describe('NewNavToggle', () => { it('changes the toggle', async () => { await actFn(); - expect(findToggle().props('value')).toBe(true); + expect(findToggle().props('value')).toBe(!toggleValue); + }); + + it('tracks the Snowplow event', async () => { + mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK); + await actFn(); + await waitForPromises(); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', { + label: trackingLabel, + property: trackingProperty, + }); }); afterEach(() => { diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js index 5a09598059d..766e840cac5 100644 --- a/spec/frontend/new_branch_spec.js +++ b/spec/frontend/new_branch_spec.js @@ -11,7 +11,7 @@ describe('Branch', () => { describe('create a new branch', () => { function fillNameWith(value) { document.querySelector('.js-branch-name').value = value; - const event = new CustomEvent('blur'); + const event = new CustomEvent('change'); document.querySelector('.js-branch-name').dispatchEvent(event); } diff --git a/spec/frontend/notebook/cells/output/dataframe_spec.js b/spec/frontend/notebook/cells/output/dataframe_spec.js new file mode 100644 index 00000000000..abf6631353c --- /dev/null +++ b/spec/frontend/notebook/cells/output/dataframe_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import DataframeOutput from '~/notebook/cells/output/dataframe.vue'; +import JSONTable from '~/behaviors/components/json_table.vue'; +import { outputWithDataframe } from '../../mock_data'; + +describe('~/notebook/cells/output/DataframeOutput', () => { + let wrapper; + + function createComponent(rawCode) { + wrapper = shallowMount(DataframeOutput, { + propsData: { + rawCode, + count: 0, + index: 0, + }, + }); + } + + const findTable = () => wrapper.findComponent(JSONTable); + + describe('with valid dataframe', () => { + beforeEach(() => createComponent(outputWithDataframe.data['text/html'].join(''))); + + it('mounts the table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('table caption is empty', () => { + expect(findTable().props().caption).toEqual(''); + }); + + it('allows filtering', () => { + expect(findTable().props().hasFilter).toBe(true); + }); + + it('sets the correct fields', () => { + expect(findTable().props().fields).toEqual([ + { key: 'index', label: '', sortable: true }, + { key: 'column_1', label: 'column_1', sortable: true }, + { key: 'column_2', label: 'column_2', sortable: true }, + ]); + }); + + it('sets the correct items', () => { + expect(findTable().props().items).toEqual([ + { index: 0, column_1: 'abc de f', column_2: 'a' }, + { index: 1, column_1: 'True', column_2: '0.1' }, + ]); + }); + }); + + describe('invalid dataframe', () => { + it('still displays the table', () => { + createComponent('dataframe'); + + expect(findTable().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/notebook/cells/output/dataframe_util_spec.js b/spec/frontend/notebook/cells/output/dataframe_util_spec.js new file mode 100644 index 00000000000..ddc1b3cfe26 --- /dev/null +++ b/spec/frontend/notebook/cells/output/dataframe_util_spec.js @@ -0,0 +1,113 @@ +import { isDataframe, convertHtmlTableToJson } from '~/notebook/cells/output/dataframe_util'; +import { outputWithDataframeContent } from '../../mock_data'; +import sanitizeTests from './html_sanitize_fixtures'; + +describe('notebook/cells/output/dataframe_utils', () => { + describe('isDataframe', () => { + describe('when output data has no text/html', () => { + it('is is not a dataframe', () => { + const input = { data: { 'image/png': ['blah'] } }; + + expect(isDataframe(input)).toBe(false); + }); + }); + + describe('when output data has no text/html, but no mention of dataframe', () => { + it('is is not a dataframe', () => { + const input = { data: { 'text/html': ['blah'] } }; + + expect(isDataframe(input)).toBe(false); + }); + }); + + describe('when output data has text/html, but no mention of dataframe in the first 20 lines', () => { + it('is is not a dataframe', () => { + const input = { data: { 'text/html': [...new Array(20).fill('a'), 'dataframe'] } }; + + expect(isDataframe(input)).toBe(false); + }); + }); + + describe('when output data has text/html, and includes "dataframe" within the first 20 lines', () => { + it('is is not a dataframe', () => { + const input = { data: { 'text/html': ['dataframe'] } }; + + expect(isDataframe(input)).toBe(true); + }); + }); + }); + + describe('convertHtmlTableToJson', () => { + it('converts table correctly', () => { + const input = outputWithDataframeContent; + + const output = { + fields: [ + { key: 'index', label: '', sortable: true }, + { key: 'column_1', label: 'column_1', sortable: true }, + { key: 'column_2', label: 'column_2', sortable: true }, + ], + items: [ + { index: 0, column_1: 'abc de f', column_2: 'a' }, + { index: 1, column_1: 'True', column_2: '0.1' }, + ], + }; + + expect(convertHtmlTableToJson(input)).toEqual(output); + }); + + describe('sanitizes input before parsing table', () => { + it('sanitizes input html', () => { + const parser = new DOMParser(); + const spy = jest.spyOn(parser, 'parseFromString'); + const input = 'hello<style>p {width:50%;}</style><script>alert(1)</script>'; + + convertHtmlTableToJson(input, parser); + + expect(spy).toHaveBeenCalledWith('hello', 'text/html'); + }); + }); + + describe('does not include harmful html', () => { + const makeDataframeWithHtml = (html) => { + return [ + '<table border="1" class="dataframe">\n', + ' <thead>\n', + ' <tr style="text-align: right;">\n', + ' <th></th>\n', + ' <th>column_1</th>\n', + ' </tr>\n', + ' </thead>\n', + ' <tbody>\n', + ' <tr>\n', + ' <th>0</th>\n', + ` <td>${html}</td>\n`, + ' </tr>\n', + ' </tbody>\n', + '</table>\n', + '</div>', + ]; + }; + + it.each([ + ['table', 0], + ['style', 1], + ['iframe', 2], + ['svg', 3], + ])('sanitizes output for: %p', (tag, index) => { + const inputHtml = makeDataframeWithHtml(sanitizeTests[index][1].input); + const convertedHtml = convertHtmlTableToJson(inputHtml).items[0].column_1; + + expect(convertedHtml).not.toContain(tag); + }); + }); + + describe('when dataframe is invalid', () => { + it('returns empty', () => { + const input = [' dataframe', ' blah']; + + expect(convertHtmlTableToJson(input)).toEqual({ fields: [], items: [] }); + }); + }); + }); +}); diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js index 1241c133b89..efbdfca8d8c 100644 --- a/spec/frontend/notebook/cells/output/index_spec.js +++ b/spec/frontend/notebook/cells/output/index_spec.js @@ -2,7 +2,13 @@ import { mount } from '@vue/test-utils'; import json from 'test_fixtures/blob/notebook/basic.json'; import Output from '~/notebook/cells/output/index.vue'; import MarkdownOutput from '~/notebook/cells/output/markdown.vue'; -import { relativeRawPath, markdownCellContent } from '../../mock_data'; +import DataframeOutput from '~/notebook/cells/output/dataframe.vue'; +import { + relativeRawPath, + markdownCellContent, + outputWithDataframe, + outputWithDataframeContent, +} from '../../mock_data'; describe('Output component', () => { let wrapper; @@ -105,6 +111,16 @@ describe('Output component', () => { }); }); + describe('Dataframe output', () => { + it('renders DataframeOutput component', () => { + createComponent(outputWithDataframe); + + expect(wrapper.findComponent(DataframeOutput).props('rawCode')).toBe( + outputWithDataframeContent.join(''), + ); + }); + }); + describe('default to plain text', () => { beforeEach(() => { const unknownType = json.cells[6]; diff --git a/spec/frontend/notebook/mock_data.js b/spec/frontend/notebook/mock_data.js index 5c47cb5aa9b..15db2931b3c 100644 --- a/spec/frontend/notebook/mock_data.js +++ b/spec/frontend/notebook/mock_data.js @@ -6,3 +6,47 @@ export const errorOutputContent = [ '\u001b[0;32m/var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_79203/294318627.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mTo\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m', "\u001b[0;31mNameError\u001b[0m: name 'To' is not defined", ]; +export const outputWithDataframeContent = [ + '<div>\n', + '<style scoped>\n', + ' .dataframe tbody tr th:only-of-type {\n', + ' vertical-align: middle;\n', + ' }\n', + '\n', + ' .dataframe tbody tr th {\n', + ' vertical-align: top;\n', + ' }\n', + '\n', + ' .dataframe thead th {\n', + ' text-align: right;\n', + ' }\n', + '</style>\n', + '<table border="1" class="dataframe">\n', + ' <thead>\n', + ' <tr style="text-align: right;">\n', + ' <th></th>\n', + ' <th>column_1</th>\n', + ' <th>column_2</th>\n', + ' </tr>\n', + ' </thead>\n', + ' <tbody>\n', + ' <tr>\n', + ' <th>0</th>\n', + ' <td>abc de f</td>\n', + ' <td>a</td>\n', + ' </tr>\n', + ' <tr>\n', + ' <th>1</th>\n', + ' <td>True</td>\n', + ' <td>0.1</td>\n', + ' </tr>\n', + ' </tbody>\n', + '</table>\n', + '</div>', +]; + +export const outputWithDataframe = { + data: { + 'text/html': outputWithDataframeContent, + }, +}; diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 062cd098640..04143bb5b60 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -5,6 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/alert'; @@ -27,6 +28,8 @@ jest.mock('~/alert'); Vue.use(Vuex); describe('issue_comment_form component', () => { + useLocalStorageSpy(); + let store; let wrapper; let axiosMock; @@ -649,6 +652,37 @@ describe('issue_comment_form component', () => { }); }); + describe('check sensitive tokens', () => { + const sensitiveMessage = 'token: glpat-1234567890abcdefghij'; + const nonSensitiveMessage = 'text'; + + it('should not save note when it contains sensitive token', () => { + mountComponent({ + mountFunction: mount, + initialData: { note: sensitiveMessage }, + }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + + clickCommentButton(); + + expect(wrapper.vm.saveNote).not.toHaveBeenCalled(); + }); + + it('should save note it does not contain sensitive token', () => { + mountComponent({ + mountFunction: mount, + initialData: { note: nonSensitiveMessage }, + }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + + clickCommentButton(); + + expect(wrapper.vm.saveNote).toHaveBeenCalled(); + }); + }); + describe('user is not logged in', () => { beforeEach(() => { mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount }); diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js index ed1ced1b3d1..28e5e65c177 100644 --- a/spec/frontend/notes/components/discussion_filter_spec.js +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; @@ -77,17 +77,16 @@ describe('DiscussionFilter component', () => { // as it doesn't matter for our tests here mock.onGet(DISCUSSION_PATH).reply(HTTP_STATUS_OK, ''); window.mrTabs = undefined; - wrapper = mountComponent(); jest.spyOn(Tracking, 'event'); }); afterEach(() => { - wrapper.vm.$destroy(); mock.restore(); }); describe('default', () => { beforeEach(() => { + wrapper = mountComponent(); jest.spyOn(store, 'dispatch').mockImplementation(); }); @@ -104,6 +103,7 @@ describe('DiscussionFilter component', () => { describe('when asc', () => { beforeEach(() => { + wrapper = mountComponent(); jest.spyOn(store, 'dispatch').mockImplementation(); }); @@ -123,6 +123,7 @@ describe('DiscussionFilter component', () => { describe('when desc', () => { beforeEach(() => { + wrapper = mountComponent(); store.state.discussionSortOrder = DESC; jest.spyOn(store, 'dispatch').mockImplementation(); }); @@ -145,56 +146,62 @@ describe('DiscussionFilter component', () => { }); }); - it('renders the all filters', () => { - expect(wrapper.findAll('.discussion-filter-container .dropdown-item').length).toBe( - discussionFiltersMock.length, - ); - }); + describe('discussion filter functionality', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); - it('renders the default selected item', () => { - expect(wrapper.find('.discussion-filter-container .dropdown-item').text().trim()).toBe( - discussionFiltersMock[0].title, - ); - }); + it('renders the all filters', () => { + expect(wrapper.findAll('.discussion-filter-container .dropdown-item').length).toBe( + discussionFiltersMock.length, + ); + }); - it('disables the dropdown when discussions are loading', () => { - store.state.isLoading = true; + it('renders the default selected item', () => { + expect(wrapper.find('.discussion-filter-container .dropdown-item').text().trim()).toBe( + discussionFiltersMock[0].title, + ); + }); - expect(wrapper.findComponent(GlDropdown).props('disabled')).toBe(true); - }); + it('disables the dropdown when discussions are loading', () => { + store.state.isLoading = true; - it('updates to the selected item', () => { - const filterItem = findFilter(DISCUSSION_FILTER_TYPES.ALL); + expect(wrapper.findComponent(GlDropdown).props('disabled')).toBe(true); + }); - filterItem.trigger('click'); + it('updates to the selected item', () => { + const filterItem = findFilter(DISCUSSION_FILTER_TYPES.ALL); - expect(wrapper.vm.currentFilter.title).toBe(filterItem.text().trim()); - }); + filterItem.trigger('click'); - it('only updates when selected filter changes', () => { - findFilter(DISCUSSION_FILTER_TYPES.ALL).trigger('click'); + expect(filterItem.text().trim()).toBe('Show all activity'); + }); - expect(filterDiscussion).not.toHaveBeenCalled(); - }); + it('only updates when selected filter changes', () => { + findFilter(DISCUSSION_FILTER_TYPES.ALL).trigger('click'); + + expect(filterDiscussion).not.toHaveBeenCalled(); + }); - it('disables timeline view if it was enabled', () => { - store.state.isTimelineEnabled = true; + it('disables timeline view if it was enabled', () => { + store.state.isTimelineEnabled = true; - findFilter(DISCUSSION_FILTER_TYPES.HISTORY).trigger('click'); + findFilter(DISCUSSION_FILTER_TYPES.HISTORY).trigger('click'); - expect(wrapper.vm.$store.state.isTimelineEnabled).toBe(false); - }); + expect(store.state.isTimelineEnabled).toBe(false); + }); - it('disables commenting when "Show history only" filter is applied', () => { - findFilter(DISCUSSION_FILTER_TYPES.HISTORY).trigger('click'); + it('disables commenting when "Show history only" filter is applied', () => { + findFilter(DISCUSSION_FILTER_TYPES.HISTORY).trigger('click'); - expect(wrapper.vm.$store.state.commentsDisabled).toBe(true); - }); + expect(store.state.commentsDisabled).toBe(true); + }); - it('enables commenting when "Show history only" filter is not applied', () => { - findFilter(DISCUSSION_FILTER_TYPES.ALL).trigger('click'); + it('enables commenting when "Show history only" filter is not applied', () => { + findFilter(DISCUSSION_FILTER_TYPES.ALL).trigger('click'); - expect(wrapper.vm.$store.state.commentsDisabled).toBe(false); + expect(store.state.commentsDisabled).toBe(false); + }); }); describe('Merge request tabs', () => { @@ -222,52 +229,41 @@ describe('DiscussionFilter component', () => { }); describe('URL with Links to notes', () => { + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + afterEach(() => { window.location.hash = ''; }); - it('updates the filter when the URL links to a note', async () => { - window.location.hash = `note_${discussionMock.notes[0].id}`; - wrapper.vm.currentValue = discussionFiltersMock[2].value; - wrapper.vm.handleLocationHash(); - - await nextTick(); - expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); - }); - it('does not update the filter when the current filter is "Show all activity"', async () => { window.location.hash = `note_${discussionMock.notes[0].id}`; - wrapper.vm.handleLocationHash(); + wrapper = mountComponent(); await nextTick(); - expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); + const filtered = findDropdownItems().filter((el) => el.classes('is-active')); + + expect(filtered).toHaveLength(1); + expect(filtered.at(0).text()).toBe(discussionFiltersMock[0].title); }); it('only updates filter when the URL links to a note', async () => { window.location.hash = `testing123`; - wrapper.vm.handleLocationHash(); + wrapper = mountComponent(); await nextTick(); - expect(wrapper.vm.currentValue).toBe(DISCUSSION_FILTERS_DEFAULT_VALUE); - }); + const filtered = findDropdownItems().filter((el) => el.classes('is-active')); - it('fetches discussions when there is a hash', async () => { - window.location.hash = `note_${discussionMock.notes[0].id}`; - wrapper.vm.currentValue = discussionFiltersMock[2].value; - jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {}); - wrapper.vm.handleLocationHash(); - - await nextTick(); - expect(wrapper.vm.selectFilter).toHaveBeenCalled(); + expect(filtered).toHaveLength(1); + expect(filtered.at(0).text()).toBe(discussionFiltersMock[0].title); }); it('does not fetch discussions when there is no hash', async () => { window.location.hash = ''; - jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {}); - wrapper.vm.handleLocationHash(); + const selectFilterSpy = jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {}); + wrapper = mountComponent(); await nextTick(); - expect(wrapper.vm.selectFilter).not.toHaveBeenCalled(); + expect(selectFilterSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js b/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js index bee08ee0605..7860e9d45da 100644 --- a/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js +++ b/spec/frontend/notes/components/note_actions/timeline_event_button_spec.js @@ -22,7 +22,7 @@ describe('NoteTimelineEventButton', () => { const findTimelineButton = () => wrapper.findComponent(GlButton); - it('emits click-promote-comment-to-event', async () => { + it('emits click-promote-comment-to-event', () => { findTimelineButton().vm.$emit('click'); expect(wrapper.emitted('click-promote-comment-to-event')).toEqual([[emitData]]); diff --git a/spec/frontend/notes/components/note_awards_list_spec.js b/spec/frontend/notes/components/note_awards_list_spec.js index 89ac0216f41..0107b27f980 100644 --- a/spec/frontend/notes/components/note_awards_list_spec.js +++ b/spec/frontend/notes/components/note_awards_list_spec.js @@ -1,76 +1,110 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; +import Vuex from 'vuex'; import { TEST_HOST } from 'helpers/test_constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { userDataMock } from 'jest/notes/mock_data'; +import EmojiPicker from '~/emoji/components/picker.vue'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import awardsNote from '~/notes/components/note_awards_list.vue'; import createStore from '~/notes/stores'; -import { noteableDataMock, notesDataMock } from '../mock_data'; -describe('note_awards_list component', () => { - let store; - let vm; - let awardsMock; - let mock; - - const toggleAwardPath = `${TEST_HOST}/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji`; - - beforeEach(() => { - mock = new AxiosMockAdapter(axios); - - mock.onPost(toggleAwardPath).reply(HTTP_STATUS_OK, ''); +Vue.use(Vuex); - const Component = Vue.extend(awardsNote); - - store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - awardsMock = [ - { - name: 'flag_tz', - user: { id: 1, name: 'Administrator', username: 'root' }, - }, - { - name: 'cartwheel_tone3', - user: { id: 12, name: 'Bobbie Stehr', username: 'erin' }, - }, - ]; +describe('Note Awards List', () => { + let wrapper; + let mock; - vm = new Component({ + const awardsMock = [ + { + name: 'flag_tz', + user: { id: 1, name: 'Administrator', username: 'root' }, + }, + { + name: 'cartwheel_tone3', + user: { id: 12, name: 'Bobbie Stehr', username: 'erin' }, + }, + ]; + const toggleAwardPathMock = `${TEST_HOST}/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji`; + + const defaultProps = { + awards: awardsMock, + noteAuthorId: 2, + noteId: '545', + canAwardEmoji: false, + toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', + }; + + const findAddAward = () => wrapper.find('.js-add-award'); + const findAwardButton = () => wrapper.findByTestId('award-button'); + const findAllEmojiAwards = () => wrapper.findAll('gl-emoji'); + const findEmojiPicker = () => wrapper.findComponent(EmojiPicker); + + const createComponent = (props = defaultProps, store = createStore()) => { + wrapper = mountExtended(awardsNote, { store, propsData: { - awards: awardsMock, - noteAuthorId: 2, - noteId: '545', - canAwardEmoji: true, - toggleAwardPath, + ...props, }, - }).$mount(); - }); + }); + }; + + describe('Note Awards functionality', () => { + const toggleAwardRequestSpy = jest.fn(); + const fakeStore = () => { + return new Vuex.Store({ + getters: { + getUserData: () => userDataMock, + }, + actions: { + toggleAwardRequest: toggleAwardRequestSpy, + }, + }); + }; - afterEach(() => { - mock.restore(); - vm.$destroy(); - }); + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + mock.onPost(toggleAwardPathMock).reply(HTTP_STATUS_OK, ''); - it('should render awarded emojis', () => { - expect(vm.$el.querySelector('.js-awards-block button [data-name="flag_tz"]')).toBeDefined(); - expect( - vm.$el.querySelector('.js-awards-block button [data-name="cartwheel_tone3"]'), - ).toBeDefined(); - }); + createComponent( + { + awards: awardsMock, + noteAuthorId: 2, + noteId: '545', + canAwardEmoji: true, + toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', + }, + fakeStore(), + ); + }); - it('should be possible to remove awarded emoji', () => { - jest.spyOn(vm, 'handleAward'); - jest.spyOn(vm, 'toggleAwardRequest'); - vm.$el.querySelector('.js-awards-block button').click(); + afterEach(() => { + mock.restore(); + }); - expect(vm.handleAward).toHaveBeenCalledWith('flag_tz'); - expect(vm.toggleAwardRequest).toHaveBeenCalled(); - }); + it('should render awarded emojis', () => { + const emojiAwards = findAllEmojiAwards(); + + expect(emojiAwards).toHaveLength(awardsMock.length); + expect(emojiAwards.at(0).attributes('data-name')).toBe('flag_tz'); + expect(emojiAwards.at(1).attributes('data-name')).toBe('cartwheel_tone3'); + }); + + it('should be possible to add new emoji', () => { + expect(findEmojiPicker().exists()).toBe(true); + }); + + it('should be possible to remove awarded emoji', async () => { + await findAwardButton().vm.$emit('click'); - it('should be possible to add new emoji', () => { - expect(vm.$el.querySelector('.js-add-award')).toBeDefined(); + const { toggleAwardPath, noteId } = defaultProps; + expect(toggleAwardRequestSpy).toHaveBeenCalledWith(expect.anything(), { + awardName: awardsMock[0].name, + endpoint: toggleAwardPath, + noteId, + }); + }); }); describe('when the user name contains special HTML characters', () => { @@ -79,85 +113,69 @@ describe('note_awards_list component', () => { user: { id: index, name: `&<>"\`'-${index}`, username: `user-${index}` }, }); - const mountComponent = () => { - const Component = Vue.extend(awardsNote); - vm = new Component({ - store, - propsData: { - awards: awardsMock, - noteAuthorId: 0, - noteId: '545', - canAwardEmoji: true, - toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', - }, - }).$mount(); + const customProps = { + awards: awardsMock, + noteAuthorId: 0, + noteId: '545', + canAwardEmoji: true, + toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', }; - const findTooltip = () => vm.$el.querySelector('[title]').getAttribute('title'); - - it('should only escape & and " characters', () => { - awardsMock = [...new Array(1)].map(createAwardEmoji); - mountComponent(); - const escapedName = awardsMock[0].user.name.replace(/&/g, '&').replace(/"/g, '"'); - - expect(vm.$el.querySelector('[title]').outerHTML).toContain(escapedName); - }); - it('should not escape special HTML characters twice when only 1 person awarded', () => { - awardsMock = [...new Array(1)].map(createAwardEmoji); - mountComponent(); + const awardsCopy = [...new Array(1)].map(createAwardEmoji); + createComponent({ + ...customProps, + awards: awardsCopy, + }); - awardsMock.forEach((award) => { - expect(findTooltip()).toContain(award.user.name); + awardsCopy.forEach((award) => { + expect(findAwardButton().attributes('title')).toContain(award.user.name); }); }); it('should not escape special HTML characters twice when 2 people awarded', () => { - awardsMock = [...new Array(2)].map(createAwardEmoji); - mountComponent(); + const awardsCopy = [...new Array(2)].map(createAwardEmoji); + createComponent({ + ...customProps, + awards: awardsCopy, + }); - awardsMock.forEach((award) => { - expect(findTooltip()).toContain(award.user.name); + awardsCopy.forEach((award) => { + expect(findAwardButton().attributes('title')).toContain(award.user.name); }); }); it('should not escape special HTML characters twice when more than 10 people awarded', () => { - awardsMock = [...new Array(11)].map(createAwardEmoji); - mountComponent(); + const awardsCopy = [...new Array(11)].map(createAwardEmoji); + createComponent({ + ...customProps, + awards: awardsCopy, + }); // Testing only the first 10 awards since 11 onward will not be displayed. - awardsMock.slice(0, 10).forEach((award) => { - expect(findTooltip()).toContain(award.user.name); + awardsCopy.slice(0, 10).forEach((award) => { + expect(findAwardButton().attributes('title')).toContain(award.user.name); }); }); }); - describe('when the user cannot award emoji', () => { + describe('when the user cannot award an emoji', () => { beforeEach(() => { - const Component = Vue.extend(awardsNote); - - vm = new Component({ - store, - propsData: { - awards: awardsMock, - noteAuthorId: 2, - noteId: '545', - canAwardEmoji: false, - toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', - }, - }).$mount(); + createComponent({ + awards: awardsMock, + noteAuthorId: 2, + noteId: '545', + canAwardEmoji: false, + toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji', + }); }); - it('should not be possible to remove awarded emoji', () => { - jest.spyOn(vm, 'toggleAwardRequest'); - - vm.$el.querySelector('.js-awards-block button').click(); - - expect(vm.toggleAwardRequest).not.toHaveBeenCalled(); + it('should display an award emoji button with a disabled class', () => { + expect(findAwardButton().classes()).toContain('disabled'); }); it('should not be possible to add new emoji', () => { - expect(vm.$el.querySelector('.js-add-award')).toBeNull(); + expect(findAddAward().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js index b4f185004bb..c4f8e50b969 100644 --- a/spec/frontend/notes/components/note_body_spec.js +++ b/spec/frontend/notes/components/note_body_spec.js @@ -7,10 +7,7 @@ import NoteAwardsList from '~/notes/components/note_awards_list.vue'; import NoteForm from '~/notes/components/note_form.vue'; import createStore from '~/notes/stores'; import notes from '~/notes/stores/modules/index'; -import Autosave from '~/autosave'; - import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; - import { noteableDataMock, notesDataMock, note } from '../mock_data'; jest.mock('~/autosave'); @@ -82,11 +79,6 @@ describe('issue_note_body component', () => { expect(wrapper.findComponent(NoteForm).props('saveButtonTitle')).toBe(buttonText); }); - it('adds autosave', () => { - // passing undefined instead of an element because of shallowMount - expect(Autosave).toHaveBeenCalledWith(undefined, ['Note', note.noteable_type, note.id]); - }); - describe('isInternalNote', () => { beforeEach(() => { wrapper.setProps({ isInternalNote: true }); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 59362e18098..d6413d33c99 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -1,42 +1,39 @@ -import { GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { GlLink, GlFormCheckbox } from '@gitlab/ui'; import { nextTick } from 'vue'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; -import { getDraft, updateDraft } from '~/lib/utils/autosave'; import NoteForm from '~/notes/components/note_form.vue'; import createStore from '~/notes/stores'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { AT_WHO_ACTIVE_CLASS } from '~/gfm_auto_complete'; +import eventHub from '~/environments/event_hub'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { noteableDataMock, notesDataMock, discussionMock, note } from '../mock_data'; jest.mock('~/lib/utils/autosave'); describe('issue_note_form component', () => { - const dummyAutosaveKey = 'some-autosave-key'; - const dummyDraft = 'dummy draft content'; - let store; let wrapper; let props; - const createComponentWrapper = () => { - return mount(NoteForm, { + const createComponentWrapper = (propsData = {}, provide = {}) => { + wrapper = mountExtended(NoteForm, { store, - propsData: props, + propsData: { + ...props, + ...propsData, + }, + provide: { + glFeatures: provide, + }, }); }; - const findCancelButton = () => wrapper.find('[data-testid="cancel"]'); + const findCancelButton = () => wrapper.findByTestId('cancel'); + const findCancelCommentButton = () => wrapper.findByTestId('cancelBatchCommentsEnabled'); + const findMarkdownField = () => wrapper.findComponent(MarkdownField); beforeEach(() => { - getDraft.mockImplementation((key) => { - if (key === dummyAutosaveKey) { - return dummyDraft; - } - - return null; - }); - store = createStore(); store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); @@ -50,27 +47,37 @@ describe('issue_note_form component', () => { describe('noteHash', () => { beforeEach(() => { - wrapper = createComponentWrapper(); + createComponentWrapper(); }); it('returns note hash string based on `noteId`', () => { expect(wrapper.vm.noteHash).toBe(`#note_${props.noteId}`); }); - it('return note hash as `#` when `noteId` is empty', async () => { - wrapper.setProps({ - ...props, + it('return note hash as `#` when `noteId` is empty', () => { + createComponentWrapper({ noteId: '', }); - await nextTick(); expect(wrapper.vm.noteHash).toBe('#'); }); }); + it('hides content editor switcher if feature flag content_editor_on_issues is off', () => { + createComponentWrapper({}, { contentEditorOnIssues: false }); + + expect(wrapper.text()).not.toContain('Rich text'); + }); + + it('shows content editor switcher if feature flag content_editor_on_issues is on', () => { + createComponentWrapper({}, { contentEditorOnIssues: true }); + + expect(wrapper.text()).toContain('Rich text'); + }); + describe('conflicts editing', () => { beforeEach(() => { - wrapper = createComponentWrapper(); + createComponentWrapper(); }); it('should show conflict message if note changes outside the component', async () => { @@ -94,15 +101,13 @@ describe('issue_note_form component', () => { describe('form', () => { beforeEach(() => { - wrapper = createComponentWrapper(); + createComponentWrapper(); }); it('should render text area with placeholder', () => { const textarea = wrapper.find('textarea'); - expect(textarea.attributes('placeholder')).toEqual( - 'Write a comment or drag your files here…', - ); + expect(textarea.attributes('placeholder')).toBe('Write a comment or drag your files here…'); }); it('should set data-supports-quick-actions to enable autocomplete', () => { @@ -117,23 +122,21 @@ describe('issue_note_form component', () => { ${true} | ${'Write an internal note or drag your files here…'} `( 'should set correct textarea placeholder text when discussion confidentiality is $internal', - ({ internal, placeholder }) => { + async ({ internal, placeholder }) => { props.note = { ...note, internal, }; - wrapper = createComponentWrapper(); + createComponentWrapper(); + + await nextTick(); expect(wrapper.find('textarea').attributes('placeholder')).toBe(placeholder); }, ); it('should link to markdown docs', () => { - const { markdownDocsPath } = notesDataMock; - const markdownField = wrapper.findComponent(MarkdownField); - const markdownFieldProps = markdownField.props(); - - expect(markdownFieldProps.markdownDocsPath).toBe(markdownDocsPath); + expect(findMarkdownField().props('markdownDocsPath')).toBe(notesDataMock.markdownDocsPath); }); describe('keyboard events', () => { @@ -146,12 +149,11 @@ describe('issue_note_form component', () => { describe('up', () => { it('should ender edit mode', () => { - // TODO: do not spy on vm - jest.spyOn(wrapper.vm, 'editMyLastNote'); + const eventHubSpy = jest.spyOn(eventHub, '$emit'); textarea.trigger('keydown.up'); - expect(wrapper.vm.editMyLastNote).toHaveBeenCalled(); + expect(eventHubSpy).not.toHaveBeenCalled(); }); }); @@ -159,17 +161,13 @@ describe('issue_note_form component', () => { it('should save note when cmd+enter is pressed', () => { textarea.trigger('keydown.enter', { metaKey: true }); - const { handleFormUpdate } = wrapper.emitted(); - - expect(handleFormUpdate.length).toBe(1); + expect(wrapper.emitted('handleFormUpdate')).toHaveLength(1); }); it('should save note when ctrl+enter is pressed', () => { textarea.trigger('keydown.enter', { ctrlKey: true }); - const { handleFormUpdate } = wrapper.emitted(); - - expect(handleFormUpdate.length).toBe(1); + expect(wrapper.emitted('handleFormUpdate')).toHaveLength(1); }); it('should disable textarea when ctrl+enter is pressed', async () => { @@ -185,151 +183,62 @@ describe('issue_note_form component', () => { }); describe('actions', () => { - it('should be possible to cancel', async () => { - wrapper.setProps({ - ...props, - }); - await nextTick(); + it('should be possible to cancel', () => { + createComponentWrapper(); - const cancelButton = findCancelButton(); - cancelButton.vm.$emit('click'); - await nextTick(); + findCancelButton().vm.$emit('click'); - expect(wrapper.emitted().cancelForm).toHaveLength(1); + expect(wrapper.emitted('cancelForm')).toHaveLength(1); }); it('will not cancel form if there is an active at-who-active class', async () => { - wrapper.setProps({ - ...props, - }); - await nextTick(); + createComponentWrapper(); - const textareaEl = wrapper.vm.$refs.textarea; + const textareaEl = wrapper.vm.$refs.markdownEditor.$el.querySelector('textarea'); const cancelButton = findCancelButton(); textareaEl.classList.add(AT_WHO_ACTIVE_CLASS); cancelButton.vm.$emit('click'); await nextTick(); - expect(wrapper.emitted().cancelForm).toBeUndefined(); + expect(wrapper.emitted('cancelForm')).toBeUndefined(); }); - it('should be possible to update the note', async () => { - wrapper.setProps({ - ...props, - }); - await nextTick(); + it('should be possible to update the note', () => { + createComponentWrapper(); const textarea = wrapper.find('textarea'); textarea.setValue('Foo'); const saveButton = wrapper.find('.js-vue-issue-save'); saveButton.vm.$emit('click'); - expect(wrapper.vm.isSubmitting).toBe(true); + expect(wrapper.emitted('handleFormUpdate')).toHaveLength(1); }); }); }); - describe('with autosaveKey', () => { - describe('with draft', () => { - beforeEach(() => { - Object.assign(props, { - noteBody: '', - autosaveKey: dummyAutosaveKey, - }); - wrapper = createComponentWrapper(); - - return nextTick(); - }); - - it('displays the draft in textarea', () => { - const textarea = wrapper.find('textarea'); - - expect(textarea.element.value).toBe(dummyDraft); - }); - }); - - describe('without draft', () => { - beforeEach(() => { - Object.assign(props, { - noteBody: '', - autosaveKey: 'some key without draft', - }); - wrapper = createComponentWrapper(); - - return nextTick(); - }); - - it('leaves the textarea empty', () => { - const textarea = wrapper.find('textarea'); - - expect(textarea.element.value).toBe(''); - }); - }); - - it('updates the draft if textarea content changes', () => { - Object.assign(props, { - noteBody: '', - autosaveKey: dummyAutosaveKey, - }); - wrapper = createComponentWrapper(); - const textarea = wrapper.find('textarea'); - const dummyContent = 'some new content'; - - textarea.setValue(dummyContent); - - expect(updateDraft).toHaveBeenCalledWith(dummyAutosaveKey, dummyContent); - }); - - it('does not save draft when ctrl+enter is pressed', () => { - const options = { - noteBody: '', - autosaveKey: dummyAutosaveKey, - }; - - props = { ...props, ...options }; - wrapper = createComponentWrapper(); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isSubmittingWithKeydown: true }); - - const textarea = wrapper.find('textarea'); - textarea.setValue('some content'); - textarea.trigger('keydown.enter', { metaKey: true }); - - expect(updateDraft).not.toHaveBeenCalled(); - }); - }); - describe('with batch comments', () => { beforeEach(() => { store.registerModule('batchComments', batchComments()); - wrapper = createComponentWrapper(); - wrapper.setProps({ - ...props, + createComponentWrapper({ isDraft: true, noteId: '', discussion: { ...discussionMock, for_commit: false }, }); }); - it('should be possible to cancel', async () => { - jest.spyOn(wrapper.vm, 'cancelHandler'); + it('should be possible to cancel', () => { + findCancelCommentButton().vm.$emit('click'); - await nextTick(); - const cancelButton = wrapper.find('[data-testid="cancelBatchCommentsEnabled"]'); - cancelButton.vm.$emit('click'); - - expect(wrapper.vm.cancelHandler).toHaveBeenCalledWith(true); + expect(wrapper.emitted('cancelForm')).toEqual([[true, false]]); }); it('shows resolve checkbox', () => { - expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(true); + expect(wrapper.findComponent(GlFormCheckbox).exists()).toBe(true); }); - it('hides resolve checkbox', async () => { - wrapper.setProps({ + it('hides resolve checkbox', () => { + createComponentWrapper({ isDraft: false, discussion: { ...discussionMock, @@ -344,15 +253,11 @@ describe('issue_note_form component', () => { }, }); - await nextTick(); - - expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(false); + expect(wrapper.findComponent(GlFormCheckbox).exists()).toBe(false); }); - it('hides actions for commits', async () => { - wrapper.setProps({ discussion: { for_commit: true } }); - - await nextTick(); + it('hides actions for commits', () => { + createComponentWrapper({ discussion: { for_commit: true } }); expect(wrapper.find('.note-form-actions').text()).not.toContain('Start a review'); }); @@ -361,13 +266,12 @@ describe('issue_note_form component', () => { it('should start review or add to review when cmd+enter is pressed', async () => { const textarea = wrapper.find('textarea'); - jest.spyOn(wrapper.vm, 'handleAddToReview'); - textarea.setValue('Foo'); textarea.trigger('keydown.enter', { metaKey: true }); await nextTick(); - expect(wrapper.vm.handleAddToReview).toHaveBeenCalled(); + + expect(wrapper.emitted('handleFormUpdateAddToReview')).toEqual([['Foo', false]]); }); }); }); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index b158cfff10d..bce335aa035 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -375,6 +375,17 @@ describe('issue_note', () => { expect(wrapper.emitted('handleUpdateNote')).toHaveLength(1); }); + it('should not update note with sensitive token', () => { + const sensitiveMessage = 'token: glpat-1234567890abcdefghij'; + + createWrapper(); + updateActions(); + wrapper + .findComponent(NoteBody) + .vm.$emit('handleFormUpdate', { ...params, noteText: sensitiveMessage }); + expect(updateNote).not.toHaveBeenCalled(); + }); + it('does not stringify empty position', () => { createWrapper(); updateActions(); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index 832264aa7d3..3fe31506223 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -174,7 +174,7 @@ describe('note_app', () => { }); describe('while fetching data', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent(); }); diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js index 40f10ca901b..355ecb78187 100644 --- a/spec/frontend/notes/deprecated_notes_spec.js +++ b/spec/frontend/notes/deprecated_notes_spec.js @@ -1,9 +1,11 @@ /* eslint-disable import/no-commonjs, no-new */ -import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import MockAdapter from 'axios-mock-adapter'; +import htmlPipelineSchedulesEditSnippets from 'test_fixtures/snippets/show.html'; +import htmlPipelineSchedulesEditCommit from 'test_fixtures/commit/show.html'; import '~/behaviors/markdown/render_gfm'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; @@ -19,7 +21,6 @@ const Notes = require('~/deprecated_notes').default; const FLASH_TYPE_ALERT = 'alert'; const NOTES_POST_PATH = /(.*)\/notes\?html=true$/; -const fixture = 'snippets/show.html'; let mockAxios; window.project_uploads_path = `${TEST_HOST}/uploads`; @@ -36,7 +37,7 @@ function wrappedDiscussionNote(note) { // eslint-disable-next-line jest/no-disabled-tests describe.skip('Old Notes (~/deprecated_notes.js)', () => { beforeEach(() => { - loadHTMLFixture(fixture); + setHTMLFixture(htmlPipelineSchedulesEditSnippets); // Re-declare this here so that test_setup.js#beforeEach() doesn't // overwrite it. @@ -671,7 +672,7 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { let $notesContainer; beforeEach(() => { - loadHTMLFixture('commit/show.html'); + setHTMLFixture(htmlPipelineSchedulesEditCommit); mockAxios.onPost(NOTES_POST_PATH).reply(HTTP_STATUS_OK, note); new Notes('', []); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 0d3ebea7af2..97249d232dc 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -257,14 +257,14 @@ describe('Actions Notes Store', () => { axiosMock.onGet(notesDataMock.notesPath).reply(HTTP_STATUS_OK, pollResponse, pollHeaders); const failureMock = () => axiosMock.onGet(notesDataMock.notesPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - const advanceAndRAF = async (time) => { + const advanceAndRAF = (time) => { if (time) { jest.advanceTimersByTime(time); } return waitForPromises(); }; - const advanceXMoreIntervals = async (number) => { + const advanceXMoreIntervals = (number) => { const timeoutLength = pollInterval * number; return advanceAndRAF(timeoutLength); @@ -273,7 +273,7 @@ describe('Actions Notes Store', () => { await store.dispatch('poll'); await advanceAndRAF(2); }; - const cleanUp = async () => { + const cleanUp = () => { jest.clearAllTimers(); return store.dispatch('stopPolling'); diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js index 0fbd073191e..480d617fcb2 100644 --- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js +++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js @@ -103,7 +103,7 @@ describe('CustomNotificationsModal', () => { ${1} | ${'new_note'} | ${'New note'} | ${false} | ${false} `( 'renders a checkbox for "$eventName" with checked=$enabled', - async ({ index, eventName, enabled, loading }) => { + ({ index, eventName, enabled, loading }) => { const checkbox = findCheckboxAt(index); expect(checkbox.text()).toContain(eventName); expect(checkbox.vm.$attrs.checked).toBe(enabled); diff --git a/spec/frontend/oauth_application/components/oauth_secret_spec.js b/spec/frontend/oauth_application/components/oauth_secret_spec.js new file mode 100644 index 00000000000..c38bd066da8 --- /dev/null +++ b/spec/frontend/oauth_application/components/oauth_secret_spec.js @@ -0,0 +1,116 @@ +import { GlButton, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import OAuthSecret from '~/oauth_application/components/oauth_secret.vue'; +import { + RENEW_SECRET_FAILURE, + RENEW_SECRET_SUCCESS, + WARNING_NO_SECRET, +} from '~/oauth_application/constants'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +jest.mock('~/alert'); +const mockEvent = { preventDefault: jest.fn() }; + +describe('OAuthSecret', () => { + let wrapper; + const renewPath = '/applications/1/renew'; + + const createComponent = (provide = {}) => { + wrapper = shallowMount(OAuthSecret, { + provide: { + initialSecret: undefined, + renewPath, + ...provide, + }, + }); + }; + + const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility); + const findRenewSecretButton = () => wrapper.findComponent(GlButton); + const findModal = () => wrapper.findComponent(GlModal); + + describe('when secret is provided', () => { + const initialSecret = 'my secret'; + beforeEach(() => { + createComponent({ initialSecret }); + }); + + it('shows the masked secret', () => { + expect(findInputCopyToggleVisibility().props('value')).toBe(initialSecret); + }); + + it('shows the renew secret button', () => { + expect(findRenewSecretButton().exists()).toBe(true); + }); + }); + + describe('when secret is not provided', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows an alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: WARNING_NO_SECRET, + variant: VARIANT_WARNING, + }); + }); + + it('shows the renew secret button', () => { + expect(findRenewSecretButton().exists()).toBe(true); + }); + + describe('when renew secret button is selected', () => { + beforeEach(() => { + createComponent(); + findRenewSecretButton().vm.$emit('click'); + }); + + it('shows a modal', () => { + expect(findModal().props('visible')).toBe(true); + }); + + describe('when secret renewal succeeds', () => { + const initialSecret = 'my secret'; + + beforeEach(async () => { + const mockAxios = new MockAdapter(axios); + mockAxios.onPut().reply(HTTP_STATUS_OK, { secret: initialSecret }); + findModal().vm.$emit('primary', mockEvent); + await waitForPromises(); + }); + + it('shows an alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: RENEW_SECRET_SUCCESS, + variant: VARIANT_SUCCESS, + }); + }); + + it('shows the new secret', () => { + expect(findInputCopyToggleVisibility().props('value')).toBe(initialSecret); + }); + }); + + describe('when secret renewal fails', () => { + beforeEach(async () => { + const mockAxios = new MockAdapter(axios); + mockAxios.onPut().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + findModal().vm.$emit('primary', mockEvent); + await waitForPromises(); + }); + + it('creates an alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: RENEW_SECRET_FAILURE, + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js index 1fa0e0aa8f6..7be3d441eb3 100644 --- a/spec/frontend/oauth_remember_me_spec.js +++ b/spec/frontend/oauth_remember_me_spec.js @@ -17,19 +17,16 @@ describe('OAuthRememberMe', () => { resetHTMLFixture(); }); - it('adds the "remember_me" query parameter to all OAuth login buttons', () => { - $('#oauth-container #remember_me').click(); + it('adds and removes the "remember_me" query parameter from all OAuth login buttons', () => { + $('#oauth-container #remember_me_omniauth').click(); expect(findFormAction('.twitter')).toBe('http://example.com/?remember_me=1'); expect(findFormAction('.github')).toBe('http://example.com/?remember_me=1'); expect(findFormAction('.facebook')).toBe( 'http://example.com/?redirect_fragment=L1&remember_me=1', ); - }); - it('removes the "remember_me" query parameter from all OAuth login buttons', () => { - $('#oauth-container #remember_me').click(); - $('#oauth-container #remember_me').click(); + $('#oauth-container #remember_me_omniauth').click(); expect(findFormAction('.twitter')).toBe('http://example.com/'); expect(findFormAction('.github')).toBe('http://example.com/'); diff --git a/spec/frontend/observability/index_spec.js b/spec/frontend/observability/index_spec.js index 83f72ff72b5..25eb048c62b 100644 --- a/spec/frontend/observability/index_spec.js +++ b/spec/frontend/observability/index_spec.js @@ -52,7 +52,7 @@ describe('renderObservability', () => { ); }); - it('handle route-update events', async () => { + it('handle route-update events', () => { component.vm.$router.push('/something?foo=bar'); component.vm.$emit('route-update', { url: '/some_path' }); expect(component.vm.$router.currentRoute.path).toBe('/something'); diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js index ee450dfc851..6ea08d4a9a5 100644 --- a/spec/frontend/operation_settings/components/metrics_settings_spec.js +++ b/spec/frontend/operation_settings/components/metrics_settings_spec.js @@ -198,7 +198,7 @@ describe('operation settings external dashboard component', () => { expect(refreshCurrentPage).toHaveBeenCalled(); }); - it('creates alert banner on error', async () => { + it('creates an alert on error', async () => { mountComponent(false); const message = 'mockErrorMessage'; axios.patch.mockRejectedValue({ response: { data: { message } } }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js index 9e443234c34..01089422376 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js @@ -1,10 +1,10 @@ import { GlDropdownItem, GlIcon, GlDropdown } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; import component from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue'; @@ -22,37 +22,27 @@ import { } from '~/packages_and_registries/container_registry/explorer/constants'; import getContainerRepositoryMetadata from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import { imageTagsCountMock } from '../../mock_data'; +import { containerRepositoryMock, imageTagsCountMock } from '../../mock_data'; describe('Details Header', () => { let wrapper; let apolloProvider; const defaultImage = { - name: 'foo', - updatedAt: '2020-11-03T13:29:21Z', - canDelete: true, - project: { - visibility: 'public', - path: 'path', - containerExpirationPolicy: { - enabled: false, - }, - }, + ...containerRepositoryMock, }; // set the date to Dec 4, 2020 useFakeDate(2020, 11, 4); - const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); - const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility'); - const findTitle = () => findByTestId('title'); - const findTagsCount = () => findByTestId('tags-count'); - const findCleanup = () => findByTestId('cleanup'); + const findCreatedAndVisibility = () => wrapper.findByTestId('created-and-visibility'); + const findTitle = () => wrapper.findByTestId('title'); + const findTagsCount = () => wrapper.findByTestId('tags-count'); + const findCleanup = () => wrapper.findByTestId('cleanup'); const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); const findInfoIcon = () => wrapper.findComponent(GlIcon); const findMenu = () => wrapper.findComponent(GlDropdown); - const findSize = () => findByTestId('image-size'); + const findSize = () => wrapper.findByTestId('image-size'); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -69,7 +59,7 @@ describe('Details Header', () => { const requestHandlers = [[getContainerRepositoryMetadata, resolver]]; apolloProvider = createMockApollo(requestHandlers); - wrapper = shallowMount(component, { + wrapper = shallowMountExtended(component, { apolloProvider, propsData, directives: { @@ -97,7 +87,7 @@ describe('Details Header', () => { }); it('root image shows project path name', () => { - expect(findTitle().text()).toBe('path'); + expect(findTitle().text()).toBe('gitlab-test'); }); it('has an icon', () => { @@ -119,7 +109,7 @@ describe('Details Header', () => { }); it('shows image.name', () => { - expect(findTitle().text()).toContain('foo'); + expect(findTitle().text()).toContain('rails-12009'); }); it('has no icon', () => { @@ -247,7 +237,7 @@ describe('Details Header', () => { expect(findCleanup().props('icon')).toBe('expire'); }); - it('when the expiration policy is disabled', async () => { + it('when cleanup is not scheduled', async () => { mountComponent(); await waitForMetadataItems(); @@ -287,12 +277,12 @@ describe('Details Header', () => { ); }); - describe('visibility and updated at', () => { - it('has last updated text', async () => { + describe('visibility and created at', () => { + it('has created text', async () => { mountComponent(); await waitForMetadataItems(); - expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago'); + expect(findCreatedAndVisibility().props('text')).toBe('Created Nov 3, 2020 13:29'); }); describe('visibility icon', () => { @@ -300,7 +290,7 @@ describe('Details Header', () => { mountComponent(); await waitForMetadataItems(); - expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); + expect(findCreatedAndVisibility().props('icon')).toBe('eye'); }); it('shows an eye slashed when the project is not public', async () => { mountComponent({ @@ -308,7 +298,7 @@ describe('Details Header', () => { }); await waitForMetadataItems(); - expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); + expect(findCreatedAndVisibility().props('icon')).toBe('eye-slash'); }); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js index 09d0370efbf..0cbb9eab018 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js @@ -4,13 +4,15 @@ import { GlEmptyState } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; - +import Tracking from '~/tracking'; import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue'; import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; +import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; + import { GRAPHQL_PAGE_SIZE, NO_TAGS_TITLE, @@ -19,7 +21,13 @@ import { NO_TAGS_MATCHING_FILTERS_DESCRIPTION, } from '~/packages_and_registries/container_registry/explorer/constants/index'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; -import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; +import { + graphQLDeleteImageRepositoryTagsMock, + tagsMock, + imageTagsMock, + tagsPageInfo, +} from '../../mock_data'; +import { DeleteModal } from '../../stubs'; describe('Tags List', () => { let wrapper; @@ -31,6 +39,7 @@ describe('Tags List', () => { noContainersImage: 'noContainersImage', }; + const findDeleteModal = () => wrapper.findComponent(DeleteModal); const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); const findTagsListRow = () => wrapper.findAllComponents(TagsListRow); const findRegistryList = () => wrapper.findComponent(RegistryList); @@ -42,20 +51,23 @@ describe('Tags List', () => { }; const waitForApolloRequestRender = async () => { + fireFirstSortUpdate(); await waitForPromises(); - await nextTick(); }; - const mountComponent = ({ propsData = { isMobile: false, id: 1 } } = {}) => { + const mountComponent = ({ propsData = { isMobile: false, id: 1 }, mutationResolver } = {}) => { Vue.use(VueApollo); - const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]]; + const requestHandlers = [ + [getContainerRepositoryTagsQuery, resolver], + [deleteContainerRepositoryTagsMutation, mutationResolver], + ]; apolloProvider = createMockApollo(requestHandlers); wrapper = shallowMount(component, { apolloProvider, propsData, - stubs: { RegistryList }, + stubs: { RegistryList, DeleteModal }, provide() { return { config: defaultConfig, @@ -66,12 +78,12 @@ describe('Tags List', () => { beforeEach(() => { resolver = jest.fn().mockResolvedValue(imageTagsMock()); + jest.spyOn(Tracking, 'event'); }); describe('registry list', () => { beforeEach(async () => { mountComponent(); - fireFirstSortUpdate(); await waitForApolloRequestRender(); }); @@ -126,11 +138,46 @@ describe('Tags List', () => { }); }); - it('emits a delete event when list emits delete', () => { - const eventPayload = 'foo'; - findRegistryList().vm.$emit('delete', eventPayload); + describe('delete event', () => { + describe('single item', () => { + beforeEach(() => { + findRegistryList().vm.$emit('delete', [tags[0]]); + }); + + it('opens the modal', () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); + + it('sets modal props', () => { + expect(findDeleteModal().props('itemsToBeDeleted')).toMatchObject([tags[0]]); + }); + + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'registry_tag_delete', + }); + }); + }); + + describe('multiple items', () => { + beforeEach(() => { + findRegistryList().vm.$emit('delete', tags); + }); + + it('opens the modal', () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); - expect(wrapper.emitted('delete')).toEqual([[eventPayload]]); + it('sets modal props', () => { + expect(findDeleteModal().props('itemsToBeDeleted')).toMatchObject(tags); + }); + + it('tracks multiple delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'bulk_registry_tag_delete', + }); + }); + }); }); }); }); @@ -138,7 +185,6 @@ describe('Tags List', () => { describe('list rows', () => { it('one row exist for each tag', async () => { mountComponent(); - fireFirstSortUpdate(); await waitForApolloRequestRender(); @@ -147,7 +193,6 @@ describe('Tags List', () => { it('the correct props are bound to it', async () => { mountComponent({ propsData: { disabled: true, id: 1 } }); - fireFirstSortUpdate(); await waitForApolloRequestRender(); @@ -162,7 +207,6 @@ describe('Tags List', () => { describe('events', () => { it('select event update the selected items', async () => { mountComponent(); - fireFirstSortUpdate(); await waitForApolloRequestRender(); findTagsListRow().at(0).vm.$emit('select'); @@ -172,13 +216,44 @@ describe('Tags List', () => { expect(findTagsListRow().at(0).attributes('selected')).toBe('true'); }); - it('delete event emit a delete event', async () => { - mountComponent(); - fireFirstSortUpdate(); - await waitForApolloRequestRender(); + describe('delete event', () => { + let mutationResolver; + + beforeEach(async () => { + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); + resolver = jest.fn().mockResolvedValue(imageTagsMock()); + mountComponent({ mutationResolver }); - findTagsListRow().at(0).vm.$emit('delete'); - expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name); + await waitForApolloRequestRender(); + findTagsListRow().at(0).vm.$emit('delete'); + }); + + it('opens the modal', () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); + + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'registry_tag_delete', + }); + }); + + it('confirmDelete event calls apollo mutation with the right parameters and refetches the tags list query', async () => { + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: [tags[0].name] }), + ); + + await waitForPromises(); + + expect(resolver).toHaveBeenLastCalledWith({ + first: GRAPHQL_PAGE_SIZE, + name: '', + sort: 'NAME_ASC', + id: '1', + }); + }); }); }); }); @@ -187,7 +262,6 @@ describe('Tags List', () => { it('sets registry list hiddenDelete prop to true', async () => { resolver = jest.fn().mockResolvedValue(imageTagsMock({ canDelete: false })); mountComponent(); - fireFirstSortUpdate(); await waitForApolloRequestRender(); expect(findRegistryList().props('hiddenDelete')).toBe(true); @@ -198,7 +272,6 @@ describe('Tags List', () => { beforeEach(async () => { resolver = jest.fn().mockResolvedValue(imageTagsMock({ nodes: [] })); mountComponent(); - fireFirstSortUpdate(); await waitForApolloRequestRender(); }); @@ -225,7 +298,7 @@ describe('Tags List', () => { filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'foo' } }], }); - await waitForApolloRequestRender(); + await waitForPromises(); expect(findEmptyState().props()).toMatchObject({ svgPath: defaultConfig.noContainersImage, @@ -236,6 +309,175 @@ describe('Tags List', () => { }); }); + describe('modal', () => { + it('exists', async () => { + mountComponent(); + await waitForApolloRequestRender(); + + expect(findDeleteModal().exists()).toBe(true); + }); + + describe('cancel event', () => { + it('tracks cancel_delete', async () => { + mountComponent(); + await waitForApolloRequestRender(); + + findDeleteModal().vm.$emit('cancel'); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', { + label: 'registry_tag_delete', + }); + }); + }); + + describe('confirmDelete event', () => { + let mutationResolver; + + describe('when mutation', () => { + beforeEach(() => { + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); + mountComponent({ mutationResolver }); + + return waitForApolloRequestRender(); + }); + + it('is started renders loader', async () => { + findRegistryList().vm.$emit('delete', [tags[0]]); + + findDeleteModal().vm.$emit('confirmDelete'); + await nextTick(); + + expect(findTagsLoader().exists()).toBe(true); + expect(findTagsListRow().exists()).toBe(false); + }); + + it('ends, loader is hidden', async () => { + findRegistryList().vm.$emit('delete', [tags[0]]); + + findDeleteModal().vm.$emit('confirmDelete'); + await waitForPromises(); + + expect(findTagsLoader().exists()).toBe(false); + expect(findTagsListRow().exists()).toBe(true); + }); + }); + + describe.each([ + { + description: 'rejection', + mutationMock: jest.fn().mockRejectedValue(), + }, + { + description: 'error', + mutationMock: jest.fn().mockResolvedValue({ + data: { + destroyContainerRepositoryTags: { + errors: [new Error()], + }, + }, + }), + }, + ])('when mutation fails with $description', ({ mutationMock }) => { + beforeEach(() => { + mutationResolver = mutationMock; + mountComponent({ mutationResolver }); + + return waitForApolloRequestRender(); + }); + + it('when one item is selected to be deleted calls apollo mutation with the right parameters and emits delete event with right arguments', async () => { + findRegistryList().vm.$emit('delete', [tags[0]]); + + resolver.mockClear(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: [tags[0].name] }), + ); + + expect(resolver).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(wrapper.emitted('delete')).toHaveLength(1); + expect(wrapper.emitted('delete')[0][0]).toEqual('danger_tag'); + }); + + it('when more than one item is selected to be deleted calls apollo mutation with the right parameters and emits delete event with right arguments', async () => { + findRegistryList().vm.$emit('delete', tagsMock); + resolver.mockClear(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }), + ); + + expect(resolver).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(wrapper.emitted('delete')).toHaveLength(1); + expect(wrapper.emitted('delete')[0][0]).toEqual('danger_tags'); + }); + }); + + describe('when mutation is successful', () => { + beforeEach(() => { + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); + mountComponent({ mutationResolver }); + + return waitForApolloRequestRender(); + }); + + it('and one item is selected to be deleted calls apollo mutation with the right parameters and refetches the tags list query', async () => { + findRegistryList().vm.$emit('delete', [tags[0]]); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: [tags[0].name] }), + ); + + expect(resolver).toHaveBeenLastCalledWith({ + first: GRAPHQL_PAGE_SIZE, + name: '', + sort: 'NAME_ASC', + id: '1', + }); + + await waitForPromises(); + + expect(wrapper.emitted('delete')).toHaveLength(1); + expect(wrapper.emitted('delete')[0][0]).toEqual('success_tag'); + }); + + it('and more than one item is selected to be deleted calls apollo mutation with the right parameters and refetches the tags list query', async () => { + findRegistryList().vm.$emit('delete', tagsMock); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }), + ); + + expect(resolver).toHaveBeenLastCalledWith({ + first: GRAPHQL_PAGE_SIZE, + name: '', + sort: 'NAME_ASC', + id: '1', + }); + + await waitForPromises(); + + expect(wrapper.emitted('delete')).toHaveLength(1); + expect(wrapper.emitted('delete')[0][0]).toEqual('success_tags'); + }); + }); + }); + }); + describe('loading state', () => { it.each` isImageLoading | queryExecuting | loadingVisible @@ -247,7 +489,6 @@ describe('Tags List', () => { 'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown', async ({ isImageLoading, queryExecuting, loadingVisible }) => { mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } }); - fireFirstSortUpdate(); if (!queryExecuting) { await waitForApolloRequestRender(); } diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index 45304cc2329..b7f3698e155 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -81,7 +81,7 @@ describe('registry_header', () => { }); }); - describe('expiration policy', () => { + describe('cleanup policy', () => { it('when is disabled', async () => { await mountComponent({ expirationPolicy: { enabled: false }, @@ -111,11 +111,11 @@ describe('registry_header', () => { const cleanupLink = findSetupCleanUpLink(); expect(text.exists()).toBe(true); - expect(text.props('text')).toBe('Expiration policy will run in '); + expect(text.props('text')).toBe('Cleanup will run in '); expect(cleanupLink.exists()).toBe(true); expect(cleanupLink.text()).toBe(SET_UP_CLEANUP); }); - it('when the expiration policy is completely disabled', async () => { + it('when the cleanup policy is not scheduled', async () => { await mountComponent({ expirationPolicy: { enabled: true }, expirationPolicyHelpPagePath: 'foo', diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js index cd54b856c97..8ca74f5077e 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js @@ -127,7 +127,6 @@ export const containerRepositoryMock = { location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009', canDelete: true, createdAt: '2020-11-03T13:29:21Z', - updatedAt: '2020-11-03T13:29:21Z', expirationPolicyStartedAt: null, expirationPolicyCleanupStatus: 'UNSCHEDULED', project: { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js index 888c3e5bffa..7fed81acead 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -22,22 +22,15 @@ import { MISSING_OR_DELETED_IMAGE_TITLE, MISSING_OR_DELETED_IMAGE_MESSAGE, } from '~/packages_and_registries/container_registry/explorer/constants'; -import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; -import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; -import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql'; import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue'; import Tracking from '~/tracking'; import { graphQLImageDetailsMock, - graphQLDeleteImageRepositoryTagsMock, - graphQLProjectImageRepositoriesDetailsMock, containerRepositoryMock, graphQLEmptyImageDetailsMock, - tagsMock, - imageTagsMock, } from '../mock_data'; import { DeleteModal } from '../stubs'; @@ -69,13 +62,6 @@ describe('Details Page', () => { isGroupPage: false, }; - const cleanTags = tagsMock.map((t) => { - const result = { ...t }; - // eslint-disable-next-line no-underscore-dangle - delete result.__typename; - return result; - }); - const waitForApolloRequestRender = async () => { await waitForPromises(); await nextTick(); @@ -83,20 +69,12 @@ describe('Details Page', () => { const mountComponent = ({ resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), - mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), - tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock())), - detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), options, config = defaultConfig, } = {}) => { Vue.use(VueApollo); - const requestHandlers = [ - [getContainerRepositoryDetailsQuery, resolver], - [deleteContainerRepositoryTagsMutation, mutationResolver], - [getContainerRepositoryTagsQuery, tagsResolver], - [getContainerRepositoriesDetails, detailsResolver], - ]; + const requestHandlers = [[getContainerRepositoryDetailsQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); @@ -184,50 +162,6 @@ describe('Details Page', () => { isMobile: false, }); }); - - describe('deleteEvent', () => { - describe('single item', () => { - let tagToBeDeleted; - beforeEach(async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - [tagToBeDeleted] = cleanTags; - findTagsList().vm.$emit('delete', [tagToBeDeleted]); - }); - - it('open the modal', async () => { - expect(DeleteModal.methods.show).toHaveBeenCalled(); - }); - - it('tracks a single delete event', () => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { - label: 'registry_tag_delete', - }); - }); - }); - - describe('multiple items', () => { - beforeEach(async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - findTagsList().vm.$emit('delete', cleanTags); - }); - - it('open the modal', () => { - expect(DeleteModal.methods.show).toHaveBeenCalled(); - }); - - it('tracks a single delete event', () => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { - label: 'bulk_registry_tag_delete', - }); - }); - }); - }); }); describe('modal', () => { @@ -248,61 +182,24 @@ describe('Details Page', () => { findDeleteModal().vm.$emit('cancel'); expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', { - label: 'registry_tag_delete', + label: 'registry_image_delete', }); }); }); - describe('confirmDelete event', () => { - let mutationResolver; - let tagsResolver; - let detailsResolver; - + describe('tags list delete event', () => { beforeEach(() => { - mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); - tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock())); - detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); - mountComponent({ mutationResolver, tagsResolver, detailsResolver }); + mountComponent(); return waitForApolloRequestRender(); }); - describe('when one item is selected to be deleted', () => { - it('calls apollo mutation with the right parameters and refetches the tags list query', async () => { - findTagsList().vm.$emit('delete', [cleanTags[0]]); - - await nextTick(); - - findDeleteModal().vm.$emit('confirmDelete'); - - expect(mutationResolver).toHaveBeenCalledWith( - expect.objectContaining({ tagNames: [cleanTags[0].name] }), - ); - - await waitForPromises(); - - expect(tagsResolver).toHaveBeenCalled(); - expect(detailsResolver).toHaveBeenCalled(); - }); - }); - - describe('when more than one item is selected to be deleted', () => { - it('calls apollo mutation with the right parameters and refetches the tags list query', async () => { - findTagsList().vm.$emit('delete', tagsMock); - - await nextTick(); + it('sets delete alert modal deleteAlertType value', async () => { + findTagsList().vm.$emit('delete', 'success_tag'); - findDeleteModal().vm.$emit('confirmDelete'); - - expect(mutationResolver).toHaveBeenCalledWith( - expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }), - ); - - await waitForPromises(); + await nextTick(); - expect(tagsResolver).toHaveBeenCalled(); - expect(detailsResolver).toHaveBeenCalled(); - }); + expect(findDeleteAlert().props('deleteAlertType')).toBe('success_tag'); }); }); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index c2ae34ce697..2e7195aa59b 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -5,7 +5,6 @@ import { GlFormInputGroup, GlFormGroup, GlModal, - GlSkeletonLoader, GlSprintf, GlEmptyState, } from '@gitlab/ui'; @@ -72,8 +71,6 @@ describe('DependencyProxyApp', () => { const findClipBoardButton = () => wrapper.findComponent(ClipboardButton); const findFormGroup = () => wrapper.findComponent(GlFormGroup); const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); - const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findMainArea = () => wrapper.findByTestId('main-area'); const findProxyCountText = () => wrapper.findByTestId('proxy-count'); const findManifestList = () => wrapper.findComponent(ManifestsList); const findEmptyState = () => wrapper.findComponent(GlEmptyState); @@ -99,23 +96,11 @@ describe('DependencyProxyApp', () => { describe('when the dependency proxy is available', () => { describe('when is loading', () => { - it('renders the skeleton loader', () => { - createComponent(); - - expect(findSkeletonLoader().exists()).toBe(true); - }); - it('does not render a form group with label', () => { createComponent(); expect(findFormGroup().exists()).toBe(false); }); - - it('does not show the main section', () => { - createComponent(); - - expect(findMainArea().exists()).toBe(false); - }); }); describe('when the app is loaded', () => { @@ -125,10 +110,6 @@ describe('DependencyProxyApp', () => { return waitForPromises(); }); - it('renders the main area', () => { - expect(findMainArea().exists()).toBe(true); - }); - it('renders a form group with a label', () => { expect(findFormGroup().attributes('label')).toBe( DependencyProxyApp.i18n.proxyImagePrefix, @@ -213,13 +194,6 @@ describe('DependencyProxyApp', () => { }); describe('triggering page event on list', () => { - it('re-renders the skeleton loader', async () => { - findManifestList().vm.$emit('next-page'); - await nextTick(); - - expect(findSkeletonLoader().exists()).toBe(true); - }); - it('renders form group with label', async () => { findManifestList().vm.$emit('next-page'); await nextTick(); @@ -228,13 +202,6 @@ describe('DependencyProxyApp', () => { expect.stringMatching(DependencyProxyApp.i18n.proxyImagePrefix), ); }); - - it('does not show the main section', async () => { - findManifestList().vm.$emit('next-page'); - await nextTick(); - - expect(findMainArea().exists()).toBe(false); - }); }); it('shows the clear cache dropdown list', () => { diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js index 639a4fbb99d..0d8af42bae3 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js @@ -1,7 +1,6 @@ -import { GlKeysetPagination } from '@gitlab/ui'; +import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; - import Component from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue'; import { proxyManifests, @@ -14,6 +13,7 @@ describe('Manifests List', () => { const defaultProps = { manifests: proxyManifests(), pagination: pagination(), + loading: false, }; const createComponent = (propsData = defaultProps) => { @@ -24,6 +24,8 @@ describe('Manifests List', () => { const findRows = () => wrapper.findAllComponents(ManifestRow); const findPagination = () => wrapper.findComponent(GlKeysetPagination); + const findMainArea = () => wrapper.findByTestId('main-area'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); it('has the correct title', () => { createComponent(); @@ -45,6 +47,19 @@ describe('Manifests List', () => { }); }); + describe('loading', () => { + it.each` + loading | expectLoader | expectContent + ${false} | ${false} | ${true} + ${true} | ${true} | ${false} + `('when loading is $loading', ({ loading, expectLoader, expectContent }) => { + createComponent({ ...defaultProps, loading }); + + expect(findSkeletonLoader().exists()).toBe(expectLoader); + expect(findMainArea().exists()).toBe(expectContent); + }); + }); + describe('pagination', () => { it('is hidden when there is no next or prev pages', () => { createComponent({ ...defaultProps, pagination: {} }); diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js index 63ea8feb1e7..1bc2657822e 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js @@ -74,7 +74,7 @@ describe('Harbor List Page', () => { }); describe('isLoading is true', () => { - it('shows the skeleton loader', async () => { + it('shows the skeleton loader', () => { mountComponent(); fireFirstSortUpdate(); @@ -93,7 +93,7 @@ describe('Harbor List Page', () => { expect(findCliCommands().exists()).toBe(false); }); - it('title has the metadataLoading props set to true', async () => { + it('title has the metadataLoading props set to true', () => { mountComponent(); fireFirstSortUpdate(); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js index 7c7faa8a3b0..12859b1d77c 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js @@ -32,7 +32,7 @@ describe('Infrastructure Title', () => { }); it('has the correct title', () => { - expect(findTitleArea().props('title')).toBe('Infrastructure Registry'); + expect(findTitleArea().props('title')).toBe('Terraform Module Registry'); }); describe('with no modules', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js b/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js index d0817a8678e..b0fc9ef0f0c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/delete_modal_spec.js @@ -1,7 +1,14 @@ -import { GlModal as RealGlModal } from '@gitlab/ui'; +import { GlModal as RealGlModal, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; +import { + DELETE_PACKAGE_MODAL_PRIMARY_ACTION, + DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT, + DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION, + DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT, + DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION, +} from '~/packages_and_registries/package_registry/constants'; const GlModal = stubComponent(RealGlModal, { methods: { @@ -15,21 +22,28 @@ describe('DeleteModal', () => { const defaultItemsToBeDeleted = [ { name: 'package 01', + version: '1.0.0', }, { name: 'package 02', + version: '1.0.0', }, ]; const findModal = () => wrapper.findComponent(GlModal); - const mountComponent = ({ itemsToBeDeleted = defaultItemsToBeDeleted } = {}) => { + const mountComponent = ({ + itemsToBeDeleted = defaultItemsToBeDeleted, + showRequestForwardingContent = false, + } = {}) => { wrapper = shallowMountExtended(DeleteModal, { propsData: { itemsToBeDeleted, + showRequestForwardingContent, }, stubs: { GlModal, + GlSprintf, }, }); }; @@ -50,11 +64,64 @@ describe('DeleteModal', () => { }); it('renders description', () => { - expect(findModal().text()).toContain( + expect(findModal().text()).toMatchInterpolatedText( 'You are about to delete 2 packages. This operation is irreversible.', ); }); + it('with only one item to be deleted renders correct description', () => { + mountComponent({ itemsToBeDeleted: [defaultItemsToBeDeleted[0]] }); + + expect(findModal().text()).toMatchInterpolatedText( + 'You are about to delete version 1.0.0 of package 01. Are you sure?', + ); + }); + + it('sets the right action primary text', () => { + expect(findModal().props('actionPrimary')).toMatchObject({ + text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION, + }); + }); + + describe('when showRequestForwardingContent is set', () => { + it('renders correct description', () => { + mountComponent({ showRequestForwardingContent: true }); + + expect(findModal().text()).toMatchInterpolatedText( + DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT, + ); + }); + + it('sets the right action primary text', () => { + mountComponent({ showRequestForwardingContent: true }); + + expect(findModal().props('actionPrimary')).toMatchObject({ + text: DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION, + }); + }); + + describe('and only one item to be deleted', () => { + beforeEach(() => { + mountComponent({ + showRequestForwardingContent: true, + itemsToBeDeleted: [defaultItemsToBeDeleted[0]], + }); + }); + + it('renders correct description', () => { + expect(findModal().text()).toMatchInterpolatedText( + DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT, + ); + }); + + it('sets the right action primary text', () => { + expect(findModal().props('actionPrimary')).toMatchObject({ + text: DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION, + }); + }); + }); + }); + it('emits confirm when primary event is emitted', () => { expect(wrapper.emitted('confirm')).toBeUndefined(); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js index fc7f5c80d45..a700f42d367 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js @@ -1,5 +1,11 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { stubComponent } from 'helpers/stub_component'; +import waitForPromises from 'helpers/wait_for_promises'; import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue'; import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'; @@ -14,24 +20,26 @@ import { DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION, + GRAPHQL_PAGE_SIZE, } from '~/packages_and_registries/package_registry/constants'; -import { packageData } from '../../mock_data'; +import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql//queries/get_package_versions.query.graphql'; +import { + emptyPackageVersionsQuery, + packageVersionsQuery, + packageVersions, + pagination, +} from '../../mock_data'; + +Vue.use(VueApollo); describe('PackageVersionsList', () => { let wrapper; + let apolloProvider; const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>empty message</div>' }; - const packageList = [ - packageData({ - name: 'version 1', - }), - packageData({ - id: 'gid://gitlab/Packages::Package/112', - name: 'version 2', - }), - ]; const uiElements = { + findAlert: () => wrapper.findComponent(GlAlert), findLoader: () => wrapper.findComponent(PackagesListLoader), findRegistryList: () => wrapper.findComponent(RegistryList), findEmptySlot: () => wrapper.findComponent(EmptySlotStub), @@ -40,12 +48,20 @@ describe('PackageVersionsList', () => { findDeletePackagesModal: () => wrapper.findComponent(DeleteModal), findPackageListDeleteModal: () => wrapper.findComponent(DeletePackageModal), }; - const mountComponent = (props) => { + + const mountComponent = ({ + props = {}, + resolver = jest.fn().mockResolvedValue(packageVersionsQuery()), + } = {}) => { + const requestHandlers = [[getPackageVersionsQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + wrapper = shallowMountExtended(PackageVersionsList, { + apolloProvider, propsData: { - versions: packageList, - pageInfo: {}, - isLoading: false, + packageId: packageVersionsQuery().data.package.id, + isMutationLoading: false, + count: packageVersions().length, ...props, }, stubs: { @@ -62,9 +78,13 @@ describe('PackageVersionsList', () => { }); }; + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + describe('when list is loading', () => { beforeEach(() => { - mountComponent({ isLoading: true, versions: [] }); + mountComponent({ props: { isMutationLoading: true } }); }); it('displays loader', () => { expect(uiElements.findLoader().exists()).toBe(true); @@ -81,11 +101,24 @@ describe('PackageVersionsList', () => { it('does not display registry list', () => { expect(uiElements.findRegistryList().exists()).toBe(false); }); + + it('does not display alert', () => { + expect(uiElements.findAlert().exists()).toBe(false); + }); }); describe('when list is loaded and has no data', () => { - beforeEach(() => { - mountComponent({ isLoading: false, versions: [] }); + const resolver = jest.fn().mockResolvedValue(emptyPackageVersionsQuery); + beforeEach(async () => { + mountComponent({ + props: { isMutationLoading: false, count: 0 }, + resolver, + }); + await waitForPromises(); + }); + + it('skips graphql query', () => { + expect(resolver).not.toHaveBeenCalled(); }); it('displays empty slot message', () => { @@ -103,11 +136,44 @@ describe('PackageVersionsList', () => { it('does not display registry list', () => { expect(uiElements.findRegistryList().exists()).toBe(false); }); + + it('does not display alert', () => { + expect(uiElements.findAlert().exists()).toBe(false); + }); + }); + + describe('if load fails, alert', () => { + beforeEach(async () => { + mountComponent({ resolver: jest.fn().mockRejectedValue() }); + + await waitForPromises(); + }); + + it('is displayed', () => { + expect(uiElements.findAlert().exists()).toBe(true); + }); + + it('shows error message', () => { + expect(uiElements.findAlert().text()).toMatchInterpolatedText('Failed to load version data'); + }); + + it('is not dismissible', () => { + expect(uiElements.findAlert().props('dismissible')).toBe(false); + }); + + it('is of variant danger', () => { + expect(uiElements.findAlert().attributes('variant')).toBe('danger'); + }); + + it('error is logged in sentry', () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }); }); describe('when list is loaded with data', () => { - beforeEach(() => { + beforeEach(async () => { mountComponent(); + await waitForPromises(); }); it('displays package registry list', () => { @@ -116,7 +182,7 @@ describe('PackageVersionsList', () => { it('binds the right props', () => { expect(uiElements.findRegistryList().props()).toMatchObject({ - items: packageList, + items: packageVersions(), pagination: {}, isLoading: false, hiddenDelete: true, @@ -125,16 +191,16 @@ describe('PackageVersionsList', () => { it('displays package version rows', () => { expect(uiElements.findAllListRow().exists()).toEqual(true); - expect(uiElements.findAllListRow()).toHaveLength(packageList.length); + expect(uiElements.findAllListRow()).toHaveLength(packageVersions().length); }); it('binds the correct props', () => { expect(uiElements.findAllListRow().at(0).props()).toMatchObject({ - packageEntity: expect.objectContaining(packageList[0]), + packageEntity: expect.objectContaining(packageVersions()[0]), }); expect(uiElements.findAllListRow().at(1).props()).toMatchObject({ - packageEntity: expect.objectContaining(packageList[1]), + packageEntity: expect.objectContaining(packageVersions()[1]), }); }); @@ -148,40 +214,52 @@ describe('PackageVersionsList', () => { }); describe('when user interacts with pagination', () => { - beforeEach(() => { - mountComponent({ pageInfo: { hasNextPage: true } }); + const resolver = jest.fn().mockResolvedValue(packageVersionsQuery()); + + beforeEach(async () => { + mountComponent({ resolver }); + await waitForPromises(); }); - it('emits prev-page event when registry list emits prev event', () => { - uiElements.findRegistryList().vm.$emit('prev-page'); + it('when list emits next-page fetches the next set of records', async () => { + uiElements.findRegistryList().vm.$emit('next-page'); + await waitForPromises(); - expect(wrapper.emitted('prev-page')).toHaveLength(1); + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ after: pagination().endCursor, first: GRAPHQL_PAGE_SIZE }), + ); }); - it('emits next-page when registry list emits next event', () => { - uiElements.findRegistryList().vm.$emit('next-page'); + it('when list emits prev-page fetches the prev set of records', async () => { + uiElements.findRegistryList().vm.$emit('prev-page'); + await waitForPromises(); - expect(wrapper.emitted('next-page')).toHaveLength(1); + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ before: pagination().startCursor, last: GRAPHQL_PAGE_SIZE }), + ); }); }); describe.each` description | finderFunction | deletePayload - ${'when the user can destroy the package'} | ${uiElements.findListRow} | ${packageList[0]} - ${'when the user can bulk destroy packages and deletes only one package'} | ${uiElements.findRegistryList} | ${[packageList[0]]} + ${'when the user can destroy the package'} | ${uiElements.findListRow} | ${packageVersions()[0]} + ${'when the user can bulk destroy packages and deletes only one package'} | ${uiElements.findRegistryList} | ${[packageVersions()[0]]} `('$description', ({ finderFunction, deletePayload }) => { let eventSpy; const category = 'UI::NpmPackages'; const { findPackageListDeleteModal } = uiElements; - beforeEach(() => { + beforeEach(async () => { eventSpy = jest.spyOn(Tracking, 'event'); - mountComponent({ canDestroy: true }); + mountComponent({ props: { canDestroy: true } }); + await waitForPromises(); finderFunction().vm.$emit('delete', deletePayload); }); it('passes itemToBeDeleted to the modal', () => { - expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(packageList[0]); + expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual( + packageVersions()[0], + ); }); it('requesting delete tracks the right action', () => { @@ -198,7 +276,7 @@ describe('PackageVersionsList', () => { }); it('emits delete when modal confirms', () => { - expect(wrapper.emitted('delete')[0][0]).toEqual([packageList[0]]); + expect(wrapper.emitted('delete')[0][0]).toEqual([packageVersions()[0]]); }); it('tracks the right action', () => { @@ -231,14 +309,15 @@ describe('PackageVersionsList', () => { let eventSpy; const { findDeletePackagesModal, findRegistryList } = uiElements; - beforeEach(() => { + beforeEach(async () => { eventSpy = jest.spyOn(Tracking, 'event'); - mountComponent({ canDestroy: true }); + mountComponent({ props: { canDestroy: true } }); + await waitForPromises(); }); it('binds the right props', () => { expect(uiElements.findRegistryList().props()).toMatchObject({ - items: packageList, + items: packageVersions(), pagination: {}, isLoading: false, hiddenDelete: false, @@ -248,11 +327,13 @@ describe('PackageVersionsList', () => { describe('upon deletion', () => { beforeEach(() => { - findRegistryList().vm.$emit('delete', packageList); + findRegistryList().vm.$emit('delete', packageVersions()); }); it('passes itemsToBeDeleted to the modal', () => { - expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual(packageList); + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual( + packageVersions(), + ); expect(wrapper.emitted('delete')).toBeUndefined(); }); @@ -270,7 +351,7 @@ describe('PackageVersionsList', () => { }); it('emits delete event', () => { - expect(wrapper.emitted('delete')[0]).toEqual([packageList]); + expect(wrapper.emitted('delete')[0]).toEqual([packageVersions()]); }); it('tracks the right action', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index 91417d2fc9f..52d222ed07b 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -132,7 +132,7 @@ describe('packages_list_row', () => { }); }); - it('emits the delete event when the delete button is clicked', async () => { + it('emits the delete event when the delete button is clicked', () => { mountComponent({ packageEntity: packageWithoutTags }); findDeleteDropdown().vm.$emit('click'); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js index ae990f3ea00..483b7a9383d 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js @@ -4,7 +4,6 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; -import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue'; import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import { @@ -17,7 +16,7 @@ import { } from '~/packages_and_registries/package_registry/constants'; import PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; import Tracking from '~/tracking'; -import { packageData } from '../../mock_data'; +import { defaultPackageGroupSettings, packageData } from '../../mock_data'; describe('packages_list', () => { let wrapper; @@ -39,18 +38,20 @@ describe('packages_list', () => { list: [firstPackage, secondPackage], isLoading: false, pageInfo: {}, + groupSettings: defaultPackageGroupSettings, }; const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; const findPackagesListLoader = () => wrapper.findComponent(PackagesListLoader); - const findPackageListDeleteModal = () => wrapper.findComponent(DeletePackageModal); const findEmptySlot = () => wrapper.findComponent(EmptySlotStub); const findRegistryList = () => wrapper.findComponent(RegistryList); const findPackagesListRow = () => wrapper.findComponent(PackagesListRow); const findErrorPackageAlert = () => wrapper.findComponent(GlAlert); const findDeletePackagesModal = () => wrapper.findComponent(DeleteModal); + const showMock = jest.fn(); + const mountComponent = (props) => { wrapper = shallowMountExtended(PackagesList, { propsData: { @@ -58,10 +59,9 @@ describe('packages_list', () => { ...props, }, stubs: { - DeletePackageModal, DeleteModal: stubComponent(DeleteModal, { methods: { - show: jest.fn(), + show: showMock, }, }), GlSprintf, @@ -119,15 +119,20 @@ describe('packages_list', () => { }); describe('layout', () => { - it("doesn't contain a visible modal component", () => { + beforeEach(() => { mountComponent(); + }); - expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull(); + it('modal component is not shown', () => { + expect(showMock).not.toHaveBeenCalled(); }); - it('does not have an error alert displayed', () => { - mountComponent(); + it('modal component props is empty', () => { + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual([]); + expect(findDeletePackagesModal().props('showRequestForwardingContent')).toBe(false); + }); + it('does not have an error alert displayed', () => { expect(findErrorPackageAlert().exists()).toBe(false); }); }); @@ -146,8 +151,8 @@ describe('packages_list', () => { finderFunction().vm.$emit('delete', deletePayload); }); - it('passes itemToBeDeleted to the modal', () => { - expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(firstPackage); + it('passes itemsToBeDeleted to the modal', () => { + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual([firstPackage]); }); it('requesting delete tracks the right action', () => { @@ -158,9 +163,13 @@ describe('packages_list', () => { ); }); + it('modal component is shown', () => { + expect(showMock).toHaveBeenCalledTimes(1); + }); + describe('when modal confirms', () => { beforeEach(() => { - findPackageListDeleteModal().vm.$emit('ok'); + findDeletePackagesModal().vm.$emit('confirm'); }); it('emits delete when modal confirms', () => { @@ -176,14 +185,14 @@ describe('packages_list', () => { }); }); - it.each(['ok', 'cancel'])('resets itemToBeDeleted when modal emits %s', async (event) => { - await findPackageListDeleteModal().vm.$emit(event); + it.each(['confirm', 'cancel'])('resets itemsToBeDeleted when modal emits %s', async (event) => { + await findDeletePackagesModal().vm.$emit(event); - expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull(); + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual([]); }); it('canceling delete tracks the right action', () => { - findPackageListDeleteModal().vm.$emit('cancel'); + findDeletePackagesModal().vm.$emit('cancel'); expect(eventSpy).toHaveBeenCalledWith( category, @@ -237,7 +246,7 @@ describe('packages_list', () => { it.each(['confirm', 'cancel'])('resets itemsToBeDeleted when modal emits %s', async (event) => { await findDeletePackagesModal().vm.$emit(event); - expect(findDeletePackagesModal().props('itemsToBeDeleted')).toHaveLength(0); + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual([]); }); it('canceling delete tracks the right action', () => { @@ -258,7 +267,7 @@ describe('packages_list', () => { return nextTick(); }); - it('should display an alert message', () => { + it('should display an alert', () => { expect(findErrorPackageAlert().exists()).toBe(true); expect(findErrorPackageAlert().props('title')).toBe( 'There was an error publishing a error package package', @@ -273,7 +282,9 @@ describe('packages_list', () => { await nextTick(); - expect(findPackageListDeleteModal().text()).toContain(errorPackage.name); + expect(showMock).toHaveBeenCalledTimes(1); + + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual([errorPackage]); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index 1250ecaf61f..82fa5b76367 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -54,7 +54,7 @@ describe('Package Search', () => { expect(findRegistrySearch().exists()).toBe(true); }); - it('registry search is mounted after mount', async () => { + it('registry search is mounted after mount', () => { mountComponent(); expect(findRegistrySearch().exists()).toBe(false); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 19c098e1f82..9054e4998bb 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -103,12 +103,20 @@ export const linksData = { }, }; +export const defaultPackageGroupSettings = { + mavenPackageRequestsForwarding: true, + npmPackageRequestsForwarding: true, + pypiPackageRequestsForwarding: true, + __typename: 'PackageSettings', +}; + export const packageVersions = () => [ { createdAt: '2021-08-10T09:33:54Z', id: 'gid://gitlab/Packages::Package/243', name: '@gitlab-org/package-15', status: 'DEFAULT', + packageType: 'NPM', canDestroy: true, tags: { nodes: packageTags() }, version: '1.0.1', @@ -120,6 +128,7 @@ export const packageVersions = () => [ id: 'gid://gitlab/Packages::Package/244', name: '@gitlab-org/package-15', status: 'DEFAULT', + packageType: 'NPM', canDestroy: true, tags: { nodes: packageTags() }, version: '1.0.2', @@ -130,7 +139,7 @@ export const packageVersions = () => [ export const packageData = (extend) => ({ __typename: 'Package', - id: 'gid://gitlab/Packages::Package/111', + id: 'gid://gitlab/Packages::Package/1', canDestroy: true, name: '@gitlab-org/package-15', packageType: 'NPM', @@ -244,14 +253,6 @@ export const packageDetailsQuery = (extendPackage) => ({ }, versions: { count: packageVersions().length, - nodes: packageVersions(), - pageInfo: { - hasNextPage: true, - hasPreviousPage: false, - endCursor: 'endCursor', - startCursor: 'startCursor', - }, - __typename: 'PackageConnection', }, dependencyLinks: { nodes: dependencyLinks(), @@ -298,6 +299,41 @@ export const packageMetadataQuery = (packageType) => { }; }; +export const packageVersionsQuery = (versions = packageVersions()) => ({ + data: { + package: { + id: 'gid://gitlab/Packages::Package/111', + versions: { + count: versions.length, + nodes: versions, + pageInfo: pagination(), + __typename: 'PackageConnection', + }, + __typename: 'PackageDetailsType', + }, + }, +}); + +export const emptyPackageVersionsQuery = { + data: { + package: { + id: 'gid://gitlab/Packages::Package/111', + versions: { + count: 0, + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + endCursor: 'endCursor', + startCursor: 'startCursor', + }, + __typename: 'PackageConnection', + }, + __typename: 'PackageDetailsType', + }, + }, +}; + export const packagesDestroyMutation = () => ({ data: { destroyPackages: { @@ -352,7 +388,12 @@ export const packageDestroyFilesMutationError = () => ({ ], }); -export const packagesListQuery = ({ type = 'group', extend = {}, extendPagination = {} } = {}) => ({ +export const packagesListQuery = ({ + type = 'group', + extend = {}, + extendPagination = {}, + packageSettings = defaultPackageGroupSettings, +} = {}) => ({ data: { [type]: { id: '1', @@ -379,6 +420,14 @@ export const packagesListQuery = ({ type = 'group', extend = {}, extendPaginatio pageInfo: pagination(extendPagination), __typename: 'PackageConnection', }, + ...(type === 'group' && { packageSettings }), + ...(type === 'project' && { + group: { + id: '1', + packageSettings, + __typename: 'Group', + }, + }), ...extend, __typename: capitalize(type), }, diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index 49f69a46395..e1765917035 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -1,4 +1,4 @@ -import { GlEmptyState, GlTabs, GlTab, GlSprintf } from '@gitlab/ui'; +import { GlEmptyState, GlModal, GlTabs, GlTab, GlSprintf } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -7,7 +7,7 @@ import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; - +import { stubComponent } from 'helpers/stub_component'; import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import PackagesApp from '~/packages_and_registries/package_registry/pages/details.vue'; import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; @@ -33,6 +33,7 @@ import { import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql'; import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; +import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql//queries/get_package_versions.query.graphql'; import { packageDetailsQuery, packageData, @@ -42,12 +43,13 @@ import { packageFiles, packageDestroyFilesMutation, packageDestroyFilesMutationError, - pagination, } from '../mock_data'; jest.mock('~/alert'); useMockLocationHelper(); +Vue.use(VueApollo); + describe('PackagesApp', () => { let wrapper; let apolloProvider; @@ -57,7 +59,7 @@ describe('PackagesApp', () => { }; const provide = { - packageId: '111', + packageId: '1', emptyListIllustration: 'svgPath', projectListUrl: 'projectListUrl', groupListUrl: 'groupListUrl', @@ -66,14 +68,13 @@ describe('PackagesApp', () => { }; const { __typename, ...packageWithoutTypename } = packageData(); + const showMock = jest.fn(); function createComponent({ resolver = jest.fn().mockResolvedValue(packageDetailsQuery()), filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()), routeId = '1', } = {}) { - Vue.use(VueApollo); - const requestHandlers = [ [getPackageDetails, resolver], [destroyPackageFilesMutation, filesDeleteMutationResolver], @@ -86,17 +87,11 @@ describe('PackagesApp', () => { stubs: { PackageTitle, DeletePackages, - GlModal: { - template: ` - <div> - <slot name="modal-title"></slot> - <p><slot></slot></p> - </div> - `, + GlModal: stubComponent(GlModal, { methods: { - show: jest.fn(), + show: showMock, }, - }, + }), GlSprintf, GlTabs, GlTab, @@ -251,7 +246,7 @@ describe('PackagesApp', () => { await findDeleteButton().trigger('click'); - expect(findDeleteModal().find('p').text()).toBe( + expect(findDeleteModal().text()).toBe( 'You are about to delete version 1.0.0 of @gitlab-org/package-15. Are you sure?', ); }); @@ -318,7 +313,7 @@ describe('PackagesApp', () => { describe('deleting a file', () => { const [fileToDelete] = packageFiles(); - const doDeleteFile = async () => { + const doDeleteFile = () => { findPackageFiles().vm.$emit('delete-files', [fileToDelete]); findDeleteFileModal().vm.$emit('primary'); @@ -331,13 +326,15 @@ describe('PackagesApp', () => { await waitForPromises(); - const showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show'); - const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show'); - findPackageFiles().vm.$emit('delete-files', [fileToDelete]); - expect(showDeletePackageSpy).not.toHaveBeenCalled(); - expect(showDeleteFileSpy).toHaveBeenCalled(); + expect(showMock).toHaveBeenCalledTimes(1); + + await waitForPromises(); + + expect(findDeleteFileModal().text()).toBe( + 'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?', + ); }); it('when its the only file opens delete package confirmation modal', async () => { @@ -360,17 +357,13 @@ describe('PackagesApp', () => { await waitForPromises(); - const showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show'); - const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show'); - findPackageFiles().vm.$emit('delete-files', [fileToDelete]); - expect(showDeletePackageSpy).toHaveBeenCalled(); - expect(showDeleteFileSpy).not.toHaveBeenCalled(); + expect(showMock).toHaveBeenCalledTimes(1); await waitForPromises(); - expect(findDeleteModal().find('p').text()).toBe( + expect(findDeleteModal().text()).toBe( 'Deleting the last package asset will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?', ); }); @@ -440,7 +433,7 @@ describe('PackagesApp', () => { }); describe('deleting multiple files', () => { - const doDeleteFiles = async () => { + const doDeleteFiles = () => { findPackageFiles().vm.$emit('delete-files', packageFiles()); findDeleteFilesModal().vm.$emit('primary'); @@ -482,6 +475,8 @@ describe('PackagesApp', () => { await doDeleteFiles(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE, @@ -542,15 +537,13 @@ describe('PackagesApp', () => { await waitForPromises(); - const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show'); - findPackageFiles().vm.$emit('delete-files', packageFiles()); - expect(showDeletePackageSpy).toHaveBeenCalled(); + expect(showMock).toHaveBeenCalledTimes(1); await waitForPromises(); - expect(findDeleteModal().find('p').text()).toBe( + expect(findDeleteModal().text()).toBe( 'Deleting all package assets will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?', ); }); @@ -574,8 +567,6 @@ describe('PackagesApp', () => { packageDetailsQuery({ versions: { count: 0, - nodes: [], - pageInfo: pagination({ hasNextPage: false, hasPreviousPage: false }), }, }), ), @@ -591,61 +582,61 @@ describe('PackagesApp', () => { }); it('binds the correct props', async () => { - const versionNodes = packageVersions(); createComponent(); await waitForPromises(); expect(findVersionsList().props()).toMatchObject({ canDestroy: true, - versions: expect.arrayContaining(versionNodes), + count: packageVersions().length, + isMutationLoading: false, + packageId: 'gid://gitlab/Packages::Package/1', }); }); describe('delete packages', () => { - it('exists and has the correct props', async () => { + beforeEach(async () => { createComponent(); - await waitForPromises(); - - expect(findDeletePackages().props()).toMatchObject({ - refetchQueries: [{ query: getPackageDetails, variables: {} }], - showSuccessAlert: true, - }); }); - it('deletePackages is bound to package-versions-list delete event', async () => { - createComponent(); - - await waitForPromises(); + it('exists and has the correct props', () => { + expect(findDeletePackages().props('showSuccessAlert')).toBe(true); + expect(findDeletePackages().props('refetchQueries')).toEqual([ + { + query: getPackageVersionsQuery, + variables: { + first: 20, + id: 'gid://gitlab/Packages::Package/1', + }, + }, + ]); + }); + it('deletePackages is bound to package-versions-list delete event', () => { findVersionsList().vm.$emit('delete', [{ id: 1 }]); expect(findDeletePackages().emitted('start')).toEqual([[]]); }); it('start and end event set loading correctly', async () => { - createComponent(); - - await waitForPromises(); - findDeletePackages().vm.$emit('start'); await nextTick(); - expect(findVersionsList().props('isLoading')).toBe(true); + expect(findVersionsList().props('isMutationLoading')).toBe(true); findDeletePackages().vm.$emit('end'); await nextTick(); - expect(findVersionsList().props('isLoading')).toBe(false); + expect(findVersionsList().props('isMutationLoading')).toBe(false); }); }); }); describe('dependency links', () => { - it('does not show the dependency links for a non nuget package', async () => { + it('does not show the dependency links for a non nuget package', () => { createComponent(); expect(findDependenciesCountBadge().exists()).toBe(false); diff --git a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js index 60bb055b1db..2ee24200ed3 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js @@ -1,9 +1,11 @@ -import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { s__ } from '~/locale'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import ListPage from '~/packages_and_registries/package_registry/pages/list.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; @@ -30,6 +32,7 @@ describe('PackagesListApp', () => { emptyListIllustration: 'emptyListIllustration', isGroupPage: true, fullPath: 'gitlab-org', + settingsPath: 'settings-path', }; const PackageList = { @@ -49,6 +52,7 @@ describe('PackagesListApp', () => { const findListComponent = () => wrapper.findComponent(PackageList); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findDeletePackages = () => wrapper.findComponent(DeletePackages); + const findSettingsLink = () => wrapper.findComponent(GlButton); const mountComponent = ({ resolver = jest.fn().mockResolvedValue(packagesListQuery()), @@ -71,13 +75,17 @@ describe('PackagesListApp', () => { GlLoadingIcon, GlSprintf, GlLink, + PackageTitle, PackageList, DeletePackages, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; - const waitForFirstRequest = async () => { + const waitForFirstRequest = () => { // emit a search update so the query is executed findSearch().vm.$emit('update', { sort: 'NAME_DESC', filters: [] }); return waitForPromises(); @@ -103,6 +111,52 @@ describe('PackagesListApp', () => { }); }); + describe('link to settings', () => { + describe('when settings path is not provided', () => { + beforeEach(() => { + mountComponent({ + provide: { + ...defaultProvide, + settingsPath: '', + }, + }); + }); + + it('is not rendered', () => { + expect(findSettingsLink().exists()).toBe(false); + }); + }); + + describe('when settings path is provided', () => { + const label = s__('PackageRegistry|Configure in settings'); + + beforeEach(() => { + mountComponent(); + }); + + it('is rendered', () => { + expect(findSettingsLink().exists()).toBe(true); + }); + + it('has the right icon', () => { + expect(findSettingsLink().props('icon')).toBe('settings'); + }); + + it('has the right attributes', () => { + expect(findSettingsLink().attributes()).toMatchObject({ + 'aria-label': label, + href: defaultProvide.settingsPath, + }); + }); + + it('sets tooltip with right label', () => { + const tooltip = getBinding(findSettingsLink().element, 'gl-tooltip'); + + expect(tooltip.value).toBe(label); + }); + }); + }); + describe('search component', () => { it('exists', () => { mountComponent(); @@ -141,6 +195,11 @@ describe('PackagesListApp', () => { list: expect.arrayContaining([expect.objectContaining({ id: packageData().id })]), isLoading: false, pageInfo: expect.objectContaining({ endCursor: pagination().endCursor }), + groupSettings: expect.objectContaining({ + mavenPackageRequestsForwarding: true, + npmPackageRequestsForwarding: true, + pypiPackageRequestsForwarding: true, + }), }); }); @@ -191,6 +250,16 @@ describe('PackagesListApp', () => { expect.objectContaining({ isGroupPage, [sortType]: 'NAME_DESC' }), ); }); + + it('list component has group settings prop set', () => { + expect(findListComponent().props()).toMatchObject({ + groupSettings: expect.objectContaining({ + mavenPackageRequestsForwarding: true, + npmPackageRequestsForwarding: true, + pypiPackageRequestsForwarding: true, + }), + }); + }); }); describe.each` diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js index 22e42f8c0ab..49e76cfbae0 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js @@ -177,7 +177,7 @@ describe('Packages Settings', () => { }); }); - it('renders ExceptionsInput and assigns duplication allowness and exception props', async () => { + it('renders ExceptionsInput and assigns duplication allowness and exception props', () => { mountComponent({ mountFn: mountExtended }); const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings; @@ -192,7 +192,7 @@ describe('Packages Settings', () => { }); }); - it('on update event calls the mutation', async () => { + it('on update event calls the mutation', () => { const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); mountComponent({ mountFn: mountExtended, mutationResolver }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js index d57077b31c8..8a66a685733 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js @@ -1,12 +1,13 @@ import Vue from 'vue'; -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlLink, GlSprintf } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { s__ } from '~/locale'; import component from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue'; import { - PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, + REQUEST_FORWARDING_HELP_PAGE_PATH, PACKAGE_FORWARDING_SETTINGS_HEADER, } from '~/packages_and_registries/settings/group/constants'; @@ -60,6 +61,7 @@ describe('Packages Forwarding Settings', () => { forwardSettings, }, stubs: { + GlSprintf, SettingsBlock, }, }); @@ -72,6 +74,7 @@ describe('Packages Forwarding Settings', () => { const findMavenForwardingSettings = () => wrapper.findByTestId('maven'); const findNpmForwardingSettings = () => wrapper.findByTestId('npm'); const findPyPiForwardingSettings = () => wrapper.findByTestId('pypi'); + const findRequestForwardingDocsLink = () => wrapper.findComponent(GlLink); const fillApolloCache = () => { apolloProvider.defaultClient.cache.writeQuery({ @@ -111,8 +114,18 @@ describe('Packages Forwarding Settings', () => { it('has the correct description text', () => { mountComponent(); - expect(findDescription().text()).toMatchInterpolatedText( - PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, + expect(findDescription().text()).toBe( + s__( + 'PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry.', + ), + ); + }); + + it('has the right help link', () => { + mountComponent(); + + expect(findRequestForwardingDocsLink().attributes('href')).toBe( + REQUEST_FORWARDING_HELP_PAGE_PATH, ); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js index 49e8601da88..cbe68df5343 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/cleanup_image_tags_spec.js @@ -126,7 +126,7 @@ describe('Cleanup image tags project settings', () => { }); describe('an admin is visiting the page', () => { - it('shows the admin part of the alert message', async () => { + it('shows the admin part of the alert', async () => { mountComponentWithApollo({ provide: { ...defaultProvidedValues, isAdmin: true }, resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()), diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js index 57b48407174..a68087f7f57 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js @@ -46,7 +46,7 @@ describe('Container Expiration Policy Settings Form', () => { const findOlderThanDropdown = () => wrapper.find('[data-testid="older-than-dropdown"]'); const findRemoveRegexInput = () => wrapper.find('[data-testid="remove-regex-input"]'); - const submitForm = async () => { + const submitForm = () => { findForm().trigger('submit'); return waitForPromises(); }; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js index 19f25d0aef7..c9dd9ce7a45 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js @@ -109,7 +109,7 @@ describe('Container expiration policy project settings', () => { }); describe('an admin is visiting the page', () => { - it('shows the admin part of the alert message', async () => { + it('shows the admin part of the alert', async () => { mountComponentWithApollo({ provide: { ...defaultProvidedValues, isAdmin: true }, resolver: jest.fn().mockResolvedValue(nullExpirationPolicyPayload()), diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js index b9c0c38bf9e..50b72d3ad72 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js @@ -48,7 +48,7 @@ describe('Packages Cleanup Policy Settings Form', () => { wrapper.findByTestId('keep-n-duplicated-package-files-dropdown'); const findNextRunAt = () => wrapper.findByTestId('next-run-at'); - const submitForm = async () => { + const submitForm = () => { findForm().trigger('submit'); return waitForPromises(); }; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index 54655acdf2a..12425909454 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -79,7 +79,7 @@ describe('Registry Settings app', () => { ${false} | ${true} ${false} | ${false} `( - 'container expiration policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings', + 'container cleanup policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings', ({ showContainerRegistrySettings, showPackageRegistrySettings }) => { mountComponent({ showContainerRegistrySettings, diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap index e6e89806ce0..e9ee6ebdb5c 100644 --- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -5,7 +5,6 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` aria-label="Breadcrumb" class="gl-breadcrumbs" > - <ol class="breadcrumb gl-breadcrumb-list" > @@ -16,29 +15,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` class="" target="_self" > + <!----> <span> </span> - - <span - class="gl-breadcrumb-separator" - data-testid="separator" - > - <span - class="gl-mx-n5" - > - <svg - aria-hidden="true" - class="gl-icon s8" - data-testid="chevron-lg-right-icon" - role="img" - > - <use - href="#chevron-lg-right" - /> - </svg> - </span> - </span> </a> </li> @@ -52,11 +32,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` href="#" target="_self" > + <!----> <span> </span> - - <!----> </a> </li> @@ -70,7 +49,6 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` aria-label="Breadcrumb" class="gl-breadcrumbs" > - <ol class="breadcrumb gl-breadcrumb-list" > @@ -82,11 +60,10 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` class="" target="_self" > + <!----> <span> </span> - - <!----> </a> </li> diff --git a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js index 1484377a475..c1e86080d29 100644 --- a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js @@ -51,7 +51,7 @@ describe('Persisted Search', () => { expect(findRegistrySearch().exists()).toBe(true); }); - it('registry search is mounted after mount', async () => { + it('registry search is mounted after mount', () => { mountComponent(); expect(findRegistrySearch().exists()).toBe(false); diff --git a/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js index a4e0d267023..85b4ca95d5d 100644 --- a/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js @@ -116,7 +116,7 @@ describe('Registry List', () => { expect(findDeleteSelected().exists()).toBe(false); }); - it('populates the first slot prop correctly', async () => { + it('populates the first slot prop correctly', () => { expect(findScopedSlots().at(0).exists()).toBe(true); // it's the first slot diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js index 6edfe9641b9..6cf30e84288 100644 --- a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js +++ b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js @@ -1,9 +1,9 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlAbuseReportsList from 'test_fixtures/abuse_reports/abuse_reports_list.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import AbuseReports from '~/pages/admin/abuse_reports/abuse_reports'; describe('Abuse Reports', () => { - const FIXTURE = 'abuse_reports/abuse_reports_list.html'; const MAX_MESSAGE_LENGTH = 500; let $messages; @@ -15,7 +15,7 @@ describe('Abuse Reports', () => { $messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first(); beforeEach(() => { - loadHTMLFixture(FIXTURE); + setHTMLFixture(htmlAbuseReportsList); new AbuseReports(); // eslint-disable-line no-new $messages = $('.abuse-reports .message'); }); diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js index d422f5dade3..176ec36fffc 100644 --- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js +++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js @@ -1,14 +1,13 @@ -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlApplicationSettingsAccountsAndLimit from 'test_fixtures/application_settings/accounts_and_limit.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initAccountAndLimitsSection, { PLACEHOLDER_USER_EXTERNAL_DEFAULT_FALSE, PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE, } from '~/pages/admin/application_settings/account_and_limits'; describe('AccountAndLimits', () => { - const FIXTURE = 'application_settings/accounts_and_limit.html'; - beforeEach(() => { - loadHTMLFixture(FIXTURE); + setHTMLFixture(htmlApplicationSettingsAccountsAndLimit); initAccountAndLimitsSection(); }); diff --git a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js index 3c512cfd6ae..72d2bb0f983 100644 --- a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js +++ b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js @@ -1,18 +1,18 @@ +import htmlApplicationSettingsUsage from 'test_fixtures/application_settings/usage.html'; import initSetHelperText, { HELPER_TEXT_SERVICE_PING_DISABLED, HELPER_TEXT_SERVICE_PING_ENABLED, } from '~/pages/admin/application_settings/metrics_and_profiling/usage_statistics'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('UsageStatistics', () => { - const FIXTURE = 'application_settings/usage.html'; let servicePingCheckBox; let servicePingFeaturesCheckBox; let servicePingFeaturesLabel; let servicePingFeaturesHelperText; beforeEach(() => { - loadHTMLFixture(FIXTURE); + setHTMLFixture(htmlApplicationSettingsUsage); initSetHelperText(); servicePingCheckBox = document.getElementById('application_setting_usage_ping_enabled'); servicePingFeaturesCheckBox = document.getElementById( diff --git a/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js index 366d148a608..afd7ee09ae8 100644 --- a/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_modal_spec.js +++ b/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js @@ -4,7 +4,7 @@ import { GlModal } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import CancelJobsModal from '~/pages/admin/jobs/index/components/cancel_jobs_modal.vue'; +import CancelJobsModal from '~/pages/admin/jobs/components/cancel_jobs_modal.vue'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), diff --git a/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_spec.js b/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js index de6d44eabdc..d94de48f238 100644 --- a/spec/frontend/pages/admin/jobs/index/components/cancel_jobs_spec.js +++ b/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js @@ -2,12 +2,12 @@ import { GlButton } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { TEST_HOST } from 'helpers/test_constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import CancelJobs from '~/pages/admin/jobs/index/components/cancel_jobs.vue'; -import CancelJobsModal from '~/pages/admin/jobs/index/components/cancel_jobs_modal.vue'; +import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue'; +import CancelJobsModal from '~/pages/admin/jobs/components/cancel_jobs_modal.vue'; import { CANCEL_JOBS_MODAL_ID, CANCEL_BUTTON_TOOLTIP, -} from '~/pages/admin/jobs/index/components/constants'; +} from '~/pages/admin/jobs/components/constants'; describe('CancelJobs component', () => { let wrapper; diff --git a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js new file mode 100644 index 00000000000..8dabc076980 --- /dev/null +++ b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js @@ -0,0 +1,100 @@ +import { GlSkeletonLoader, GlLoadingIcon, GlEmptyState, GlAlert } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import getJobsQuery from '~/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql'; +import AdminJobsTableApp from '~/pages/admin/jobs/components/table/admin_jobs_table_app.vue'; + +import { + mockAllJobsResponsePaginated, + mockJobsResponseEmpty, + statuses, +} from '../../../../../jobs/mock_data'; + +Vue.use(VueApollo); + +describe('Job table app', () => { + let wrapper; + + const successHandler = jest.fn().mockResolvedValue(mockAllJobsResponsePaginated); + const emptyHandler = jest.fn().mockResolvedValue(mockJobsResponseEmpty); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findTable = () => wrapper.findComponent(JobsTable); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findAlert = () => wrapper.findComponent(GlAlert); + + const createMockApolloProvider = (handler) => { + const requestHandlers = [[getJobsQuery, handler]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = ({ + handler = successHandler, + mountFn = shallowMount, + data = {}, + } = {}) => { + wrapper = mountFn(AdminJobsTableApp, { + data() { + return { + ...data, + }; + }, + provide: { + jobStatuses: statuses, + }, + apolloProvider: createMockApolloProvider(handler), + }); + }; + + describe('loaded state', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('should display the jobs table with data', () => { + expect(findTable().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + expect(findLoadingSpinner().exists()).toBe(false); + }); + }); + + describe('empty state', () => { + it('should display empty state if there are no jobs and tab scope is null', async () => { + createComponent({ handler: emptyHandler, mountFn: mount }); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + }); + + it('should not display empty state if there are jobs and tab scope is not null', async () => { + createComponent({ handler: successHandler, mountFn: mount }); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTable().exists()).toBe(true); + }); + }); + + describe('error state', () => { + it('should show an alert if there is an error fetching the jobs data', async () => { + createComponent({ handler: failedHandler }); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was an error fetching the jobs.'); + expect(findTable().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js new file mode 100644 index 00000000000..59e9eda6343 --- /dev/null +++ b/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js @@ -0,0 +1,106 @@ +import cacheConfig from '~/pages/admin/jobs/components/table/graphql/cache_config'; +import { + CIJobConnectionExistingCache, + CIJobConnectionIncomingCache, + CIJobConnectionIncomingCacheRunningStatus, +} from '../../../../../../jobs/mock_data'; + +const firstLoadArgs = { first: 3, statuses: 'PENDING' }; +const runningArgs = { first: 3, statuses: 'RUNNING' }; + +describe('jobs/components/table/graphql/cache_config', () => { + describe('when fetching data with the same statuses', () => { + it('should contain cache nodes and a status when merging caches on first load', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { + args: firstLoadArgs, + }); + + expect(res.nodes).toHaveLength(CIJobConnectionIncomingCache.nodes.length); + expect(res.statuses).toBe('PENDING'); + }); + + it('should add to existing caches when merging caches after first load', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + CIJobConnectionIncomingCache, + { + args: firstLoadArgs, + }, + ); + + expect(res.nodes).toHaveLength( + CIJobConnectionIncomingCache.nodes.length + CIJobConnectionExistingCache.nodes.length, + ); + }); + + it('should not add to existing cache if the incoming elements are the same', () => { + // simulate that this is the last page + const finalExistingCache = { + ...CIJobConnectionExistingCache, + pageInfo: { + hasNextPage: false, + }, + }; + + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + finalExistingCache, + { + args: firstLoadArgs, + }, + ); + + expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length); + }); + + it('should contain the pageInfo key as part of the result', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { + args: firstLoadArgs, + }); + + expect(res.pageInfo).toEqual( + expect.objectContaining({ + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjIwNTEifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIxNzMifQ', + }), + ); + }); + }); + + describe('when fetching data with different statuses', () => { + it('should reset cache when a cache already exists', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + CIJobConnectionIncomingCacheRunningStatus, + { + args: runningArgs, + }, + ); + + expect(res.nodes).not.toEqual(CIJobConnectionExistingCache.nodes); + expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length); + }); + }); + + describe('when incoming data has no nodes', () => { + it('should return existing cache', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + { __typename: 'CiJobConnection', count: 500 }, + { + args: { statuses: 'SUCCESS' }, + }, + ); + + const expectedResponse = { + ...CIJobConnectionExistingCache, + statuses: 'SUCCESS', + }; + + expect(res).toEqual(expectedResponse); + }); + }); +}); diff --git a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js index 834d14e0fb3..c00dbc0ec02 100644 --- a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js +++ b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js @@ -45,7 +45,7 @@ describe('NamespaceSelect', () => { expect(findNamespaceInput().exists()).toBe(false); }); - it('sets appropriate props', async () => { + it('sets appropriate props', () => { expect(findListbox().props()).toMatchObject({ items: [ { text: 'user: Administrator', value: '10' }, @@ -84,7 +84,7 @@ describe('NamespaceSelect', () => { expect(findNamespaceInput().attributes('value')).toBe(selectId); }); - it('updates the listbox value', async () => { + it('updates the listbox value', () => { expect(findListbox().props()).toMatchObject({ selected: selectId, toggleText: expectToggleText, diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index 70d7cb9c839..52091d45ada 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlTodos from 'test_fixtures/todos/todos.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import '~/lib/utils/common_utils'; import axios from '~/lib/utils/axios_utils'; @@ -18,7 +19,7 @@ describe('Todos', () => { let mock; beforeEach(() => { - loadHTMLFixture('todos/todos.html'); + setHTMLFixture(htmlTodos); mock = new MockAdapter(axios); return new Todos(); diff --git a/spec/frontend/pages/groups/new/components/app_spec.js b/spec/frontend/pages/groups/new/components/app_spec.js index 7ccded9b0f1..19240f1a044 100644 --- a/spec/frontend/pages/groups/new/components/app_spec.js +++ b/spec/frontend/pages/groups/new/components/app_spec.js @@ -6,7 +6,9 @@ describe('App component', () => { let wrapper; const createComponent = (propsData = {}) => { - wrapper = shallowMount(App, { propsData: { groupsUrl: '/dashboard/groups', ...propsData } }); + wrapper = shallowMount(App, { + propsData: { rootPath: '/', groupsUrl: '/dashboard/groups', ...propsData }, + }); }; const findNewNamespacePage = () => wrapper.findComponent(NewNamespacePage); @@ -20,6 +22,7 @@ describe('App component', () => { createComponent(); expect(findNewNamespacePage().props('initialBreadcrumbs')).toEqual([ + { href: '/', text: 'Your work' }, { href: '/dashboard/groups', text: 'Groups' }, { href: '#', text: 'New group' }, ]); diff --git a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js index b020caa3010..8eab5061e97 100644 --- a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js +++ b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js @@ -39,7 +39,7 @@ describe('BitbucketServerStatusTable', () => { expect(wrapper.findComponent(BitbucketStatusTable).exists()).toBe(true); }); - it('renders Reconfigure button', async () => { + it('renders Reconfigure button', () => { createComponent(BitbucketStatusTableStub); expect(findReconfigureButton().attributes().href).toBe('/reconfigure'); expect(findReconfigureButton().text()).toBe('Reconfigure'); diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js index 477511cde64..8a7fc57c409 100644 --- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -192,7 +192,7 @@ describe('BulkImportsHistoryApp', () => { return axios.waitForAll(); }); - it('renders details button if relevant item has failures', async () => { + it('renders details button if relevant item has failures', () => { expect( extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(), ).toBe(true); diff --git a/spec/frontend/pages/import/history/components/import_history_app_spec.js b/spec/frontend/pages/import/history/components/import_history_app_spec.js index 43cbac25fe8..8e14b5a24f8 100644 --- a/spec/frontend/pages/import/history/components/import_history_app_spec.js +++ b/spec/frontend/pages/import/history/components/import_history_app_spec.js @@ -61,7 +61,6 @@ describe('ImportHistoryApp', () => { beforeEach(() => { gon.api_version = 'v4'; - gon.features = { fullPathProjectSearch: true }; mock = new MockAdapter(axios); }); @@ -167,7 +166,7 @@ describe('ImportHistoryApp', () => { return axios.waitForAll(); }); - it('renders details button if relevant item has failed', async () => { + it('renders details button if relevant item has failed', () => { expect( extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(), ).toBe(true); diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js index af578b69a81..b308d6305da 100644 --- a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js @@ -145,7 +145,7 @@ describe('ProjectNamespace component', () => { await nextTick(); }); - it('creates an alert message and captures the error', () => { + it('creates an alert and captures the error', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while loading data. Please refresh the page to try again.', captureError: true, diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 0812be9745e..d8a848f0a2e 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -760,7 +760,7 @@ describe('Settings Panel', () => { expect(findEnvironmentsSettings().exists()).toBe(true); }); }); - describe('Feature Flags', () => { + describe('Feature flags', () => { it('should show the feature flags toggle', () => { wrapper = mountComponent({}); diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js index 4c4a0fbea11..e554521bfb5 100644 --- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js +++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlSessionsNew from 'test_fixtures/sessions/new.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment'; describe('preserve_url_fragment', () => { @@ -8,7 +9,7 @@ describe('preserve_url_fragment', () => { }; beforeEach(() => { - loadHTMLFixture('sessions/new.html'); + setHTMLFixture(htmlSessionsNew); }); afterEach(() => { diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js index f736ce46f9b..cae2615e849 100644 --- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js +++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js @@ -1,4 +1,5 @@ -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlStaticSigninTabs from 'test_fixtures_static/signin_tabs.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import AccessorUtilities from '~/lib/utils/accessor'; import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer'; @@ -6,7 +7,6 @@ import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer'; useLocalStorageSpy(); describe('SigninTabsMemoizer', () => { - const fixtureTemplate = 'static/signin_tabs.html'; const tabSelector = 'ul.new-session-tabs'; const currentTabKey = 'current_signin_tab'; let memo; @@ -20,7 +20,7 @@ describe('SigninTabsMemoizer', () => { } beforeEach(() => { - loadHTMLFixture(fixtureTemplate); + setHTMLFixture(htmlStaticSigninTabs); jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); }); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 1be4a974f7a..ddaa3df71e8 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -116,6 +116,7 @@ describe('WikiForm', () => { renderMarkdownPath: pageInfoPersisted.markdownPreviewPath, uploadsPath: pageInfoPersisted.uploadsPath, autofocus: pageInfoPersisted.persisted, + markdownDocsPath: pageInfoPersisted.markdownHelpPath, }), ); @@ -123,10 +124,6 @@ describe('WikiForm', () => { id: 'wiki_content', name: 'wiki[content]', }); - - expect(markdownEditor.vm.$attrs['markdown-docs-path']).toEqual( - pageInfoPersisted.markdownHelpPath, - ); }); it.each` @@ -307,7 +304,7 @@ describe('WikiForm', () => { expect(findFormat().element.getAttribute('disabled')).toBeDefined(); }); - it('sends tracking event when editor loads', async () => { + it('sends tracking event when editor loads', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, }); @@ -321,7 +318,7 @@ describe('WikiForm', () => { await triggerFormSubmit(); }); - it('triggers tracking events on form submit', async () => { + it('triggers tracking events on form submit', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, }); diff --git a/spec/frontend/performance_bar/components/performance_bar_app_spec.js b/spec/frontend/performance_bar/components/performance_bar_app_spec.js index 2c9ab4bf78d..7a018236314 100644 --- a/spec/frontend/performance_bar/components/performance_bar_app_spec.js +++ b/spec/frontend/performance_bar/components/performance_bar_app_spec.js @@ -1,18 +1,53 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; import PerformanceBarApp from '~/performance_bar/components/performance_bar_app.vue'; import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store'; describe('performance bar app', () => { + let wrapper; const store = new PerformanceBarStore(); - const wrapper = shallowMount(PerformanceBarApp, { - propsData: { - store, - env: 'development', - requestId: '123', - statsUrl: 'https://log.gprd.gitlab.net/app/dashboards#/view/', - peekUrl: '/-/peek/results', - profileUrl: '?lineprofiler=true', - }, + store.addRequest('123', 'https://gitlab.com', '', {}, 'GET'); + const createComponent = () => { + wrapper = mount(PerformanceBarApp, { + propsData: { + store, + env: 'development', + requestId: '123', + requestMethod: 'GET', + statsUrl: 'https://log.gprd.gitlab.net/app/dashboards#/view/', + peekUrl: '/-/peek/results', + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('flamegraph buttons', () => { + const flamegraphDiv = () => wrapper.find('#peek-flamegraph'); + const flamegraphLinks = () => flamegraphDiv().findAllComponents(GlLink); + + it('creates three flamegraph buttons based on the path', () => { + expect(flamegraphLinks()).toHaveLength(3); + + ['wall', 'cpu', 'object'].forEach((path, index) => { + expect(flamegraphLinks().at(index).attributes('href')).toBe( + `https://gitlab.com?performance_bar=flamegraph&stackprof_mode=${path}`, + ); + }); + }); + }); + + describe('memory report button', () => { + const memoryReportDiv = () => wrapper.find('#peek-memory-report'); + const memoryReportLink = () => memoryReportDiv().findComponent(GlLink); + + it('creates memory report button', () => { + expect(memoryReportLink().attributes('href')).toEqual( + 'https://gitlab.com?performance_bar=memory', + ); + }); }); it('sets the class to match the environment', () => { diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index f09b0cc3df8..1849c373326 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -20,9 +20,9 @@ describe('performance bar wrapper', () => { peekWrapper.setAttribute('id', 'js-peek'); peekWrapper.dataset.env = 'development'; peekWrapper.dataset.requestId = '123'; + peekWrapper.dataset.requestMethod = 'GET'; peekWrapper.dataset.peekUrl = '/-/peek/results'; peekWrapper.dataset.statsUrl = 'https://log.gprd.gitlab.net/app/dashboards#/view/'; - peekWrapper.dataset.profileUrl = '?lineprofiler=true'; mock = new MockAdapter(axios); @@ -70,7 +70,13 @@ describe('performance bar wrapper', () => { it('adds the request immediately', () => { vm.addRequest('123', 'https://gitlab.com/'); - expect(vm.store.addRequest).toHaveBeenCalledWith('123', 'https://gitlab.com/', undefined); + expect(vm.store.addRequest).toHaveBeenCalledWith( + '123', + 'https://gitlab.com/', + undefined, + undefined, + undefined, + ); }); }); diff --git a/spec/frontend/performance_bar/services/performance_bar_service_spec.js b/spec/frontend/performance_bar/services/performance_bar_service_spec.js index 1bb70a43a1b..b1f5f4d6982 100644 --- a/spec/frontend/performance_bar/services/performance_bar_service_spec.js +++ b/spec/frontend/performance_bar/services/performance_bar_service_spec.js @@ -66,7 +66,7 @@ describe('PerformanceBarService', () => { describe('operationName', () => { function requestUrl(response, peekUrl) { - return PerformanceBarService.callbackParams(response, peekUrl)[3]; + return PerformanceBarService.callbackParams(response, peekUrl)[4]; } it('gets the operation name from response.config', () => { diff --git a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js index 7d5c5031792..170469db6ad 100644 --- a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js +++ b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js @@ -46,6 +46,14 @@ describe('PerformanceBarStore', () => { store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation'); expect(findUrl('id')).toBe('graphql (someOperation)'); }); + + it('appends the number of batches queries when it is a GraphQL call', () => { + store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation'); + store.addRequest('id', 'http://localhost:3001/api/graphql', 'anotherOperation'); + store.addRequest('id', 'http://localhost:3001/api/graphql', 'anotherOne'); + store.addRequest('anotherId', 'http://localhost:3001/api/graphql', 'operationName'); + expect(findUrl('id')).toBe('graphql (someOperation) [3 queries batched]'); + }); }); describe('setRequestDetailsData', () => { diff --git a/spec/frontend/pipeline_wizard/components/commit_spec.js b/spec/frontend/pipeline_wizard/components/commit_spec.js index 8f44a6c085b..7095525e948 100644 --- a/spec/frontend/pipeline_wizard/components/commit_spec.js +++ b/spec/frontend/pipeline_wizard/components/commit_spec.js @@ -128,7 +128,7 @@ describe('Pipeline Wizard - Commit Page', () => { await waitForPromises(); }); - it('will not show an error', async () => { + it('will not show an error', () => { expect(wrapper.findByTestId('commit-error').exists()).not.toBe(true); }); @@ -155,7 +155,7 @@ describe('Pipeline Wizard - Commit Page', () => { await waitForPromises(); }); - it('will show an error', async () => { + it('will show an error', () => { expect(wrapper.findByTestId('commit-error').exists()).toBe(true); expect(wrapper.findByTestId('commit-error').text()).toBe(i18n.errors.commitError); }); @@ -236,11 +236,11 @@ describe('Pipeline Wizard - Commit Page', () => { await waitForPromises(); }); - it('sets up without error', async () => { + it('sets up without error', () => { expect(consoleSpy).not.toHaveBeenCalled(); }); - it('does not show a load error', async () => { + it('does not show a load error', () => { expect(wrapper.findByTestId('load-error').exists()).not.toBe(true); }); diff --git a/spec/frontend/pipeline_wizard/components/step_nav_spec.js b/spec/frontend/pipeline_wizard/components/step_nav_spec.js index 8e2f0ab0281..e80eb01ea7a 100644 --- a/spec/frontend/pipeline_wizard/components/step_nav_spec.js +++ b/spec/frontend/pipeline_wizard/components/step_nav_spec.js @@ -25,7 +25,7 @@ describe('Pipeline Wizard - Step Navigation Component', () => { ${'has prev, but not next'} | ${true} | ${false} ${'has next, but not prev'} | ${false} | ${true} ${'has both next and prev'} | ${true} | ${true} - `('$scenario', async ({ showBackButton, showNextButton }) => { + `('$scenario', ({ showBackButton, showNextButton }) => { createComponent({ showBackButton, showNextButton }); expect(prevButton.exists()).toBe(showBackButton); @@ -53,13 +53,13 @@ describe('Pipeline Wizard - Step Navigation Component', () => { expect(wrapper.emitted().next.length).toBe(1); }); - it('enables the next button if nextButtonEnabled ist set to true', async () => { + it('enables the next button if nextButtonEnabled ist set to true', () => { createComponent({ nextButtonEnabled: true }); expect(nextButton.attributes('disabled')).not.toBe('disabled'); }); - it('disables the next button if nextButtonEnabled ist set to false', async () => { + it('disables the next button if nextButtonEnabled ist set to false', () => { createComponent({ nextButtonEnabled: false }); expect(nextButton.attributes('disabled')).toBe('disabled'); diff --git a/spec/frontend/pipeline_wizard/components/step_spec.js b/spec/frontend/pipeline_wizard/components/step_spec.js index 00b57f95ccc..4d5f563228c 100644 --- a/spec/frontend/pipeline_wizard/components/step_spec.js +++ b/spec/frontend/pipeline_wizard/components/step_spec.js @@ -207,7 +207,7 @@ describe('Pipeline Wizard - Step Page', () => { findInputWrappers(); }); - it('injects the template when an input wrapper emits a beforeUpdate:compiled event', async () => { + it('injects the template when an input wrapper emits a beforeUpdate:compiled event', () => { input1.vm.$emit('beforeUpdate:compiled'); expect(wrapper.vm.compiled.toString()).toBe(compiledYamlAfterInitialLoad); diff --git a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js index b0eb7279a94..df8841e6ad3 100644 --- a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js +++ b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js @@ -51,7 +51,7 @@ describe('Pipeline Wizard - List Widget', () => { expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description); }); - it('sets the input field type attribute to "text"', async () => { + it('sets the input field type attribute to "text"', () => { createComponent(); expect(findFirstGlFormInputGroup().attributes('type')).toBe('text'); @@ -164,7 +164,7 @@ describe('Pipeline Wizard - List Widget', () => { }); describe('form validation', () => { - it('does not show validation state when untouched', async () => { + it('does not show validation state when untouched', () => { createComponent({}, mountExtended); expect(findGlFormGroup().classes()).not.toContain('is-valid'); expect(findGlFormGroup().classes()).not.toContain('is-invalid'); diff --git a/spec/frontend/pipeline_wizard/components/widgets/text_spec.js b/spec/frontend/pipeline_wizard/components/widgets/text_spec.js index a11c0214d15..abfb4a33c0f 100644 --- a/spec/frontend/pipeline_wizard/components/widgets/text_spec.js +++ b/spec/frontend/pipeline_wizard/components/widgets/text_spec.js @@ -123,7 +123,7 @@ describe('Pipeline Wizard - Text Widget', () => { expect(findGlFormGroup().classes()).toContain('is-invalid'); }); - it('does not update validation if not required', async () => { + it('does not update validation if not required', () => { createComponent({ pattern: null, validate: true, diff --git a/spec/frontend/pipeline_wizard/components/wrapper_spec.js b/spec/frontend/pipeline_wizard/components/wrapper_spec.js index 1056602c912..2808fd0c7a5 100644 --- a/spec/frontend/pipeline_wizard/components/wrapper_spec.js +++ b/spec/frontend/pipeline_wizard/components/wrapper_spec.js @@ -82,7 +82,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { expect(wrapper.findByTestId('editor-header').text()).toBe(expectedMessage); }); - it('shows the editor header with a custom filename', async () => { + it('shows the editor header with a custom filename', () => { const filename = 'my-file.yml'; createComponent({ filename, @@ -142,7 +142,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { }); if (expectCommitStepShown) { - it('does not show the step wrapper', async () => { + it('does not show the step wrapper', () => { expect(wrapper.findComponent(WizardStep).isVisible()).toBe(false); }); @@ -150,7 +150,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { expect(wrapper.findComponent(CommitStep).isVisible()).toBe(true); }); } else { - it('passes the correct step config to the step component', async () => { + it('passes the correct step config to the step component', () => { expect(getStepWrapper().props('inputs')).toMatchObject(expectStepDef.inputs); }); @@ -250,7 +250,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { }); describe('integration test', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({}, mountExtended); }); diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js index e2dc8120309..5483c1c7b99 100644 --- a/spec/frontend/pipelines/components/dag/dag_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_spec.js @@ -59,7 +59,7 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - it('does not render the graph', async () => { + it('does not render the graph', () => { expect(getGraph().exists()).toBe(false); }); @@ -70,7 +70,7 @@ describe('Pipeline DAG graph wrapper', () => { describe('when all query variables are defined', () => { describe('but the parse fails', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ graphData: unparseableGraph, }); @@ -88,7 +88,7 @@ describe('Pipeline DAG graph wrapper', () => { }); describe('parse succeeds', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ method: mount }); }); @@ -102,7 +102,7 @@ describe('Pipeline DAG graph wrapper', () => { }); describe('parse succeeds, but the resulting graph is too small', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ graphData: tooSmallGraph, }); @@ -120,7 +120,7 @@ describe('Pipeline DAG graph wrapper', () => { }); describe('the returned data is empty', () => { - beforeEach(async () => { + beforeEach(() => { createComponent({ method: mount, graphData: graphWithoutDependencies, @@ -139,7 +139,7 @@ describe('Pipeline DAG graph wrapper', () => { }); describe('annotations', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); }); diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js index 52df7b4500b..39475788fe2 100644 --- a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js +++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js @@ -34,7 +34,7 @@ describe('Jobs app', () => { const createComponent = (resolver) => { wrapper = shallowMount(JobsApp, { provide: { - fullPath: 'root/ci-project', + projectPath: 'root/ci-project', pipelineIid: 1, }, apolloProvider: createMockApolloProvider(resolver), diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js index 864f2d66f60..21d92fec9bf 100644 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js @@ -129,7 +129,7 @@ describe('Pipelines stage component', () => { await axios.waitForAll(); }); - it('renders the received data and emits the correct events', async () => { + it('renders the received data and emits the correct events', () => { expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); expect(findDropdownMenuTitle().text()).toContain(stageReply.name); expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js index 0f4a2b1d02f..4bf4257f462 100644 --- a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js @@ -1,24 +1,10 @@ import '~/commons'; -import { GlButton, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { stubExperiments } from 'helpers/experimentation_helper'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; -import { - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - RUNNERS_SETTINGS_LINK_CLICKED_EVENT, - RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, - RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, - I18N, -} from '~/ci/pipeline_editor/constants'; const pipelineEditorPath = '/-/ci/editor'; -const ciRunnerSettingsPath = '/-/settings/ci_cd'; - -jest.mock('~/experimentation/experiment_tracking'); describe('Pipelines CI Templates', () => { let wrapper; @@ -28,8 +14,6 @@ describe('Pipelines CI Templates', () => { return shallowMountExtended(PipelinesCiTemplates, { provide: { pipelineEditorPath, - ciRunnerSettingsPath, - anyRunnersAvailable: true, ...propsData, }, stubs, @@ -38,19 +22,17 @@ describe('Pipelines CI Templates', () => { const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); const findCiTemplates = () => wrapper.findComponent(CiTemplates); - const findSettingsLink = () => wrapper.findByTestId('settings-link'); - const findDocumentationLink = () => wrapper.findByTestId('documentation-link'); - const findSettingsButton = () => wrapper.findByTestId('settings-button'); - describe('renders test template', () => { + describe('templates', () => { beforeEach(() => { wrapper = createWrapper(); }); - it('links to the getting started template', () => { + it('renders test template and Ci templates', () => { expect(findTestTemplateLink().attributes('href')).toBe( pipelineEditorPath.concat('?template=Getting-Started'), ); + expect(findCiTemplates().exists()).toBe(true); }); }); @@ -73,84 +55,4 @@ describe('Pipelines CI Templates', () => { }); }); }); - - describe('when the runners_availability_section experiment is active', () => { - beforeEach(() => { - stubExperiments({ runners_availability_section: 'candidate' }); - }); - - describe('when runners are available', () => { - beforeEach(() => { - wrapper = createWrapper({ anyRunnersAvailable: true }, { GitlabExperiment, GlSprintf }); - }); - - it('show the runners available section', () => { - expect(wrapper.text()).toContain(I18N.runners.title); - }); - - it('tracks an event when clicking the settings link', () => { - findSettingsLink().vm.$emit('click'); - - expect(ExperimentTracking).toHaveBeenCalledWith( - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - ); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - RUNNERS_SETTINGS_LINK_CLICKED_EVENT, - ); - }); - - it('tracks an event when clicking the documentation link', () => { - findDocumentationLink().vm.$emit('click'); - - expect(ExperimentTracking).toHaveBeenCalledWith( - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - ); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, - ); - }); - }); - - describe('when runners are not available', () => { - beforeEach(() => { - wrapper = createWrapper({ anyRunnersAvailable: false }, { GitlabExperiment, GlButton }); - }); - - it('show the no runners available section', () => { - expect(wrapper.text()).toContain(I18N.noRunners.title); - }); - - it('tracks an event when clicking the settings button', () => { - findSettingsButton().trigger('click'); - - expect(ExperimentTracking).toHaveBeenCalledWith( - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - ); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, - ); - }); - }); - }); - - describe.each` - experimentVariant | anyRunnersAvailable | templatesRendered - ${'control'} | ${true} | ${true} - ${'control'} | ${false} | ${true} - ${'candidate'} | ${true} | ${true} - ${'candidate'} | ${false} | ${false} - `( - 'when the runners_availability_section experiment variant is $experimentVariant and runners are available: $anyRunnersAvailable', - ({ experimentVariant, anyRunnersAvailable, templatesRendered }) => { - beforeEach(() => { - stubExperiments({ runners_availability_section: experimentVariant }); - wrapper = createWrapper({ anyRunnersAvailable }); - }); - - it(`renders the templates: ${templatesRendered}`, () => { - expect(findTestTemplateLink().exists()).toBe(templatesRendered); - expect(findCiTemplates().exists()).toBe(templatesRendered); - }); - }, - ); }); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 42e47a23db8..cc952eac1d7 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -1,10 +1,10 @@ -import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { stubPerformanceWebAPI } from 'helpers/performance'; import waitForPromises from 'helpers/wait_for_promises'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; @@ -48,23 +48,25 @@ describe('Pipeline graph wrapper', () => { useLocalStorageSpy(); let wrapper; - const getAlert = () => wrapper.findComponent(GlAlert); - const getDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]'); - const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const getLinksLayer = () => wrapper.findComponent(LinksLayer); - const getGraph = () => wrapper.findComponent(PipelineGraph); - const getStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]'); - const getAllStageColumnGroupsInColumn = () => + let requestHandlers; + const findAlert = () => wrapper.findComponent(GlAlert); + const findDependenciesToggle = () => wrapper.findByTestId('show-links-toggle'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLinksLayer = () => wrapper.findComponent(LinksLayer); + const findGraph = () => wrapper.findComponent(PipelineGraph); + const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title'); + const findAllStageColumnGroupsInColumn = () => wrapper.findComponent(StageColumnComponent).findAll('[data-testid="stage-column-group"]'); - const getViewSelector = () => wrapper.findComponent(GraphViewSelector); - const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert); + const findViewSelector = () => wrapper.findComponent(GraphViewSelector); + const findViewSelectorToggle = () => findViewSelector().findComponent(GlToggle); + const findViewSelectorTrip = () => findViewSelector().findComponent(GlAlert); const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const createComponent = ({ apolloProvider, data = {}, provide = {}, - mountFn = shallowMount, + mountFn = shallowMountExtended, } = {}) => { wrapper = mountFn(PipelineGraphWrapper, { provide: { @@ -84,37 +86,41 @@ describe('Pipeline graph wrapper', () => { calloutsList = [], data = {}, getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse), - mountFn = shallowMount, + mountFn = shallowMountExtended, provide = {}, } = {}) => { const callouts = mapCallouts(calloutsList); - const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)); - const getPipelineHeaderDataHandler = jest.fn().mockResolvedValue(mockRunningPipelineHeaderData); - const requestHandlers = [ - [getPipelineHeaderData, getPipelineHeaderDataHandler], - [getPipelineDetails, getPipelineDetailsHandler], - [getUserCallouts, getUserCalloutsHandler], + requestHandlers = { + getUserCalloutsHandler: jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)), + getPipelineHeaderDataHandler: jest.fn().mockResolvedValue(mockRunningPipelineHeaderData), + getPipelineDetailsHandler, + }; + + const handlers = [ + [getPipelineHeaderData, requestHandlers.getPipelineHeaderDataHandler], + [getPipelineDetails, requestHandlers.getPipelineDetailsHandler], + [getUserCallouts, requestHandlers.getUserCalloutsHandler], ]; - const apolloProvider = createMockApollo(requestHandlers); + const apolloProvider = createMockApollo(handlers); createComponent({ apolloProvider, data, provide, mountFn }); }; describe('when data is loading', () => { it('displays the loading icon', () => { createComponentWithApollo(); - expect(getLoadingIcon().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(true); }); it('does not display the alert', () => { createComponentWithApollo(); - expect(getAlert().exists()).toBe(false); + expect(findAlert().exists()).toBe(false); }); it('does not display the graph', () => { createComponentWithApollo(); - expect(getGraph().exists()).toBe(false); + expect(findGraph().exists()).toBe(false); }); it('skips querying headerPipeline', () => { @@ -130,19 +136,19 @@ describe('Pipeline graph wrapper', () => { }); it('does not display the loading icon', () => { - expect(getLoadingIcon().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); }); it('does not display the alert', () => { - expect(getAlert().exists()).toBe(false); + expect(findAlert().exists()).toBe(false); }); it('displays the graph', () => { - expect(getGraph().exists()).toBe(true); + expect(findGraph().exists()).toBe(true); }); it('passes the etag resource and metrics path to the graph', () => { - expect(getGraph().props('configPaths')).toMatchObject({ + expect(findGraph().props('configPaths')).toMatchObject({ graphqlResourceEtag: defaultProvide.graphqlResourceEtag, metricsPath: defaultProvide.metricsPath, }); @@ -158,15 +164,15 @@ describe('Pipeline graph wrapper', () => { }); it('does not display the loading icon', () => { - expect(getLoadingIcon().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); }); it('displays the alert', () => { - expect(getAlert().exists()).toBe(true); + expect(findAlert().exists()).toBe(true); }); it('does not display the graph', () => { - expect(getGraph().exists()).toBe(false); + expect(findGraph().exists()).toBe(false); }); }); @@ -181,18 +187,18 @@ describe('Pipeline graph wrapper', () => { }); it('does not display the loading icon', () => { - expect(getLoadingIcon().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); }); it('displays the no iid alert', () => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe( + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe( 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', ); }); it('does not display the graph', () => { - expect(getGraph().exists()).toBe(false); + expect(findGraph().exists()).toBe(false); }); }); @@ -203,11 +209,11 @@ describe('Pipeline graph wrapper', () => { }); describe('when receiving `setSkipRetryModal` event', () => { it('passes down `skipRetryModal` value as true', async () => { - expect(getGraph().props('skipRetryModal')).toBe(false); + expect(findGraph().props('skipRetryModal')).toBe(false); - await getGraph().vm.$emit('setSkipRetryModal'); + await findGraph().vm.$emit('setSkipRetryModal'); - expect(getGraph().props('skipRetryModal')).toBe(true); + expect(findGraph().props('skipRetryModal')).toBe(true); }); }); }); @@ -216,36 +222,37 @@ describe('Pipeline graph wrapper', () => { beforeEach(async () => { createComponentWithApollo(); await waitForPromises(); - await getGraph().vm.$emit('error', { type: ACTION_FAILURE }); + await findGraph().vm.$emit('error', { type: ACTION_FAILURE }); }); it('does not display the loading icon', () => { - expect(getLoadingIcon().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); }); it('displays the action error alert', () => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe('An error occurred while performing this action.'); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('An error occurred while performing this action.'); }); it('displays the graph', () => { - expect(getGraph().exists()).toBe(true); + expect(findGraph().exists()).toBe(true); }); }); describe('when refresh action is emitted', () => { beforeEach(async () => { createComponentWithApollo(); - jest.spyOn(wrapper.vm.$apollo.queries.headerPipeline, 'refetch'); - jest.spyOn(wrapper.vm.$apollo.queries.pipeline, 'refetch'); await waitForPromises(); - getGraph().vm.$emit('refreshPipelineGraph'); + findGraph().vm.$emit('refreshPipelineGraph'); }); it('calls refetch', () => { - expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(false); - expect(wrapper.vm.$apollo.queries.headerPipeline.refetch).toHaveBeenCalled(); - expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled(); + expect(requestHandlers.getPipelineHeaderDataHandler).toHaveBeenCalledWith({ + fullPath: 'frog/amphibirama', + iid: '22', + }); + expect(requestHandlers.getPipelineDetailsHandler).toHaveBeenCalledTimes(2); + expect(requestHandlers.getUserCalloutsHandler).toHaveBeenCalledWith({}); }); }); @@ -277,18 +284,18 @@ describe('Pipeline graph wrapper', () => { it('shows correct errors and does not overwrite populated data when data is empty', async () => { /* fails at first, shows error, no data yet */ - expect(getAlert().exists()).toBe(true); - expect(getGraph().exists()).toBe(false); + expect(findAlert().exists()).toBe(true); + expect(findGraph().exists()).toBe(false); /* succeeds, clears error, shows graph */ await advanceApolloTimers(); - expect(getAlert().exists()).toBe(false); - expect(getGraph().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + expect(findGraph().exists()).toBe(true); /* fails again, alert returns but data persists */ await advanceApolloTimers(); - expect(getAlert().exists()).toBe(true); - expect(getGraph().exists()).toBe(true); + expect(findAlert().exists()).toBe(true); + expect(findGraph().exists()).toBe(true); }); }); @@ -298,38 +305,38 @@ describe('Pipeline graph wrapper', () => { beforeEach(async () => { layersFn = jest.spyOn(parsingUtils, 'listByLayers'); createComponentWithApollo({ - mountFn: mount, + mountFn: mountExtended, }); await waitForPromises(); }); it('appears when pipeline uses needs', () => { - expect(getViewSelector().exists()).toBe(true); + expect(findViewSelector().exists()).toBe(true); }); it('switches between views', async () => { const groupsInFirstColumn = mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes.length; - expect(getAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn); - expect(getStageColumnTitle().text()).toBe('build'); - await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW); - expect(getAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn + 1); - expect(getStageColumnTitle().text()).toBe(''); + expect(findAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn); + expect(findStageColumnTitle().text()).toBe('build'); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + expect(findAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn + 1); + expect(findStageColumnTitle().text()).toBe(''); }); it('saves the view type to local storage', async () => { - await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); expect(localStorage.setItem.mock.calls).toEqual([[VIEW_TYPE_KEY, LAYER_VIEW]]); }); it('calls listByLayers only once no matter how many times view is switched', async () => { expect(layersFn).not.toHaveBeenCalled(); - await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); expect(layersFn).toHaveBeenCalledTimes(1); - await getViewSelector().vm.$emit('updateViewType', STAGE_VIEW); - await getViewSelector().vm.$emit('updateViewType', LAYER_VIEW); - await getViewSelector().vm.$emit('updateViewType', STAGE_VIEW); + await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW); expect(layersFn).toHaveBeenCalledTimes(1); }); }); @@ -340,7 +347,7 @@ describe('Pipeline graph wrapper', () => { data: { currentViewType: LAYER_VIEW, }, - mountFn: mount, + mountFn: mountExtended, }); jest.runOnlyPendingTimers(); @@ -349,10 +356,10 @@ describe('Pipeline graph wrapper', () => { it('sets showLinks to true', async () => { /* This spec uses .props for performance reasons. */ - expect(getLinksLayer().exists()).toBe(true); - expect(getLinksLayer().props('showLinks')).toBe(false); - expect(getViewSelector().props('type')).toBe(LAYER_VIEW); - await getDependenciesToggle().vm.$emit('change', true); + expect(findLinksLayer().exists()).toBe(true); + expect(findLinksLayer().props('showLinks')).toBe(false); + expect(findViewSelector().props('type')).toBe(LAYER_VIEW); + await findDependenciesToggle().vm.$emit('change', true); jest.runOnlyPendingTimers(); await waitForPromises(); @@ -367,15 +374,15 @@ describe('Pipeline graph wrapper', () => { currentViewType: LAYER_VIEW, showLinks: true, }, - mountFn: mount, + mountFn: mountExtended, }); await waitForPromises(); }); it('shows the hover tip in the view selector', async () => { - await getViewSelector().setData({ showLinksActive: true }); - expect(getViewSelectorTrip().exists()).toBe(true); + await findViewSelectorToggle().vm.$emit('change', true); + expect(findViewSelectorTrip().exists()).toBe(true); }); }); @@ -386,7 +393,7 @@ describe('Pipeline graph wrapper', () => { currentViewType: LAYER_VIEW, showLinks: true, }, - mountFn: mount, + mountFn: mountExtended, calloutsList: ['pipeline_needs_hover_tip'.toUpperCase()], }); @@ -395,8 +402,8 @@ describe('Pipeline graph wrapper', () => { }); it('does not show the hover tip', async () => { - await getViewSelector().setData({ showLinksActive: true }); - expect(getViewSelectorTrip().exists()).toBe(false); + await findViewSelectorToggle().vm.$emit('change', true); + expect(findViewSelectorTrip().exists()).toBe(false); }); }); @@ -405,7 +412,7 @@ describe('Pipeline graph wrapper', () => { localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); createComponentWithApollo({ - mountFn: mount, + mountFn: mountExtended, }); await waitForPromises(); @@ -436,7 +443,7 @@ describe('Pipeline graph wrapper', () => { localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); createComponentWithApollo({ - mountFn: mount, + mountFn: mountExtended, getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), }); @@ -448,7 +455,7 @@ describe('Pipeline graph wrapper', () => { }); it('still passes stage type to graph', () => { - expect(getGraph().props('viewType')).toBe(STAGE_VIEW); + expect(findGraph().props('viewType')).toBe(STAGE_VIEW); }); }); @@ -458,7 +465,7 @@ describe('Pipeline graph wrapper', () => { nonNeedsResponse.data.project.pipeline.usesNeeds = false; createComponentWithApollo({ - mountFn: mount, + mountFn: mountExtended, getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), }); @@ -467,7 +474,7 @@ describe('Pipeline graph wrapper', () => { }); it('does not appear when pipeline does not use needs', () => { - expect(getViewSelector().exists()).toBe(false); + expect(findViewSelector().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js index 78265165d1f..65ae9d19978 100644 --- a/spec/frontend/pipelines/graph/graph_view_selector_spec.js +++ b/spec/frontend/pipelines/graph/graph_view_selector_spec.js @@ -144,6 +144,7 @@ describe('the graph view selector component', () => { createComponent({ props: { showLinks: true, + type: LAYER_VIEW, }, data: { showLinksActive: true, @@ -162,6 +163,18 @@ describe('the graph view selector component', () => { await findHoverTip().find('button').trigger('click'); expect(wrapper.emitted().dismissHoverTip).toHaveLength(1); }); + + it('is displayed at first then hidden on swith to STAGE_VIEW then displayed on switch to LAYER_VIEW', async () => { + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + + await findStageViewButton().trigger('click'); + expect(findHoverTip().exists()).toBe(false); + + await findLayerViewButton().trigger('click'); + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + }); }); describe('when links are live and it has been previously dismissed', () => { @@ -170,6 +183,7 @@ describe('the graph view selector component', () => { props: { showLinks: true, tipPreviouslyDismissed: true, + type: LAYER_VIEW, }, data: { showLinksActive: true, @@ -187,6 +201,7 @@ describe('the graph view selector component', () => { createComponent({ props: { showLinks: true, + type: LAYER_VIEW, }, data: { showLinksActive: false, diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index b5ef10dee12..bf92cd585d9 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -1,11 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { createWrapper } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; @@ -14,10 +13,9 @@ import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline. import CiStatus from '~/vue_shared/components/ci_icon.vue'; import mockPipeline from './linked_pipelines_mock_data'; -Vue.use(VueApollo); - describe('Linked pipeline', () => { let wrapper; + let requestHandlers; const downstreamProps = { pipeline: { @@ -47,15 +45,28 @@ describe('Linked pipeline', () => { const findPipelineLink = () => wrapper.findByTestId('pipelineLink'); const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline'); - const createWrapper = ({ propsData }) => { - const mockApollo = createMockApollo(); + const defaultHandlers = { + cancelPipeline: jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [] } } }), + retryPipeline: jest.fn().mockResolvedValue({ data: { pipelineRetry: { errors: [] } } }), + }; - wrapper = extendedWrapper( - mount(LinkedPipelineComponent, { - propsData, - apolloProvider: mockApollo, - }), - ); + const createMockApolloProvider = (handlers) => { + Vue.use(VueApollo); + + requestHandlers = handlers; + return createMockApollo([ + [CancelPipelineMutation, requestHandlers.cancelPipeline], + [RetryPipelineMutation, requestHandlers.retryPipeline], + ]); + }; + + const createComponent = ({ propsData, handlers = defaultHandlers }) => { + const mockApollo = createMockApolloProvider(handlers); + + wrapper = mountExtended(LinkedPipelineComponent, { + propsData, + apolloProvider: mockApollo, + }); }; describe('rendered output', () => { @@ -68,7 +79,7 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper({ propsData: props }); + createComponent({ propsData: props }); }); it('should render the project name', () => { @@ -109,7 +120,7 @@ describe('Linked pipeline', () => { describe('upstream pipelines', () => { beforeEach(() => { - createWrapper({ propsData: upstreamProps }); + createComponent({ propsData: upstreamProps }); }); it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { @@ -129,7 +140,7 @@ describe('Linked pipeline', () => { describe('downstream pipelines', () => { describe('styling', () => { beforeEach(() => { - createWrapper({ propsData: downstreamProps }); + createComponent({ propsData: downstreamProps }); }); it('parent/child label container should exist', () => { @@ -164,7 +175,7 @@ describe('Linked pipeline', () => { pipeline: { ...mockPipeline, retryable: true }, }; - createWrapper({ propsData: retryablePipeline }); + createComponent({ propsData: retryablePipeline }); }); it('does not show the retry or cancel button', () => { @@ -175,14 +186,14 @@ describe('Linked pipeline', () => { }); describe('on a downstream', () => { + const retryablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + describe('when retryable', () => { beforeEach(() => { - const retryablePipeline = { - ...downstreamProps, - pipeline: { ...mockPipeline, retryable: true }, - }; - - createWrapper({ propsData: retryablePipeline }); + createComponent({ propsData: retryablePipeline }); }); it('shows only the retry button', () => { @@ -205,50 +216,51 @@ describe('Linked pipeline', () => { describe('and the retry button is clicked', () => { describe('on success', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); - jest.spyOn(wrapper.vm, '$emit'); await findRetryButton().trigger('click'); }); it('calls the retry mutation', () => { - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: RetryPipelineMutation, - variables: { - id: convertToGraphQLId(TYPENAME_CI_PIPELINE, mockPipeline.id), - }, + expect(requestHandlers.retryPipeline).toHaveBeenCalledTimes(1); + expect(requestHandlers.retryPipeline).toHaveBeenCalledWith({ + id: 'gid://gitlab/Ci::Pipeline/195', }); }); - it('emits the refreshPipelineGraph event', () => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); + it('emits the refreshPipelineGraph event', async () => { + await waitForPromises(); + expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1); }); }); describe('on failure', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); - jest.spyOn(wrapper.vm, '$emit'); + createComponent({ + propsData: retryablePipeline, + handlers: { + retryPipeline: jest.fn().mockRejectedValue({ errors: [] }), + cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }), + }, + }); + await findRetryButton().trigger('click'); }); - it('emits an error event', () => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { - type: ACTION_FAILURE, - }); + it('emits an error event', async () => { + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]); }); }); }); }); describe('when cancelable', () => { - beforeEach(() => { - const cancelablePipeline = { - ...downstreamProps, - pipeline: { ...mockPipeline, cancelable: true }, - }; + const cancelablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true }, + }; - createWrapper({ propsData: cancelablePipeline }); + beforeEach(() => { + createComponent({ propsData: cancelablePipeline }); }); it('shows only the cancel button', () => { @@ -271,34 +283,37 @@ describe('Linked pipeline', () => { describe('and the cancel button is clicked', () => { describe('on success', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); - jest.spyOn(wrapper.vm, '$emit'); await findCancelButton().trigger('click'); }); it('calls the cancel mutation', () => { - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: CancelPipelineMutation, - variables: { - id: convertToGraphQLId(TYPENAME_CI_PIPELINE, mockPipeline.id), - }, + expect(requestHandlers.cancelPipeline).toHaveBeenCalledTimes(1); + expect(requestHandlers.cancelPipeline).toHaveBeenCalledWith({ + id: 'gid://gitlab/Ci::Pipeline/195', }); }); - it('emits the refreshPipelineGraph event', () => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); + it('emits the refreshPipelineGraph event', async () => { + await waitForPromises(); + expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1); }); }); + describe('on failure', () => { beforeEach(async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); - jest.spyOn(wrapper.vm, '$emit'); + createComponent({ + propsData: cancelablePipeline, + handlers: { + retryPipeline: jest.fn().mockRejectedValue({ errors: [] }), + cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }), + }, + }); + await findCancelButton().trigger('click'); }); - it('emits an error event', () => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { - type: ACTION_FAILURE, - }); + + it('emits an error event', async () => { + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]); }); }); }); @@ -311,7 +326,7 @@ describe('Linked pipeline', () => { pipeline: { ...mockPipeline, cancelable: true, retryable: true }, }; - createWrapper({ propsData: pipelineWithTwoActions }); + createComponent({ propsData: pipelineWithTwoActions }); }); it('only shows the cancel button', () => { @@ -334,7 +349,7 @@ describe('Linked pipeline', () => { }, }; - createWrapper({ propsData: pipelineWithTwoActions }); + createComponent({ propsData: pipelineWithTwoActions }); }); it('does not show any action button', () => { @@ -355,7 +370,7 @@ describe('Linked pipeline', () => { `( '$pipelineType.columnTitle pipeline button icon should be $chevronPosition with $buttonBorderClasses if expanded state is $expanded', ({ pipelineType, chevronPosition, buttonBorderClasses, expanded }) => { - createWrapper({ propsData: { ...pipelineType, expanded } }); + createComponent({ propsData: { ...pipelineType, expanded } }); expect(findExpandButton().props('icon')).toBe(chevronPosition); expect(findExpandButton().classes()).toContain(buttonBorderClasses); }, @@ -363,7 +378,7 @@ describe('Linked pipeline', () => { describe('shadow border', () => { beforeEach(() => { - createWrapper({ propsData: downstreamProps }); + createComponent({ propsData: downstreamProps }); }); it.each` @@ -397,7 +412,7 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper({ propsData: props }); + createComponent({ propsData: props }); }); it('loading icon is visible', () => { @@ -415,36 +430,35 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper({ propsData: props }); + createComponent({ propsData: props }); }); it('emits `pipelineClicked` event', () => { - jest.spyOn(wrapper.vm, '$emit'); findButton().trigger('click'); - expect(wrapper.emitted().pipelineClicked).toHaveLength(1); + expect(wrapper.emitted('pipelineClicked')).toHaveLength(1); }); - it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, () => { - jest.spyOn(wrapper.vm.$root, '$emit'); - findButton().trigger('click'); + it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, async () => { + const root = createWrapper(wrapper.vm.$root); + await findButton().vm.$emit('click'); - expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual([BV_HIDE_TOOLTIP]); + expect(root.emitted(BV_HIDE_TOOLTIP)).toHaveLength(1); }); it('should emit downstreamHovered with job name on mouseover', () => { findLinkedPipeline().trigger('mouseover'); - expect(wrapper.emitted().downstreamHovered).toStrictEqual([['test_c']]); + expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['test_c']]); }); it('should emit downstreamHovered with empty string on mouseleave', () => { findLinkedPipeline().trigger('mouseleave'); - expect(wrapper.emitted().downstreamHovered).toStrictEqual([['']]); + expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['']]); }); it('should emit pipelineExpanded with job name and expanded state on click', () => { findExpandButton().trigger('click'); - expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['test_c', true]]); + expect(wrapper.emitted('pipelineExpandToggle')).toStrictEqual([['test_c', true]]); }); }); }); diff --git a/spec/frontend/pipelines/pipeline_operations_spec.js b/spec/frontend/pipelines/pipeline_operations_spec.js new file mode 100644 index 00000000000..b2191453824 --- /dev/null +++ b/spec/frontend/pipelines/pipeline_operations_spec.js @@ -0,0 +1,77 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; +import PipelineMultiActions from '~/pipelines/components/pipelines_list/pipeline_multi_actions.vue'; +import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue'; +import eventHub from '~/pipelines/event_hub'; + +describe('Pipeline operations', () => { + let wrapper; + + const defaultProps = { + pipeline: { + id: 329, + iid: 234, + details: { + has_manual_actions: true, + has_scheduled_actions: false, + }, + flags: { + retryable: true, + cancelable: true, + }, + cancel_path: '/root/ci-project/-/pipelines/329/cancel', + retry_path: '/root/ci-project/-/pipelines/329/retry', + }, + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineOperations, { + propsData: { + ...props, + }, + }); + }; + + const findManualActions = () => wrapper.findComponent(PipelinesManualActions); + const findMultiActions = () => wrapper.findComponent(PipelineMultiActions); + const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); + const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + + it('should display pipeline manual actions', () => { + createComponent(); + + expect(findManualActions().exists()).toBe(true); + }); + + it('should display pipeline multi actions', () => { + createComponent(); + + expect(findMultiActions().exists()).toBe(true); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('should emit retryPipeline event', () => { + findRetryBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'retryPipeline', + defaultProps.pipeline.retry_path, + ); + }); + + it('should emit openConfirmationModal event', () => { + findCancelBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', { + pipeline: defaultProps.pipeline, + endpoint: defaultProps.pipeline.cancel_path, + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js deleted file mode 100644 index 2db9f5c2a83..00000000000 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ /dev/null @@ -1,168 +0,0 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { TEST_HOST } from 'spec/test_constants'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; -import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; -import { TRACKING_CATEGORIES } from '~/pipelines/constants'; - -jest.mock('~/alert'); -jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); - -describe('Pipelines Actions dropdown', () => { - let wrapper; - let mock; - - const createComponent = (props, mountFn = shallowMount) => { - wrapper = mountFn(PipelinesManualActions, { - propsData: { - ...props, - }, - }); - }; - - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown); - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - confirmAction.mockReset(); - }); - - describe('manual actions', () => { - const mockActions = [ - { - name: 'stop_review', - path: `${TEST_HOST}/root/review-app/builds/1893/play`, - }, - { - name: 'foo', - path: `${TEST_HOST}/disabled/pipeline/action`, - playable: false, - }, - ]; - - beforeEach(() => { - createComponent({ actions: mockActions }); - }); - - it('renders a dropdown with the provided actions', () => { - expect(findAllDropdownItems()).toHaveLength(mockActions.length); - }); - - it("renders a disabled action when it's not playable", () => { - expect(findAllDropdownItems().at(1).attributes('disabled')).toBe('true'); - }); - - describe('on click', () => { - it('makes a request and toggles the loading state', async () => { - mock.onPost(mockActions.path).reply(HTTP_STATUS_OK); - - findAllDropdownItems().at(0).vm.$emit('click'); - - await nextTick(); - expect(findDropdown().props('loading')).toBe(true); - - await waitForPromises(); - expect(findDropdown().props('loading')).toBe(false); - }); - - it('makes a failed request and toggles the loading state', async () => { - mock.onPost(mockActions.path).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - findAllDropdownItems().at(0).vm.$emit('click'); - - await nextTick(); - expect(findDropdown().props('loading')).toBe(true); - - await waitForPromises(); - expect(findDropdown().props('loading')).toBe(false); - expect(createAlert).toHaveBeenCalledTimes(1); - }); - }); - - describe('tracking', () => { - afterEach(() => { - unmockTracking(); - }); - - it('tracks manual actions click', () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - findDropdown().vm.$emit('shown'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', { - label: TRACKING_CATEGORIES.table, - }); - }); - }); - }); - - describe('scheduled jobs', () => { - const scheduledJobAction = { - name: 'scheduled action', - path: `${TEST_HOST}/scheduled/job/action`, - playable: true, - scheduled_at: '2063-04-05T00:42:00Z', - }; - const expiredJobAction = { - name: 'expired action', - path: `${TEST_HOST}/expired/job/action`, - playable: true, - scheduled_at: '2018-10-05T08:23:00Z', - }; - - beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); - createComponent({ actions: [scheduledJobAction, expiredJobAction] }); - }); - - it('makes post request after confirming', async () => { - mock.onPost(scheduledJobAction.path).reply(HTTP_STATUS_OK); - confirmAction.mockResolvedValueOnce(true); - - findAllDropdownItems().at(0).vm.$emit('click'); - - expect(confirmAction).toHaveBeenCalled(); - - await waitForPromises(); - - expect(mock.history.post).toHaveLength(1); - }); - - it('does not make post request if confirmation is cancelled', async () => { - mock.onPost(scheduledJobAction.path).reply(HTTP_STATUS_OK); - confirmAction.mockResolvedValueOnce(false); - - findAllDropdownItems().at(0).vm.$emit('click'); - - expect(confirmAction).toHaveBeenCalled(); - - await waitForPromises(); - - expect(mock.history.post).toHaveLength(0); - }); - - it('displays the remaining time in the dropdown', () => { - expect(findAllCountdowns().at(0).props('endDateString')).toBe( - scheduledJobAction.scheduled_at, - ); - }); - - it('displays 00:00:00 for expired jobs in the dropdown', () => { - expect(findAllCountdowns().at(1).props('endDateString')).toBe(expiredJobAction.scheduled_at); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipelines_manual_actions_spec.js b/spec/frontend/pipelines/pipelines_manual_actions_spec.js new file mode 100644 index 00000000000..e47e57db887 --- /dev/null +++ b/spec/frontend/pipelines/pipelines_manual_actions_spec.js @@ -0,0 +1,216 @@ +import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineActionsQueryResponse from 'test_fixtures/graphql/pipelines/get_pipeline_actions.query.graphql.json'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; +import getPipelineActionsQuery from '~/pipelines/graphql/queries/get_pipeline_actions.query.graphql'; +import { TRACKING_CATEGORIES } from '~/pipelines/constants'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +describe('Pipeline manual actions', () => { + let wrapper; + let mock; + + const queryHandler = jest.fn().mockResolvedValue(mockPipelineActionsQueryResponse); + const { + data: { + project: { + pipeline: { + jobs: { nodes }, + }, + }, + }, + } = mockPipelineActionsQueryResponse; + + const mockPath = nodes[2].playPath; + + const createComponent = (limit = 50) => { + wrapper = shallowMountExtended(PipelinesManualActions, { + provide: { + fullPath: 'root/ci-project', + manualActionsLimit: limit, + }, + propsData: { + iid: 100, + }, + stubs: { + GlDropdown, + }, + apolloProvider: createMockApollo([[getPipelineActionsQuery, queryHandler]]), + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLimitMessage = () => wrapper.findByTestId('limit-reached-msg'); + + it('skips calling query on mount', () => { + createComponent(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + + describe('loading', () => { + beforeEach(() => { + createComponent(); + + findDropdown().vm.$emit('shown'); + }); + + it('display loading state while actions are being fetched', () => { + expect(findAllDropdownItems().at(0).text()).toBe('Loading...'); + expect(findLoadingIcon().exists()).toBe(true); + expect(findAllDropdownItems()).toHaveLength(1); + }); + }); + + describe('loaded', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + }); + + afterEach(() => { + mock.restore(); + confirmAction.mockReset(); + }); + + it('displays dropdown with the provided actions', () => { + expect(findAllDropdownItems()).toHaveLength(3); + }); + + it("displays a disabled action when it's not playable", () => { + expect(findAllDropdownItems().at(0).attributes('disabled')).toBe('true'); + }); + + describe('on action click', () => { + it('makes a request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + }); + + it('makes a failed request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks manual actions click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('shown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); + + describe('scheduled jobs', () => { + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + }); + + it('makes post request after confirming', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(true); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(1); + }); + + it('does not make post request if confirmation is cancelled', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(false); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(0); + }); + + it('displays the remaining time in the dropdown', () => { + expect(findAllCountdowns().at(0).props('endDateString')).toBe(nodes[2].scheduledAt); + }); + }); + }); + + describe('limit message', () => { + it('limit message does not show', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(false); + }); + + it('limit message does show', async () => { + createComponent(3); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 48539d84024..44f345fdf4e 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -245,7 +245,7 @@ describe('Pipelines', () => { await waitForPromises(); }); - it('should filter pipelines', async () => { + it('should filter pipelines', () => { expect(findPipelinesTable().exists()).toBe(true); expect(findPipelineUrlLinks()).toHaveLength(1); @@ -287,7 +287,7 @@ describe('Pipelines', () => { await waitForPromises(); }); - it('should filter pipelines', async () => { + it('should filter pipelines', () => { expect(findEmptyState().text()).toBe('There are currently no pipelines.'); }); @@ -330,11 +330,11 @@ describe('Pipelines', () => { await waitForPromises(); }); - it('requests data with query params on filter submit', async () => { + it('requests data with query params on filter submit', () => { expect(mock.history.get[1].params).toEqual(expectedParams); }); - it('renders filtered pipelines', async () => { + it('renders filtered pipelines', () => { expect(findPipelineUrlLinks()).toHaveLength(1); expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); }); @@ -356,7 +356,7 @@ describe('Pipelines', () => { await waitForPromises(); }); - it('requests data with query params on filter submit', async () => { + it('requests data with query params on filter submit', () => { expect(mock.history.get[1].params).toEqual({ page: '1', scope: 'all' }); }); @@ -516,7 +516,7 @@ describe('Pipelines', () => { expect(findNavigationTabs().exists()).toBe(true); }); - it('is loading after a time', async () => { + it('is loading after a time', () => { expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); @@ -727,7 +727,7 @@ describe('Pipelines', () => { }); describe('when pipelines cannot be loaded', () => { - beforeEach(async () => { + beforeEach(() => { mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, {}); }); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index 9c374ea817a..685ac6ea3e5 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -58,7 +58,7 @@ describe('Mutations TestReports Store', () => { expect(mockState.errorMessage).toBe(message); }); - it('should show an alert message otherwise', () => { + it('should show an alert otherwise', () => { mutations[types.SET_SUITE_ERROR](mockState, {}); expect(createAlert).toHaveBeenCalled(); diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index d922820601e..3cb9cf3622a 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -93,7 +93,7 @@ describe('UpdateUsername component', () => { await findNewUsernameInput().setValue(newUsername); }); - it('confirmation modal contains proper header and body', async () => { + it('confirmation modal contains proper header and body', () => { const { modal } = findElements(); expect(modal.props('title')).toBe('Change username?'); diff --git a/spec/frontend/profile/components/overview_tab_spec.js b/spec/frontend/profile/components/overview_tab_spec.js index d4cb1dfd15d..aeab24cb730 100644 --- a/spec/frontend/profile/components/overview_tab_spec.js +++ b/spec/frontend/profile/components/overview_tab_spec.js @@ -1,15 +1,25 @@ -import { GlTab } from '@gitlab/ui'; +import { GlLoadingIcon, GlTab, GlLink } from '@gitlab/ui'; +import projects from 'test_fixtures/api/users/projects/get.json'; import { s__ } from '~/locale'; import OverviewTab from '~/profile/components/overview_tab.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ActivityCalendar from '~/profile/components/activity_calendar.vue'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; describe('OverviewTab', () => { let wrapper; - const createComponent = () => { - wrapper = shallowMountExtended(OverviewTab); + const defaultPropsData = { + personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }), + personalProjectsLoading: false, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(OverviewTab, { + propsData: { ...defaultPropsData, ...propsData }, + }); }; it('renders `GlTab` and sets `title` prop', () => { @@ -23,4 +33,41 @@ describe('OverviewTab', () => { expect(wrapper.findComponent(ActivityCalendar).exists()).toBe(true); }); + + it('renders personal projects section heading and `View all` link', () => { + createComponent(); + + expect( + wrapper.findByRole('heading', { name: OverviewTab.i18n.personalProjects }).exists(), + ).toBe(true); + expect(wrapper.findComponent(GlLink).text()).toBe(OverviewTab.i18n.viewAll); + }); + + describe('when personal projects are loading', () => { + it('renders loading icon', () => { + createComponent({ + propsData: { + personalProjects: [], + personalProjectsLoading: true, + }, + }); + + expect( + wrapper.findByTestId('personal-projects-section').findComponent(GlLoadingIcon).exists(), + ).toBe(true); + }); + }); + + describe('when projects are done loading', () => { + it('renders `ProjectsList` component and passes `projects` prop', () => { + createComponent(); + + expect( + wrapper + .findByTestId('personal-projects-section') + .findComponent(ProjectsList) + .props('projects'), + ).toMatchObject(defaultPropsData.personalProjects); + }); + }); }); diff --git a/spec/frontend/profile/components/profile_tabs_spec.js b/spec/frontend/profile/components/profile_tabs_spec.js index 11ab372f1dd..80a1ff422ab 100644 --- a/spec/frontend/profile/components/profile_tabs_spec.js +++ b/spec/frontend/profile/components/profile_tabs_spec.js @@ -1,6 +1,9 @@ +import projects from 'test_fixtures/api/users/projects/get.json'; import ProfileTabs from '~/profile/components/profile_tabs.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; - +import { createAlert } from '~/alert'; +import { getUserProjects } from '~/rest_api'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import OverviewTab from '~/profile/components/overview_tab.vue'; import ActivityTab from '~/profile/components/activity_tab.vue'; import GroupsTab from '~/profile/components/groups_tab.vue'; @@ -10,12 +13,20 @@ import StarredProjectsTab from '~/profile/components/starred_projects_tab.vue'; import SnippetsTab from '~/profile/components/snippets_tab.vue'; import FollowersTab from '~/profile/components/followers_tab.vue'; import FollowingTab from '~/profile/components/following_tab.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/alert'); +jest.mock('~/rest_api'); describe('ProfileTabs', () => { let wrapper; const createComponent = () => { - wrapper = shallowMountExtended(ProfileTabs); + wrapper = shallowMountExtended(ProfileTabs, { + provide: { + userId: '1', + }, + }); }; it.each([ @@ -33,4 +44,46 @@ describe('ProfileTabs', () => { expect(wrapper.findComponent(tab).exists()).toBe(true); }); + + describe('when personal projects API request is loading', () => { + beforeEach(() => { + getUserProjects.mockReturnValueOnce(new Promise(() => {})); + createComponent(); + }); + + it('passes correct props to `OverviewTab` component', () => { + expect(wrapper.findComponent(OverviewTab).props()).toEqual({ + personalProjects: [], + personalProjectsLoading: true, + }); + }); + }); + + describe('when personal projects API request is successful', () => { + beforeEach(async () => { + getUserProjects.mockResolvedValueOnce({ data: projects }); + createComponent(); + await waitForPromises(); + }); + + it('passes correct props to `OverviewTab` component', () => { + expect(wrapper.findComponent(OverviewTab).props()).toMatchObject({ + personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }), + personalProjectsLoading: false, + }); + }); + }); + + describe('when personal projects API request is not successful', () => { + beforeEach(() => { + getUserProjects.mockRejectedValueOnce(); + createComponent(); + }); + + it('calls `createAlert`', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: ProfileTabs.i18n.personalProjectsErrorMessage, + }); + }); + }); }); diff --git a/spec/frontend/projects/commit/components/branches_dropdown_spec.js b/spec/frontend/projects/commit/components/branches_dropdown_spec.js index 0e68bd21cd4..bff40c2bc39 100644 --- a/spec/frontend/projects/commit/components/branches_dropdown_spec.js +++ b/spec/frontend/projects/commit/components/branches_dropdown_spec.js @@ -59,7 +59,7 @@ describe('BranchesDropdown', () => { }); describe('Selecting Dropdown Item', () => { - it('emits event', async () => { + it('emits event', () => { findDropdown().vm.$emit('select', '_anything_'); expect(wrapper.emitted()).toHaveProperty('input'); @@ -68,13 +68,11 @@ describe('BranchesDropdown', () => { describe('When searching', () => { it('invokes fetchBranches', async () => { - const spy = jest.spyOn(wrapper.vm, 'fetchBranches'); - findDropdown().vm.$emit('search', '_anything_'); await nextTick(); - expect(spy).toHaveBeenCalledWith('_anything_'); + expect(spyFetchBranches).toHaveBeenCalledWith(expect.any(Object), '_anything_'); }); }); }); diff --git a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js index 70491405986..7df498f597b 100644 --- a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js +++ b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js @@ -85,7 +85,7 @@ describe('BranchesDropdown', () => { expect(findTagItem().exists()).toBe(false); }); - it('does not have a email patches options', () => { + it('does not have a patches options', () => { createComponent({ canEmailPatches: false }); expect(findEmailPatchesItem().exists()).toBe(false); diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js index 84cb30953c3..d40e2d7a48c 100644 --- a/spec/frontend/projects/commit/components/form_modal_spec.js +++ b/spec/frontend/projects/commit/components/form_modal_spec.js @@ -1,9 +1,9 @@ import { GlModal, GlForm, GlFormCheckbox, GlSprintf } from '@gitlab/ui'; import { within } from '@testing-library/dom'; -import { shallowMount, mount, createWrapper } from '@vue/test-utils'; +import { createWrapper } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import api from '~/api'; import axios from '~/lib/utils/axios_utils'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; @@ -21,21 +21,24 @@ describe('CommitFormModal', () => { let store; let axiosMock; - const createComponent = (method, state = {}, provide = {}, propsData = {}) => { + const createComponent = ({ + method = shallowMountExtended, + state = {}, + provide = {}, + propsData = {}, + } = {}) => { store = createStore({ ...mockData.mockModal, ...state }); - wrapper = extendedWrapper( - method(CommitFormModal, { - provide: { - ...provide, - }, - propsData: { ...mockData.modalPropsData, ...propsData }, - store, - attrs: { - static: true, - visible: true, - }, - }), - ); + wrapper = method(CommitFormModal, { + provide: { + ...provide, + }, + propsData: { ...mockData.modalPropsData, ...propsData }, + store, + attrs: { + static: true, + visible: true, + }, + }); }; const findModal = () => wrapper.findComponent(GlModal); @@ -62,13 +65,13 @@ describe('CommitFormModal', () => { it('Listens for opening of modal on mount', () => { jest.spyOn(eventHub, '$on'); - createComponent(shallowMount); + createComponent(); expect(eventHub.$on).toHaveBeenCalledWith(mockData.modalPropsData.openModal, wrapper.vm.show); }); it('Shows modal', () => { - createComponent(shallowMount); + createComponent(); const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); wrapper.vm.show(); @@ -77,25 +80,25 @@ describe('CommitFormModal', () => { }); it('Clears the modal state once modal is hidden', () => { - createComponent(shallowMount); + createComponent(); jest.spyOn(store, 'dispatch').mockImplementation(); - wrapper.vm.checked = false; + findCheckBox().vm.$emit('input', false); findModal().vm.$emit('hidden'); expect(store.dispatch).toHaveBeenCalledWith('clearModal'); expect(store.dispatch).toHaveBeenCalledWith('setSelectedBranch', ''); - expect(wrapper.vm.checked).toBe(true); + expect(findCheckBox().attributes('checked')).toBe('true'); }); it('Shows the checkbox for new merge request', () => { - createComponent(shallowMount); + createComponent(); expect(findCheckBox().exists()).toBe(true); }); it('Shows the prepended text', () => { - createComponent(shallowMount, {}, { prependedText: '_prepended_text_' }); + createComponent({ provide: { prependedText: '_prepended_text_' } }); expect(findPrependedText().exists()).toBe(true); expect(findPrependedText().findComponent(GlSprintf).attributes('message')).toBe( @@ -104,25 +107,25 @@ describe('CommitFormModal', () => { }); it('Does not show prepended text', () => { - createComponent(shallowMount); + createComponent(); expect(findPrependedText().exists()).toBe(false); }); it('Does not show extra message text', () => { - createComponent(shallowMount); + createComponent(); expect(findModal().find('[data-testid="appended-text"]').exists()).toBe(false); }); it('Does not show the checkbox for new merge request', () => { - createComponent(shallowMount, { pushCode: false }); + createComponent({ state: { pushCode: false } }); expect(findCheckBox().exists()).toBe(false); }); it('Shows the branch in fork message', () => { - createComponent(shallowMount, { pushCode: false }); + createComponent({ state: { pushCode: false } }); expect(findAppendedText().exists()).toBe(true); expect(findAppendedText().findComponent(GlSprintf).attributes('message')).toContain( @@ -131,7 +134,7 @@ describe('CommitFormModal', () => { }); it('Shows the branch collaboration message', () => { - createComponent(shallowMount, { pushCode: false, branchCollaboration: true }); + createComponent({ state: { pushCode: false, branchCollaboration: true } }); expect(findAppendedText().exists()).toBe(true); expect(findAppendedText().findComponent(GlSprintf).attributes('message')).toContain( @@ -142,17 +145,13 @@ describe('CommitFormModal', () => { describe('Taking action on the form', () => { beforeEach(() => { - createComponent(mount); + createComponent({ method: mountExtended }); }); it('Action primary button dispatches submit action', () => { - const submitSpy = jest.spyOn(findForm().element, 'submit'); - getByText(mockData.modalPropsData.i18n.actionPrimaryText).trigger('click'); - expect(submitSpy).toHaveBeenCalled(); - - submitSpy.mockRestore(); + expect(wrapper.vm.$refs.form.$el.submit).toHaveBeenCalled(); }); it('Changes the start_branch input value', async () => { @@ -164,7 +163,7 @@ describe('CommitFormModal', () => { }); it('Changes the target_project_id input value', async () => { - createComponent(shallowMount, {}, {}, { isCherryPick: true }); + createComponent({ propsData: { isCherryPick: true } }); findProjectsDropdown().vm.$emit('input', '_changed_project_value_'); await nextTick(); @@ -174,12 +173,9 @@ describe('CommitFormModal', () => { }); it('action primary button triggers Redis HLL tracking api call', async () => { - createComponent(mount, {}, {}, { primaryActionEventName: 'test_event' }); - + createComponent({ method: mountExtended, propsData: { primaryActionEventName: 'test_event' } }); await nextTick(); - jest.spyOn(findForm().element, 'submit'); - getByText(mockData.modalPropsData.i18n.actionPrimaryText).trigger('click'); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_event'); diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js index d48f9fd6fc0..adb87142fee 100644 --- a/spec/frontend/projects/commit/store/actions_spec.js +++ b/spec/frontend/projects/commit/store/actions_spec.js @@ -63,7 +63,7 @@ describe('Commit form modal store actions', () => { ); }); - it('should show alert error and set error in state on fetchBranches failure', async () => { + it('should show an alert and set error in state on fetchBranches failure', async () => { jest.spyOn(axios, 'get').mockRejectedValue(); await testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }]); diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js index ff1d860fd53..b06463e73a7 100644 --- a/spec/frontend/projects/commits/components/author_select_spec.js +++ b/spec/frontend/projects/commits/components/author_select_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import setWindowLocation from 'helpers/set_window_location_helper'; import * as urlUtility from '~/lib/utils/url_utility'; import AuthorSelect from '~/projects/commits/components/author_select.vue'; import { createStore } from '~/projects/commits/store'; @@ -64,39 +65,23 @@ describe('Author Select', () => { const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); describe('user is searching via "filter by commit message"', () => { - it('disables dropdown container', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ hasSearchParam: true }); + beforeEach(() => { + setWindowLocation(`?search=foo`); + createComponent(); + }); - await nextTick(); + it('does not disable dropdown container', () => { expect(findDropdownContainer().attributes('disabled')).toBeUndefined(); }); - it('has correct tooltip message', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ hasSearchParam: true }); - - await nextTick(); + it('has correct tooltip message', () => { expect(findDropdownContainer().attributes('title')).toBe( 'Searching by both author and message is currently not supported.', ); }); - it('disables dropdown', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ hasSearchParam: false }); - - await nextTick(); - expect(findDropdown().attributes('disabled')).toBeUndefined(); - }); - - it('hasSearchParam if user types a truthy string', () => { - wrapper.vm.setSearchParam('false'); - - expect(wrapper.vm.hasSearchParam).toBe(true); + it('disables dropdown', () => { + expect(findDropdown().attributes('disabled')).toBe('true'); }); }); @@ -106,9 +91,8 @@ describe('Author Select', () => { }); it('displays the current selected author', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ currentAuthor }); + setWindowLocation(`?author=${currentAuthor}`); + createComponent(); await nextTick(); expect(findDropdown().attributes('text')).toBe(currentAuthor); @@ -146,12 +130,14 @@ describe('Author Select', () => { expect(findDropdownItems().at(0).text()).toBe('Any Author'); }); - it('displays the project authors', async () => { - await nextTick(); + it('displays the project authors', () => { expect(findDropdownItems()).toHaveLength(authors.length + 1); }); it('has the correct props', async () => { + setWindowLocation(`?author=${currentAuthor}`); + createComponent(); + const [{ avatar_url: avatarUrl, username }] = authors; const result = { avatarUrl, @@ -159,16 +145,11 @@ describe('Author Select', () => { isChecked: true, }; - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ currentAuthor }); - await nextTick(); expect(findDropdownItems().at(1).props()).toEqual(expect.objectContaining(result)); }); - it("display the author's name", async () => { - await nextTick(); + it("display the author's name", () => { expect(findDropdownItems().at(1).text()).toBe(currentAuthor); }); diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js index f5184e59420..8afa2a6fb8f 100644 --- a/spec/frontend/projects/commits/store/actions_spec.js +++ b/spec/frontend/projects/commits/store/actions_spec.js @@ -34,7 +34,7 @@ describe('Project commits actions', () => { ])); }); - describe('shows an alert message when there is an error', () => { + describe('shows an alert when there is an error', () => { it('creates an alert', () => { const mockDispatchContext = { dispatch: () => {}, commit: () => {}, state }; actions.receiveAuthorsError(mockDispatchContext); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js index 645d0483a5f..e289569f8ce 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -90,7 +90,7 @@ describe('RevisionDropdown component', () => { expect(findTagsDropdownItem()).toHaveLength(0); }); - it('shows alert message on error', async () => { + it('shows an alert on error', async () => { axiosMock.onGet('some/invalid/path').replyOnce(HTTP_STATUS_NOT_FOUND); await waitForPromises(); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js index 3a256682549..1cf99f16601 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js @@ -78,7 +78,7 @@ describe('RevisionDropdown component', () => { }); }); - it('shows alert message on error', async () => { + it('shows an alert on error', async () => { axiosMock.onGet('some/invalid/path').replyOnce(HTTP_STATUS_NOT_FOUND); createComponent(); @@ -104,7 +104,7 @@ describe('RevisionDropdown component', () => { }); describe('search', () => { - it('shows alert message on error', async () => { + it('shows alert on error', async () => { axiosMock.onGet('some/invalid/path').replyOnce(HTTP_STATUS_NOT_FOUND); createComponent(); diff --git a/spec/frontend/projects/new/components/app_spec.js b/spec/frontend/projects/new/components/app_spec.js index 5b2dc25077e..16576523c66 100644 --- a/spec/frontend/projects/new/components/app_spec.js +++ b/spec/frontend/projects/new/components/app_spec.js @@ -7,7 +7,7 @@ describe('Experimental new project creation app', () => { const createComponent = (propsData) => { wrapper = shallowMount(App, { - propsData: { projectsUrl: '/dashboard/projects', ...propsData }, + propsData: { rootPath: '/', projectsUrl: '/dashboard/projects', ...propsData }, }); }; @@ -45,6 +45,7 @@ describe('Experimental new project creation app', () => { createComponent(); expect(findNewNamespacePage().props('initialBreadcrumbs')).toEqual([ + { href: '/', text: 'Your work' }, { href: '/dashboard/projects', text: 'Projects' }, { href: '#', text: 'New project' }, ]); diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js index fa720f4487c..ceac4435282 100644 --- a/spec/frontend/projects/new/components/new_project_url_select_spec.js +++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js @@ -247,7 +247,7 @@ describe('NewProjectUrlSelect component', () => { eventHub.$emit('select-template', getIdFromGraphQLId(id), fullPath); }); - it('filters the dropdown items to the selected group and children', async () => { + it('filters the dropdown items to the selected group and children', () => { const listItems = wrapper.findAll('li'); expect(listItems).toHaveLength(3); diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap index 1545c52d7cb..61bcd44724c 100644 --- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap +++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap @@ -17,6 +17,7 @@ exports[`CiCdAnalyticsAreaChart matches the snapshot 1`] = ` legendlayout="inline" legendmaxtext="Max" legendmintext="Min" + legendseriesinfo="" option="[object Object]" responsive="" thresholds="" diff --git a/spec/frontend/projects/settings/components/transfer_project_form_spec.js b/spec/frontend/projects/settings/components/transfer_project_form_spec.js index d8c2cf83f38..a92ac1bed9d 100644 --- a/spec/frontend/projects/settings/components/transfer_project_form_spec.js +++ b/spec/frontend/projects/settings/components/transfer_project_form_spec.js @@ -64,17 +64,17 @@ describe('Transfer project form', () => { expect(findTransferLocations().props('value')).toEqual(selectedItem); }); - it('emits the `selectTransferLocation` event when a namespace is selected', async () => { + it('emits the `selectTransferLocation` event when a namespace is selected', () => { const args = [selectedItem.id]; expect(wrapper.emitted('selectTransferLocation')).toEqual([args]); }); - it('enables the confirm button', async () => { + it('enables the confirm button', () => { expect(findConfirmDanger().attributes('disabled')).toBeUndefined(); }); - it('clicking the confirm button emits the `confirm` event', async () => { + it('clicking the confirm button emits the `confirm` event', () => { findConfirmDanger().vm.$emit('confirm'); expect(wrapper.emitted('confirm')).toBeDefined(); diff --git a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js index 4b94c179f74..b2c03352cdc 100644 --- a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js +++ b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js @@ -83,7 +83,7 @@ describe('TopicsTokenSelector', () => { }); }); - it('passes topic title to the avatar', async () => { + it('passes topic title to the avatar', () => { createComponent(); const avatars = findAllAvatars(); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index 4d0d2191176..86e4e88e3cf 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -78,7 +78,7 @@ describe('ServiceDeskRoot', () => { const alertBodyLink = alertEl.findComponent(GlLink); expect(alertBodyLink.exists()).toBe(true); expect(alertBodyLink.attributes('href')).toBe( - '/help/user/project/service_desk.html#using-a-custom-email-address', + '/help/user/project/service_desk.html#use-a-custom-email-address', ); expect(alertBodyLink.text()).toBe('How do I create a custom email address?'); }); @@ -147,7 +147,7 @@ describe('ServiceDeskRoot', () => { await waitForPromises(); }); - it('sends a request to update template', async () => { + it('sends a request to update template', () => { expect(spy).toHaveBeenCalledWith(provideData.endpoint, { issue_template_key: 'Bug', outgoing_name: 'GitLab Support Bot', diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js index 3852f2678b7..823caec0211 100644 --- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js @@ -1,5 +1,6 @@ +import prometheusIntegration from 'test_fixtures/integrations/prometheus/prometheus_integration.html'; import MockAdapter from 'axios-mock-adapter'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import PANEL_STATE from '~/prometheus_metrics/constants'; @@ -7,7 +8,6 @@ import CustomMetrics from '~/prometheus_metrics/custom_metrics'; import { metrics1 as metrics } from './mock_data'; describe('PrometheusMetrics', () => { - const FIXTURE = 'integrations/prometheus/prometheus_integration.html'; const customMetricsEndpoint = 'http://test.host/frontend-fixtures/integrations-project/prometheus/metrics'; let mock; @@ -17,7 +17,7 @@ describe('PrometheusMetrics', () => { mock.onGet(customMetricsEndpoint).reply(HTTP_STATUS_OK, { metrics, }); - loadHTMLFixture(FIXTURE); + setHTMLFixture(prometheusIntegration); }); afterEach(() => { diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js index 45654d6a2eb..bc796ac53ca 100644 --- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js @@ -1,5 +1,6 @@ +import prometheusIntegration from 'test_fixtures/integrations/prometheus/prometheus_integration.html'; import MockAdapter from 'axios-mock-adapter'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -8,10 +9,8 @@ import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; import { metrics2 as metrics, missingVarMetrics } from './mock_data'; describe('PrometheusMetrics', () => { - const FIXTURE = 'integrations/prometheus/prometheus_integration.html'; - beforeEach(() => { - loadHTMLFixture(FIXTURE); + setHTMLFixture(prometheusIntegration); }); describe('constructor', () => { diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index 4141d000a1c..e1966908452 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -115,7 +115,7 @@ describe('ProtectedBranchEdit', () => { }); describe('when clicked', () => { - beforeEach(async () => { + beforeEach(() => { mock .onPatch(TEST_URL, { protected_branch: { [patchParam]: true } }) .replyOnce(HTTP_STATUS_OK, {}); diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js index 9eddc50d50a..e45405088b1 100644 --- a/spec/frontend/read_more_spec.js +++ b/spec/frontend/read_more_spec.js @@ -42,7 +42,7 @@ describe('Read more click-to-expand functionality', () => { nestedElement.click(); }); - it('removes the trigger element', async () => { + it('removes the trigger element', () => { expect(findTrigger()).toBe(null); }); }); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 6b90827f9c2..5e15ba26ece 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -35,6 +35,7 @@ describe('Ref selector component', () => { const projectId = '8'; const totalBranchesCount = 123; const totalTagsCount = 456; + const queryParams = { sort: 'updated_desc' }; let wrapper; let branchesApiCallSpy; @@ -738,4 +739,25 @@ describe('Ref selector component', () => { expect(lastCallProps.matches).toMatchObject(expectedMatches); }); }); + describe('when queryParam prop is present', () => { + it('passes params to a branches API call', () => { + createComponent({ propsData: { queryParams } }); + + return waitForRequests().then(() => { + expect(branchesApiCallSpy).toHaveBeenCalledWith( + expect.objectContaining({ params: { per_page: 20, search: '', sort: queryParams.sort } }), + ); + }); + }); + + it('does not pass params to tags API call', () => { + createComponent({ propsData: { queryParams } }); + + return waitForRequests().then(() => { + expect(tagsApiCallSpy).toHaveBeenCalledWith( + expect.objectContaining({ params: { per_page: 20, search: '' } }), + ); + }); + }); + }); }); diff --git a/spec/frontend/ref/stores/actions_spec.js b/spec/frontend/ref/stores/actions_spec.js index 099ce062a3a..c6aac8c9c98 100644 --- a/spec/frontend/ref/stores/actions_spec.js +++ b/spec/frontend/ref/stores/actions_spec.js @@ -52,6 +52,13 @@ describe('Ref selector Vuex store actions', () => { }); }); + describe('setParams', () => { + it(`commits ${types.SET_PARAMS} with the provided params`, () => { + const params = { sort: 'updated_asc' }; + testAction(actions.setParams, params, state, [{ type: types.SET_PARAMS, payload: params }]); + }); + }); + describe('search', () => { it(`commits ${types.SET_QUERY} with the new search query`, () => { const query = 'hello'; diff --git a/spec/frontend/ref/stores/mutations_spec.js b/spec/frontend/ref/stores/mutations_spec.js index 37eee18dc10..8f16317b751 100644 --- a/spec/frontend/ref/stores/mutations_spec.js +++ b/spec/frontend/ref/stores/mutations_spec.js @@ -34,6 +34,7 @@ describe('Ref selector Vuex store mutations', () => { error: null, }, }, + params: null, selectedRef: null, requestCount: 0, }); @@ -56,6 +57,15 @@ describe('Ref selector Vuex store mutations', () => { }); }); + describe(`${types.SET_PARAMS}`, () => { + it('sets the additional query params', () => { + const params = { sort: 'updated_desc' }; + mutations[types.SET_PARAMS](state, params); + + expect(state.params).toBe(params); + }); + }); + describe(`${types.SET_PROJECT_ID}`, () => { it('updates the project ID', () => { const newProjectId = '4'; diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index dcb6a3293a6..d253c42e03f 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -31,6 +31,8 @@ describe('Release edit/new component', () => { let actions; let getters; let state; + let refActions; + let refState; let mock; const factory = async ({ featureFlags = {}, store: storeUpdates = {} } = {}) => { @@ -62,6 +64,20 @@ describe('Release edit/new component', () => { tagNameValidation: new ValidationResult(), }), formattedReleaseNotes: () => 'these notes are formatted', + isCreating: jest.fn(), + isSearching: jest.fn(), + isExistingTag: jest.fn(), + isNewTag: jest.fn(), + }; + + refState = { + matches: [], + }; + + refActions = { + setEnabledRefTypes: jest.fn(), + setProjectId: jest.fn(), + search: jest.fn(), }; const store = new Vuex.Store( @@ -74,6 +90,11 @@ describe('Release edit/new component', () => { state, getters, }, + ref: { + namespaced: true, + actions: refActions, + state: refState, + }, }, }, storeUpdates, diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 7a0e9fb7326..2aa36056735 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -412,7 +412,7 @@ describe('app_index.vue', () => { await createComponent(); }); - it('shows a toast', async () => { + it('shows a toast', () => { expect(toast).toHaveBeenCalledWith( sprintf(__('Release %{release} has been successfully deleted.'), { release, @@ -420,7 +420,7 @@ describe('app_index.vue', () => { ); }); - it('clears session storage', async () => { + it('clears session storage', () => { expect(window.sessionStorage.getItem(key)).toBe(null); }); }); diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js index 92199896ab4..76907b4b8bb 100644 --- a/spec/frontend/releases/components/releases_sort_spec.js +++ b/spec/frontend/releases/components/releases_sort_spec.js @@ -1,5 +1,6 @@ import { GlSorting, GlSortingItem } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { assertProps } from 'helpers/assert_props'; import ReleasesSort from '~/releases/components/releases_sort.vue'; import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; @@ -92,7 +93,7 @@ describe('releases_sort.vue', () => { describe('prop validation', () => { it('validates that the `value` prop is one of the expected sort strings', () => { expect(() => { - createComponent('not a valid value'); + assertProps(ReleasesSort, { value: 'not a valid value' }); }).toThrow('Invalid prop: custom validator check failed'); }); }); diff --git a/spec/frontend/releases/components/tag_create_spec.js b/spec/frontend/releases/components/tag_create_spec.js new file mode 100644 index 00000000000..0df2483bcf2 --- /dev/null +++ b/spec/frontend/releases/components/tag_create_spec.js @@ -0,0 +1,107 @@ +import { GlButton, GlFormInput, GlFormTextarea } from '@gitlab/ui'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { __, s__ } from '~/locale'; +import TagCreate from '~/releases/components/tag_create.vue'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import createStore from '~/releases/stores'; +import createEditNewModule from '~/releases/stores/modules/edit_new'; +import { createRefModule } from '~/ref/stores'; + +const TEST_PROJECT_ID = '1234'; + +const VALUE = 'new-tag'; + +describe('releases/components/tag_create', () => { + let store; + let wrapper; + let mock; + + const createWrapper = () => { + wrapper = shallowMount(TagCreate, { + store, + propsData: { value: VALUE }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + store = createStore({ + modules: { + editNew: createEditNewModule({ + projectId: TEST_PROJECT_ID, + }), + ref: createRefModule(), + }, + }); + store.state.editNew.release = { + tagMessage: 'test', + }; + store.state.editNew.createFrom = 'v1'; + createWrapper(); + }); + + afterEach(() => mock.restore()); + + const findTagInput = () => wrapper.findComponent(GlFormInput); + const findTagRef = () => wrapper.findComponent(RefSelector); + const findTagMessage = () => wrapper.findComponent(GlFormTextarea); + const findSave = () => wrapper.findAllComponents(GlButton).at(-2); + const findCancel = () => wrapper.findAllComponents(GlButton).at(-1); + + it('initializes the input with value prop', () => { + expect(findTagInput().attributes('value')).toBe(VALUE); + }); + + it('emits a change event when the tag name chagnes', () => { + findTagInput().vm.$emit('input', 'new-value'); + + expect(wrapper.emitted('change')).toEqual([['new-value']]); + }); + + it('binds the store value to the ref selector', () => { + const ref = findTagRef(); + expect(ref.props('value')).toBe('v1'); + + ref.vm.$emit('input', 'v2'); + + expect(ref.props('value')).toBe('v1'); + }); + + it('passes the project id tot he ref selector', () => { + expect(findTagRef().props('projectId')).toBe(TEST_PROJECT_ID); + }); + + it('binds the store value to the message', async () => { + const message = findTagMessage(); + expect(message.attributes('value')).toBe('test'); + + message.vm.$emit('input', 'hello'); + + await nextTick(); + + expect(message.attributes('value')).toBe('hello'); + }); + + it('emits create event when Save is clicked', () => { + const button = findSave(); + + expect(button.text()).toBe(__('Save')); + + button.vm.$emit('click'); + + expect(wrapper.emitted('create')).toEqual([[]]); + }); + + it('emits cancel event when Select another tag is clicked', () => { + const button = findCancel(); + + expect(button.text()).toBe(s__('Release|Select another tag')); + + button.vm.$emit('click'); + + expect(wrapper.emitted('cancel')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index 2508495429c..3468338b8a7 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -1,18 +1,19 @@ -import { GlDropdownItem, GlFormGroup, GlSprintf } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import { GlFormGroup, GlDropdown, GlPopover } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; -import { trimText } from 'helpers/text_helper'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; import TagFieldNew from '~/releases/components/tag_field_new.vue'; +import TagSearch from '~/releases/components/tag_search.vue'; +import TagCreate from '~/releases/components/tag_create.vue'; import createStore from '~/releases/stores'; import createEditNewModule from '~/releases/stores/modules/edit_new'; +import { CREATE } from '~/releases/stores/modules/edit_new/constants'; +import { createRefModule } from '~/ref/stores'; import { i18n } from '~/releases/constants'; const TEST_TAG_NAME = 'test-tag-name'; -const TEST_TAG_MESSAGE = 'Test tag message'; const TEST_PROJECT_ID = '1234'; const TEST_CREATE_FROM = 'test-create-from'; const NONEXISTENT_TAG_NAME = 'nonexistent-tag'; @@ -21,38 +22,12 @@ describe('releases/components/tag_field_new', () => { let store; let wrapper; let mock; - let RefSelectorStub; - - const createComponent = ( - mountFn = shallowMount, - { searchQuery } = { searchQuery: NONEXISTENT_TAG_NAME }, - ) => { - // A mock version of the RefSelector component that just renders the - // #footer slot, so that the content inside this slot can be tested. - RefSelectorStub = Vue.component('RefSelectorStub', { - data() { - return { - footerSlotProps: { - isLoading: false, - matches: { - tags: { - totalCount: 1, - list: [{ name: TEST_TAG_NAME }], - }, - }, - query: searchQuery, - }, - }; - }, - template: '<div><slot name="footer" v-bind="footerSlotProps"></slot></div>', - }); - wrapper = mountFn(TagFieldNew, { + const createComponent = () => { + wrapper = shallowMountExtended(TagFieldNew, { store, stubs: { - RefSelector: RefSelectorStub, GlFormGroup, - GlSprintf, }, }); }; @@ -63,11 +38,12 @@ describe('releases/components/tag_field_new', () => { editNew: createEditNewModule({ projectId: TEST_PROJECT_ID, }), + ref: createRefModule(), }, }); store.state.editNew.createFrom = TEST_CREATE_FROM; - store.state.editNew.showCreateFrom = true; + store.state.editNew.step = CREATE; store.state.editNew.release = { tagName: TEST_TAG_NAME, @@ -81,20 +57,13 @@ describe('releases/components/tag_field_new', () => { gon.api_version = 'v4'; }); - afterEach(() => { - mock.restore(); - }); - - const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]'); - const findTagNameDropdown = () => findTagNameFormGroup().findComponent(RefSelectorStub); + afterEach(() => mock.restore()); - const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]'); - const findCreateFromDropdown = () => findCreateFromFormGroup().findComponent(RefSelectorStub); - - const findCreateNewTagOption = () => wrapper.findComponent(GlDropdownItem); - - const findAnnotatedTagMessageFormGroup = () => - wrapper.find('[data-testid="annotated-tag-message-field"]'); + const findTagNameFormGroup = () => wrapper.findComponent(GlFormGroup); + const findTagNameInput = () => wrapper.findComponent(GlDropdown); + const findTagNamePopover = () => wrapper.findComponent(GlPopover); + const findTagNameSearch = () => wrapper.findComponent(TagSearch); + const findTagNameCreate = () => wrapper.findComponent(TagCreate); describe('"Tag name" field', () => { describe('rendering and behavior', () => { @@ -102,20 +71,37 @@ describe('releases/components/tag_field_new', () => { it('renders a label', () => { expect(findTagNameFormGroup().attributes().label).toBe(__('Tag name')); - expect(findTagNameFormGroup().props().labelDescription).toBe(__('*Required')); + expect(findTagNameFormGroup().props().optionalText).toBe(__('(required)')); + }); + + it('flips between search and create, passing the searched value', async () => { + let create = findTagNameCreate(); + let search = findTagNameSearch(); + + expect(create.exists()).toBe(true); + expect(search.exists()).toBe(false); + + await create.vm.$emit('cancel'); + + search = findTagNameSearch(); + expect(create.exists()).toBe(false); + expect(search.exists()).toBe(true); + + await search.vm.$emit('create', TEST_TAG_NAME); + + create = findTagNameCreate(); + expect(create.exists()).toBe(true); + expect(create.props('value')).toBe(TEST_TAG_NAME); + expect(search.exists()).toBe(false); }); describe('when the user selects a new tag name', () => { - beforeEach(async () => { - findCreateNewTagOption().vm.$emit('click'); - }); + it("updates the store's release.tagName property", async () => { + findTagNameCreate().vm.$emit('change', NONEXISTENT_TAG_NAME); + await findTagNameCreate().vm.$emit('create'); - it("updates the store's release.tagName property", () => { expect(store.state.editNew.release.tagName).toBe(NONEXISTENT_TAG_NAME); - }); - - it('hides the "Create from" field', () => { - expect(findCreateFromFormGroup().exists()).toBe(true); + expect(findTagNameInput().props('text')).toBe(NONEXISTENT_TAG_NAME); }); }); @@ -123,19 +109,17 @@ describe('releases/components/tag_field_new', () => { const updatedTagName = 'updated-tag-name'; beforeEach(async () => { - findTagNameDropdown().vm.$emit('input', updatedTagName); + await findTagNameCreate().vm.$emit('cancel'); + findTagNameSearch().vm.$emit('select', updatedTagName); }); it("updates the store's release.tagName property", () => { expect(store.state.editNew.release.tagName).toBe(updatedTagName); + expect(findTagNameInput().props('text')).toBe(updatedTagName); }); it('hides the "Create from" field', () => { - expect(findCreateFromFormGroup().exists()).toBe(false); - }); - - it('hides the "Tag message" field', () => { - expect(findAnnotatedTagMessageFormGroup().exists()).toBe(false); + expect(findTagNameCreate().exists()).toBe(false); }); it('fetches the release notes for the tag', () => { @@ -145,131 +129,66 @@ describe('releases/components/tag_field_new', () => { }); }); - describe('"Create tag" option', () => { - describe('when the search query exactly matches one of the search results', () => { - beforeEach(async () => { - createComponent(mount, { searchQuery: TEST_TAG_NAME }); - }); - - it('does not show the "Create tag" option', () => { - expect(findCreateNewTagOption().exists()).toBe(false); - }); - }); - - describe('when the search query does not exactly match one of the search results', () => { - beforeEach(async () => { - createComponent(mount, { searchQuery: NONEXISTENT_TAG_NAME }); - }); - - it('shows the "Create tag" option', () => { - expect(findCreateNewTagOption().exists()).toBe(true); - }); - }); - }); - describe('validation', () => { beforeEach(() => { - createComponent(mount); + createComponent(); + findTagNameCreate().vm.$emit('cancel'); }); /** * Utility function to test the visibility of the validation message - * @param {'shown' | 'hidden'} state The expected state of the validation message. - * Should be passed either 'shown' or 'hidden' + * @param {boolean} isShown Whether or not the message is shown. */ - const expectValidationMessageToBe = async (state) => { + const expectValidationMessageToBeShown = async (isShown) => { await nextTick(); - expect(findTagNameFormGroup().element).toHaveClass( - state === 'shown' ? 'is-invalid' : 'is-valid', - ); - expect(findTagNameFormGroup().element).not.toHaveClass( - state === 'shown' ? 'is-valid' : 'is-invalid', - ); + const state = findTagNameFormGroup().attributes('state'); + + if (isShown) { + expect(state).toBeUndefined(); + } else { + expect(state).toBe('true'); + } }; describe('when the user has not yet interacted with the component', () => { it('does not display a validation error', async () => { - findTagNameDropdown().vm.$emit('input', ''); - - await expectValidationMessageToBe('hidden'); + await expectValidationMessageToBeShown(false); }); }); describe('when the user has interacted with the component and the value is not empty', () => { it('does not display validation error', async () => { - findTagNameDropdown().vm.$emit('hide'); + findTagNameSearch().vm.$emit('select', 'vTest'); + findTagNamePopover().vm.$emit('hide'); - await expectValidationMessageToBe('hidden'); + await expectValidationMessageToBeShown(false); }); it('displays a validation error if the tag has an associated release', async () => { - findTagNameDropdown().vm.$emit('input', 'vTest'); - findTagNameDropdown().vm.$emit('hide'); + findTagNameSearch().vm.$emit('select', 'vTest'); + findTagNamePopover().vm.$emit('hide'); store.state.editNew.existingRelease = {}; - await expectValidationMessageToBe('shown'); - expect(findTagNameFormGroup().text()).toContain(i18n.tagIsAlredyInUseMessage); + await expectValidationMessageToBeShown(true); + expect(findTagNameFormGroup().attributes('invalidfeedback')).toBe( + i18n.tagIsAlredyInUseMessage, + ); }); }); describe('when the user has interacted with the component and the value is empty', () => { it('displays a validation error', async () => { - findTagNameDropdown().vm.$emit('input', ''); - findTagNameDropdown().vm.$emit('hide'); + findTagNameSearch().vm.$emit('select', ''); + findTagNamePopover().vm.$emit('hide'); - await expectValidationMessageToBe('shown'); - expect(findTagNameFormGroup().text()).toContain(i18n.tagNameIsRequiredMessage); + await expectValidationMessageToBeShown(true); + expect(findTagNameFormGroup().attributes('invalidfeedback')).toContain( + i18n.tagNameIsRequiredMessage, + ); }); }); }); }); - - describe('"Create from" field', () => { - beforeEach(() => createComponent()); - - it('renders a label', () => { - expect(findCreateFromFormGroup().attributes().label).toBe('Create from'); - }); - - describe('when the user selects a git ref', () => { - it("updates the store's createFrom property", async () => { - const updatedCreateFrom = 'update-create-from'; - findCreateFromDropdown().vm.$emit('input', updatedCreateFrom); - - expect(store.state.editNew.createFrom).toBe(updatedCreateFrom); - }); - }); - }); - - describe('"Annotated Tag" field', () => { - beforeEach(() => { - createComponent(mountExtended); - }); - - it('renders a label', () => { - expect(wrapper.findByRole('textbox', { name: 'Set tag message' }).exists()).toBe(true); - }); - - it('renders a description', () => { - expect(trimText(findAnnotatedTagMessageFormGroup().text())).toContain( - 'Add a message to the tag. Leaving this blank creates a lightweight tag.', - ); - }); - - it('updates the store', async () => { - await findAnnotatedTagMessageFormGroup().find('textarea').setValue(TEST_TAG_MESSAGE); - - expect(store.state.editNew.release.tagMessage).toBe(TEST_TAG_MESSAGE); - }); - - it('shows a link', () => { - const link = wrapper.findByRole('link', { - name: 'lightweight tag', - }); - - expect(link.attributes('href')).toBe('https://git-scm.com/book/en/v2/Git-Basics-Tagging/'); - }); - }); }); diff --git a/spec/frontend/releases/components/tag_search_spec.js b/spec/frontend/releases/components/tag_search_spec.js new file mode 100644 index 00000000000..4144a9cc297 --- /dev/null +++ b/spec/frontend/releases/components/tag_search_spec.js @@ -0,0 +1,144 @@ +import { GlButton, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { DEFAULT_PER_PAGE } from '~/api'; +import { __, s__, sprintf } from '~/locale'; +import TagSearch from '~/releases/components/tag_search.vue'; +import createStore from '~/releases/stores'; +import createEditNewModule from '~/releases/stores/modules/edit_new'; +import { createRefModule } from '~/ref/stores'; + +const TEST_TAG_NAME = 'test-tag-name'; +const TEST_PROJECT_ID = '1234'; +const TAGS = [{ name: 'v1' }, { name: 'v2' }, { name: 'v3' }]; + +describe('releases/components/tag_search', () => { + let store; + let wrapper; + let mock; + + const createWrapper = (propsData = {}) => { + wrapper = mount(TagSearch, { + store, + propsData, + }); + }; + + beforeEach(() => { + store = createStore({ + modules: { + editNew: createEditNewModule({ + projectId: TEST_PROJECT_ID, + }), + ref: createRefModule(), + }, + }); + + store.state.editNew.release = {}; + + mock = new MockAdapter(axios); + gon.api_version = 'v4'; + }); + + afterEach(() => mock.restore()); + + const findSearch = () => wrapper.findComponent(GlSearchBoxByType); + const findCreate = () => wrapper.findAllComponents(GlButton).at(-1); + const findResults = () => wrapper.findAllComponents(GlDropdownItem); + + describe('init', () => { + beforeEach(async () => { + mock + .onGet(`/api/v4/projects/${TEST_PROJECT_ID}/repository/tags`) + .reply(200, TAGS, { 'x-total': TAGS.length }); + + createWrapper(); + + await waitForPromises(); + }); + + it('displays a set of results immediately', () => { + findResults().wrappers.forEach((w, i) => expect(w.text()).toBe(TAGS[i].name)); + }); + + it('has a disabled button', () => { + const button = findCreate(); + expect(button.text()).toBe(s__('Release|Or type a new tag name')); + expect(button.props('disabled')).toBe(true); + }); + + it('has an empty search input', () => { + expect(findSearch().props('value')).toBe(''); + }); + + describe('searching', () => { + const query = TEST_TAG_NAME; + + beforeEach(async () => { + mock.reset(); + mock + .onGet(`/api/v4/projects/${TEST_PROJECT_ID}/repository/tags`, { + params: { search: query, per_page: DEFAULT_PER_PAGE }, + }) + .reply(200, [], { 'x-total': 0 }); + + findSearch().vm.$emit('input', query); + + await nextTick(); + await waitForPromises(); + }); + + it('shows "No results found" when there are no results', () => { + expect(wrapper.text()).toContain(__('No results found')); + }); + + it('searches with the given input', () => { + expect(mock.history.get[0].params.search).toBe(query); + }); + + it('emits the query', () => { + expect(wrapper.emitted('change')).toEqual([[query]]); + }); + }); + }); + + describe('with query', () => { + const query = TEST_TAG_NAME; + + beforeEach(async () => { + mock + .onGet(`/api/v4/projects/${TEST_PROJECT_ID}/repository/tags`, { + params: { search: query, per_page: DEFAULT_PER_PAGE }, + }) + .reply(200, TAGS, { 'x-total': TAGS.length }); + + createWrapper({ query }); + + await waitForPromises(); + }); + + it('displays a set of results immediately', () => { + findResults().wrappers.forEach((w, i) => expect(w.text()).toBe(TAGS[i].name)); + }); + + it('has an enabled button', () => { + const button = findCreate(); + expect(button.text()).toMatchInterpolatedText( + sprintf(s__('Release|Create tag %{tag}'), { tag: query }), + ); + expect(button.props('disabled')).toBe(false); + }); + + it('emits create event when button clicked', () => { + findCreate().vm.$emit('click'); + expect(wrapper.emitted('create')).toEqual([[query]]); + }); + + it('has an empty search input', () => { + expect(findSearch().props('value')).toBe(query); + }); + }); +}); diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js index 649e772f956..736eae13fb3 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -424,14 +424,27 @@ describe('Release edit/new getters', () => { describe('formattedReleaseNotes', () => { it.each` - description | includeTagNotes | tagNotes | included - ${'release notes'} | ${true} | ${'tag notes'} | ${true} - ${'release notes'} | ${true} | ${''} | ${false} - ${'release notes'} | ${false} | ${'tag notes'} | ${false} + description | includeTagNotes | tagNotes | included | showCreateFrom + ${'release notes'} | ${true} | ${'tag notes'} | ${true} | ${false} + ${'release notes'} | ${true} | ${''} | ${false} | ${false} + ${'release notes'} | ${false} | ${'tag notes'} | ${false} | ${false} + ${'release notes'} | ${true} | ${'tag notes'} | ${true} | ${true} + ${'release notes'} | ${true} | ${''} | ${false} | ${true} + ${'release notes'} | ${false} | ${'tag notes'} | ${false} | ${true} `( - 'should include tag notes=$included when includeTagNotes=$includeTagNotes and tagNotes=$tagNotes', - ({ description, includeTagNotes, tagNotes, included }) => { - const state = { release: { description }, includeTagNotes, tagNotes }; + 'should include tag notes=$included when includeTagNotes=$includeTagNotes and tagNotes=$tagNotes and showCreateFrom=$showCreateFrom', + ({ description, includeTagNotes, tagNotes, included, showCreateFrom }) => { + let state; + + if (showCreateFrom) { + state = { + release: { description, tagMessage: tagNotes }, + includeTagNotes, + showCreateFrom, + }; + } else { + state = { release: { description }, includeTagNotes, tagNotes, showCreateFrom }; + } const text = `### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`; if (included) { diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index 96dedd54126..2c63deb99c9 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -1,5 +1,6 @@ import { GlButton } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; @@ -26,7 +27,24 @@ const DEFAULT_INJECT = { describe('BlobButtonGroup component', () => { let wrapper; + let showUploadBlobModalMock; + let showDeleteBlobModalMock; + const createComponent = (props = {}) => { + showUploadBlobModalMock = jest.fn(); + showDeleteBlobModalMock = jest.fn(); + + const UploadBlobModalStub = stubComponent(UploadBlobModal, { + methods: { + show: showUploadBlobModalMock, + }, + }); + const DeleteBlobModalStub = stubComponent(DeleteBlobModal, { + methods: { + show: showDeleteBlobModalMock, + }, + }); + wrapper = mountExtended(BlobButtonGroup, { propsData: { ...DEFAULT_PROPS, @@ -35,6 +53,10 @@ describe('BlobButtonGroup component', () => { provide: { ...DEFAULT_INJECT, }, + stubs: { + UploadBlobModal: UploadBlobModalStub, + DeleteBlobModal: DeleteBlobModalStub, + }, }); }; @@ -57,8 +79,6 @@ describe('BlobButtonGroup component', () => { describe('buttons', () => { beforeEach(() => { createComponent(); - jest.spyOn(findUploadBlobModal().vm, 'show'); - jest.spyOn(findDeleteBlobModal().vm, 'show'); }); it('renders both the replace and delete button', () => { @@ -73,33 +93,31 @@ describe('BlobButtonGroup component', () => { it('triggers the UploadBlobModal from the replace button', () => { findReplaceButton().trigger('click'); - expect(findUploadBlobModal().vm.show).toHaveBeenCalled(); + expect(showUploadBlobModalMock).toHaveBeenCalled(); }); it('triggers the DeleteBlobModal from the delete button', () => { findDeleteButton().trigger('click'); - expect(findDeleteBlobModal().vm.show).toHaveBeenCalled(); + expect(showDeleteBlobModalMock).toHaveBeenCalled(); }); describe('showForkSuggestion set to true', () => { beforeEach(() => { createComponent({ showForkSuggestion: true }); - jest.spyOn(findUploadBlobModal().vm, 'show'); - jest.spyOn(findDeleteBlobModal().vm, 'show'); }); it('does not trigger the UploadBlobModal from the replace button', () => { findReplaceButton().trigger('click'); - expect(findUploadBlobModal().vm.show).not.toHaveBeenCalled(); + expect(showUploadBlobModalMock).not.toHaveBeenCalled(); expect(wrapper.emitted().fork).toHaveLength(1); }); it('does not trigger the DeleteBlobModal from the delete button', () => { findDeleteButton().trigger('click'); - expect(findDeleteBlobModal().vm.show).not.toHaveBeenCalled(); + expect(showDeleteBlobModalMock).not.toHaveBeenCalled(); expect(wrapper.emitted().fork).toHaveLength(1); }); }); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 8b7a7d91125..f4baa817d32 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -1,27 +1,60 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { GlDropdown } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; -import { nextTick } from 'vue'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; +import projectPathQuery from '~/repository/queries/project_path.query.graphql'; + +import createApolloProvider from 'helpers/mock_apollo_helper'; const defaultMockRoute = { name: 'blobPath', }; +const TEST_PROJECT_PATH = 'test-project/path'; + +Vue.use(VueApollo); + describe('Repository breadcrumbs component', () => { let wrapper; - - const factory = (currentPath, extraProps = {}, mockRoute = {}) => { - const $apollo = { - queries: { + let permissionsQuerySpy; + + const createPermissionsQueryResponse = ({ + pushCode = false, + forkProject = false, + createMergeRequestIn = false, + } = {}) => ({ + data: { + project: { + id: 1, + __typename: '__typename', userPermissions: { - loading: true, + __typename: '__typename', + pushCode, + forkProject, + createMergeRequestIn, }, }, - }; + }, + }); + + const factory = (currentPath, extraProps = {}, mockRoute = {}) => { + const apolloProvider = createApolloProvider([[permissionsQuery, permissionsQuerySpy]]); + + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: projectPathQuery, + data: { + projectPath: TEST_PROJECT_PATH, + }, + }); wrapper = shallowMount(Breadcrumbs, { + apolloProvider, propsData: { currentPath, ...extraProps, @@ -34,13 +67,29 @@ describe('Repository breadcrumbs component', () => { defaultMockRoute, ...mockRoute, }, - $apollo, }, }); }; + const findDropdown = () => wrapper.findComponent(GlDropdown); const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); const findNewDirectoryModal = () => wrapper.findComponent(NewDirectoryModal); + const findRouterLink = () => wrapper.findAllComponents(RouterLinkStub); + + beforeEach(() => { + permissionsQuerySpy = jest.fn().mockResolvedValue(createPermissionsQueryResponse()); + }); + + it('queries for permissions', async () => { + factory('/'); + + // We need to wait for the projectPath query to resolve + await waitForPromises(); + + expect(permissionsQuerySpy).toHaveBeenCalledWith({ + projectPath: TEST_PROJECT_PATH, + }); + }); it.each` path | linkCount @@ -51,7 +100,7 @@ describe('Repository breadcrumbs component', () => { `('renders $linkCount links for path $path', ({ path, linkCount }) => { factory(path); - expect(wrapper.findAllComponents(RouterLinkStub).length).toEqual(linkCount); + expect(findRouterLink().length).toEqual(linkCount); }); it.each` @@ -64,36 +113,27 @@ describe('Repository breadcrumbs component', () => { 'links to the correct router path when routeName is $routeName', ({ routeName, path, linkTo }) => { factory(path, {}, { name: routeName }); - expect(wrapper.findAllComponents(RouterLinkStub).at(3).props('to')).toEqual(linkTo); + expect(findRouterLink().at(3).props('to')).toEqual(linkTo); }, ); it('escapes hash in directory path', () => { factory('app/assets/javascripts#'); - expect(wrapper.findAllComponents(RouterLinkStub).at(3).props('to')).toEqual( - '/-/tree/app/assets/javascripts%23', - ); + expect(findRouterLink().at(3).props('to')).toEqual('/-/tree/app/assets/javascripts%23'); }); it('renders last link as active', () => { factory('app/assets'); - expect(wrapper.findAllComponents(RouterLinkStub).at(2).attributes('aria-current')).toEqual( - 'page', - ); + expect(findRouterLink().at(2).attributes('aria-current')).toEqual('page'); }); it('does not render add to tree dropdown when permissions are false', async () => { - factory('/', { canCollaborate: false }); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); - + factory('/', { canCollaborate: false }, {}); await nextTick(); - expect(wrapper.findComponent(GlDropdown).exists()).toBe(false); + expect(findDropdown().exists()).toBe(false); }); it.each` @@ -107,20 +147,19 @@ describe('Repository breadcrumbs component', () => { 'does render add to tree dropdown $isRendered when route is $routeName', ({ routeName, isRendered }) => { factory('app/assets/javascripts.js', { canCollaborate: true }, { name: routeName }); - expect(wrapper.findComponent(GlDropdown).exists()).toBe(isRendered); + expect(findDropdown().exists()).toBe(isRendered); }, ); it('renders add to tree dropdown when permissions are true', async () => { - factory('/', { canCollaborate: true }); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); + permissionsQuerySpy.mockResolvedValue( + createPermissionsQueryResponse({ forkProject: true, createMergeRequestIn: true }), + ); + factory('/', { canCollaborate: true }); await nextTick(); - expect(wrapper.findComponent(GlDropdown).exists()).toBe(true); + expect(findDropdown().exists()).toBe(true); }); describe('renders the upload blob modal', () => { @@ -133,10 +172,6 @@ describe('Repository breadcrumbs component', () => { }); it('renders the modal once loaded', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); - await nextTick(); expect(findUploadBlobModal().exists()).toBe(true); @@ -152,10 +187,6 @@ describe('Repository breadcrumbs component', () => { }); it('renders the modal once loaded', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); - await nextTick(); expect(findNewDirectoryModal().exists()).toBe(true); diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js index 9ca45bfb655..90f2150222c 100644 --- a/spec/frontend/repository/components/delete_blob_modal_spec.js +++ b/spec/frontend/repository/components/delete_blob_modal_spec.js @@ -182,13 +182,13 @@ describe('DeleteBlobModal', () => { await fillForm({ targetText: '', commitText: '' }); }); - it('disables submit button', async () => { + it('disables submit button', () => { expect(findModal().props('actionPrimary').attributes).toEqual( expect.objectContaining({ disabled: true }), ); }); - it('does not submit form', async () => { + it('does not submit form', () => { findModal().vm.$emit('primary', { preventDefault: () => {} }); expect(submitSpy).not.toHaveBeenCalled(); }); @@ -202,13 +202,13 @@ describe('DeleteBlobModal', () => { }); }); - it('enables submit button', async () => { + it('enables submit button', () => { expect(findModal().props('actionPrimary').attributes).toEqual( expect.objectContaining({ disabled: false }), ); }); - it('submits form', async () => { + it('submits form', () => { findModal().vm.$emit('primary', { preventDefault: () => {} }); expect(submitSpy).toHaveBeenCalled(); }); diff --git a/spec/frontend/repository/components/fork_info_spec.js b/spec/frontend/repository/components/fork_info_spec.js index 7a2b03a8d8f..8521f91a6c7 100644 --- a/spec/frontend/repository/components/fork_info_spec.js +++ b/spec/frontend/repository/components/fork_info_spec.js @@ -1,23 +1,29 @@ -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlSkeletonLoader, GlIcon, GlLink, GlSprintf, GlButton, GlLoadingIcon } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { createAlert } from '~/alert'; +import { createAlert, VARIANT_INFO } from '~/alert'; import ForkInfo, { i18n } from '~/repository/components/fork_info.vue'; import ConflictsModal from '~/repository/components/fork_sync_conflicts_modal.vue'; import forkDetailsQuery from '~/repository/queries/fork_details.query.graphql'; import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql'; +import eventHub from '~/repository/event_hub'; +import { + POLLING_INTERVAL_DEFAULT, + POLLING_INTERVAL_BACKOFF, + FORK_UPDATED_EVENT, +} from '~/repository/constants'; import { propsForkInfo } from '../mock_data'; jest.mock('~/alert'); describe('ForkInfo component', () => { let wrapper; - let mockResolver; + let mockForkDetailsQuery; const forkInfoError = new Error('Something went wrong'); const projectId = 'gid://gitlab/Project/1'; const showMock = jest.fn(); @@ -25,14 +31,19 @@ describe('ForkInfo component', () => { Vue.use(VueApollo); - const createForkDetailsData = ( + const waitForPolling = async (interval = POLLING_INTERVAL_DEFAULT) => { + jest.advanceTimersByTime(interval); + await waitForPromises(); + }; + + const mockResolvedForkDetailsQuery = ( forkDetails = { ahead: 3, behind: 7, isSyncing: false, hasConflicts: false }, ) => { - return { + mockForkDetailsQuery.mockResolvedValue({ data: { project: { id: projectId, forkDetails }, }, - }; + }); }; const createSyncForkDetailsData = ( @@ -45,14 +56,10 @@ describe('ForkInfo component', () => { }; }; - const createComponent = (props = {}, data = {}, mutationData = {}, isRequestFailed = false) => { - mockResolver = isRequestFailed - ? jest.fn().mockRejectedValue(forkInfoError) - : jest.fn().mockResolvedValue(createForkDetailsData(data)); - + const createComponent = (props = {}, mutationData = {}) => { wrapper = shallowMountExtended(ForkInfo, { apolloProvider: createMockApollo([ - [forkDetailsQuery, mockResolver], + [forkDetailsQuery, mockForkDetailsQuery], [syncForkMutation, jest.fn().mockResolvedValue(createSyncForkDetailsData(mutationData))], ]), propsData: { ...propsForkInfo, ...props }, @@ -77,13 +84,24 @@ describe('ForkInfo component', () => { const findLink = () => wrapper.findComponent(GlLink); const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); const findIcon = () => wrapper.findComponent(GlIcon); - const findUpdateForkButton = () => wrapper.findComponent(GlButton); + const findUpdateForkButton = () => wrapper.findByTestId('update-fork-button'); + const findCreateMrButton = () => wrapper.findByTestId('create-mr-button'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDivergenceMessage = () => wrapper.findByTestId('divergence-message'); const findInaccessibleMessage = () => wrapper.findByTestId('inaccessible-project'); const findCompareLinks = () => findDivergenceMessage().findAllComponents(GlLink); - it('displays a skeleton while loading data', async () => { + const startForkUpdate = async () => { + findUpdateForkButton().vm.$emit('click'); + await waitForPromises(); + }; + + beforeEach(() => { + mockForkDetailsQuery = jest.fn(); + mockResolvedForkDetailsQuery(); + }); + + it('displays a skeleton while loading data', () => { createComponent(); expect(findSkeleton().exists()).toBe(true); }); @@ -100,12 +118,12 @@ describe('ForkInfo component', () => { it('queries the data when sourceName is present', async () => { await createComponent(); - expect(mockResolver).toHaveBeenCalled(); + expect(mockForkDetailsQuery).toHaveBeenCalled(); }); it('does not query the data when sourceName is empty', async () => { await createComponent({ sourceName: null }); - expect(mockResolver).not.toHaveBeenCalled(); + expect(mockForkDetailsQuery).not.toHaveBeenCalled(); }); it('renders inaccessible message when fork source is not available', async () => { @@ -122,48 +140,91 @@ describe('ForkInfo component', () => { expect(link.attributes('href')).toBe(propsForkInfo.sourcePath); }); - describe('Unknown divergence', () => { - beforeEach(async () => { - await createComponent( - {}, - { ahead: null, behind: null, isSyncing: false, hasConflicts: false }, - ); + it('renders Create MR Button with correct path', async () => { + await createComponent(); + expect(findCreateMrButton().attributes('href')).toBe(propsForkInfo.createMrPath); + }); + + it('does not render create MR button if user had no permission to Create MR in fork', async () => { + await createComponent({ canUserCreateMrInFork: false }); + expect(findCreateMrButton().exists()).toBe(false); + }); + + it('renders alert with error message when request fails', async () => { + mockForkDetailsQuery.mockRejectedValue(forkInfoError); + await createComponent({}); + expect(createAlert).toHaveBeenCalledWith({ + message: i18n.error, + captureError: true, + error: forkInfoError, }); + }); + describe('Unknown divergence', () => { it('renders unknown divergence message when divergence is unknown', async () => { + mockResolvedForkDetailsQuery({ + ahead: null, + behind: null, + isSyncing: false, + hasConflicts: false, + }); + await createComponent({}); expect(findDivergenceMessage().text()).toBe(i18n.unknown); }); it('renders Update Fork button', async () => { + mockResolvedForkDetailsQuery({ + ahead: null, + behind: null, + isSyncing: false, + hasConflicts: false, + }); + await createComponent({}); expect(findUpdateForkButton().exists()).toBe(true); - expect(findUpdateForkButton().text()).toBe(i18n.sync); + expect(findUpdateForkButton().text()).toBe(i18n.updateFork); }); }); describe('Up to date divergence', () => { beforeEach(async () => { + mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false }); await createComponent({}, { ahead: 0, behind: 0, isSyncing: false, hasConflicts: false }); }); - it('renders up to date message when fork is up to date', async () => { + it('renders up to date message when fork is up to date', () => { expect(findDivergenceMessage().text()).toBe(i18n.upToDate); }); - it('does not render Update Fork button', async () => { + it('does not render Update Fork button', () => { expect(findUpdateForkButton().exists()).toBe(false); }); }); describe('Limited visibility project', () => { beforeEach(async () => { + mockResolvedForkDetailsQuery(null); await createComponent({}, null); }); - it('renders limited visibility messsage when forkDetails are empty', async () => { + it('renders limited visibility message when forkDetails are empty', () => { expect(findDivergenceMessage().text()).toBe(i18n.limitedVisibility); }); - it('does not render Update Fork button', async () => { + it('does not render Update Fork button', () => { + expect(findUpdateForkButton().exists()).toBe(false); + }); + }); + + describe('User cannot sync the branch', () => { + beforeEach(async () => { + mockResolvedForkDetailsQuery({ ahead: 0, behind: 7, isSyncing: false, hasConflicts: false }); + await createComponent( + { canSyncBranch: false }, + { ahead: 0, behind: 7, isSyncing: false, hasConflicts: false }, + ); + }); + + it('does not render Update Fork button', () => { expect(findUpdateForkButton().exists()).toBe(false); }); }); @@ -175,7 +236,8 @@ describe('ForkInfo component', () => { message: '3 commits behind, 7 commits ahead of the upstream repository.', firstLink: propsForkInfo.behindComparePath, secondLink: propsForkInfo.aheadComparePath, - hasButton: true, + hasUpdateButton: true, + hasCreateMrButton: true, }, { ahead: 7, @@ -183,7 +245,8 @@ describe('ForkInfo component', () => { message: '7 commits ahead of the upstream repository.', firstLink: propsForkInfo.aheadComparePath, secondLink: '', - hasButton: false, + hasUpdateButton: false, + hasCreateMrButton: true, }, { ahead: 0, @@ -191,13 +254,15 @@ describe('ForkInfo component', () => { message: '3 commits behind the upstream repository.', firstLink: propsForkInfo.behindComparePath, secondLink: '', - hasButton: true, + hasUpdateButton: true, + hasCreateMrButton: false, }, ])( 'renders correct divergence message for ahead: $ahead, behind: $behind divergence commits', - ({ ahead, behind, message, firstLink, secondLink, hasButton }) => { + ({ ahead, behind, message, firstLink, secondLink, hasUpdateButton, hasCreateMrButton }) => { beforeEach(async () => { - await createComponent({}, { ahead, behind, isSyncing: false, hasConflicts: false }); + mockResolvedForkDetailsQuery({ ahead, behind, isSyncing: false, hasConflicts: false }); + await createComponent({}); }); it('displays correct text', () => { @@ -214,17 +279,25 @@ describe('ForkInfo component', () => { }); it('renders Update Fork button when fork is behind', () => { - expect(findUpdateForkButton().exists()).toBe(hasButton); - if (hasButton) { - expect(findUpdateForkButton().text()).toBe(i18n.sync); + expect(findUpdateForkButton().exists()).toBe(hasUpdateButton); + if (hasUpdateButton) { + expect(findUpdateForkButton().text()).toBe(i18n.updateFork); + } + }); + + it('renders Create Merge Request button when fork is ahead', () => { + expect(findCreateMrButton().exists()).toBe(hasCreateMrButton); + if (hasCreateMrButton) { + expect(findCreateMrButton().text()).toBe(i18n.createMergeRequest); } }); }, ); describe('when sync is not possible due to conflicts', () => { - it('opens Conflicts Modal', async () => { - await createComponent({}, { ahead: 7, behind: 3, isSyncing: false, hasConflicts: true }); + it('Opens Conflicts Modal', async () => { + mockResolvedForkDetailsQuery({ ahead: 7, behind: 3, isSyncing: false, hasConflicts: true }); + await createComponent({}); findUpdateForkButton().vm.$emit('click'); expect(showMock).toHaveBeenCalled(); }); @@ -232,24 +305,71 @@ describe('ForkInfo component', () => { describe('projectSyncFork mutation', () => { it('changes button to have loading state', async () => { - await createComponent( - {}, - { ahead: 0, behind: 3, isSyncing: false, hasConflicts: false }, - { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }, - ); + await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }); + mockResolvedForkDetailsQuery({ ahead: 0, behind: 3, isSyncing: false, hasConflicts: false }); expect(findLoadingIcon().exists()).toBe(false); - findUpdateForkButton().vm.$emit('click'); - await waitForPromises(); + await startForkUpdate(); expect(findLoadingIcon().exists()).toBe(true); }); }); - it('renders alert with error message when request fails', async () => { - await createComponent({}, {}, true); - expect(createAlert).toHaveBeenCalledWith({ - message: i18n.error, - captureError: true, - error: forkInfoError, + describe('polling', () => { + beforeEach(async () => { + await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }); + mockResolvedForkDetailsQuery({ ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }); + }); + + it('fetches data on the initial load', () => { + expect(mockForkDetailsQuery).toHaveBeenCalledTimes(1); + }); + + it('starts polling after sync button is clicked', async () => { + await startForkUpdate(); + await waitForPolling(); + expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2); + + await waitForPolling(POLLING_INTERVAL_DEFAULT * POLLING_INTERVAL_BACKOFF); + expect(mockForkDetailsQuery).toHaveBeenCalledTimes(3); + }); + + it('stops polling once sync is finished', async () => { + mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false }); + await startForkUpdate(); + await waitForPolling(); + expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2); + await waitForPolling(POLLING_INTERVAL_DEFAULT * POLLING_INTERVAL_BACKOFF); + expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2); + await nextTick(); + }); + }); + + describe('once fork is updated', () => { + beforeEach(async () => { + await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }); + mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false }); + }); + + it('shows info alert once the fork is updated', async () => { + await startForkUpdate(); + await waitForPolling(); + expect(createAlert).toHaveBeenCalledWith({ + message: i18n.successMessage, + variant: VARIANT_INFO, + }); + }); + + it('emits fork:updated event to eventHub', async () => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + await startForkUpdate(); + await waitForPolling(); + expect(eventHub.$emit).toHaveBeenCalledWith(FORK_UPDATED_EVENT); + }); + + it('hides update fork button', async () => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + await startForkUpdate(); + await waitForPolling(); + expect(findUpdateForkButton().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/repository/components/fork_sync_conflicts_modal_spec.js b/spec/frontend/repository/components/fork_sync_conflicts_modal_spec.js index f97c970275b..3fd9284e29b 100644 --- a/spec/frontend/repository/components/fork_sync_conflicts_modal_spec.js +++ b/spec/frontend/repository/components/fork_sync_conflicts_modal_spec.js @@ -36,7 +36,11 @@ describe('ConflictsModal', () => { expect(findInstructions().at(0).text()).toContain(propsConflictsModal.sourcePath); }); - it('renders default branch name in a first intruction', () => { + it('renders default branch name in a first step intruction', () => { expect(findInstructions().at(0).text()).toContain(propsConflictsModal.sourceDefaultBranch); }); + + it('renders selected branch name in a second step intruction', () => { + expect(findInstructions().at(1).text()).toContain(propsConflictsModal.selectedBranch); + }); }); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index f16edcb0b7c..c207d32d61d 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -4,11 +4,12 @@ import { GlLoadingIcon } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; - import LastCommit from '~/repository/components/last_commit.vue'; import SignatureBadge from '~/commit/components/signature_badge.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import eventHub from '~/repository/event_hub'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; +import { FORK_UPDATED_EVENT } from '~/repository/constants'; import { refMock } from '../mock_data'; let wrapper; @@ -100,7 +101,7 @@ const createCommitData = ({ }; }; -const createComponent = async (data = {}) => { +const createComponent = (data = {}) => { Vue.use(VueApollo); const currentPath = 'path'; @@ -180,6 +181,25 @@ describe('Repository last commit component', () => { expect(findCommitRowDescription().exists()).toBe(false); }); + describe('created', () => { + it('binds `epicsListScrolled` event listener via eventHub', () => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); + createComponent(); + + expect(eventHub.$on).toHaveBeenCalledWith(FORK_UPDATED_EVENT, expect.any(Function)); + }); + }); + + describe('beforeDestroy', () => { + it('unbinds `epicsListScrolled` event listener via eventHub', () => { + jest.spyOn(eventHub, '$off').mockImplementation(() => {}); + createComponent(); + wrapper.destroy(); + + expect(eventHub.$off).toHaveBeenCalledWith(FORK_UPDATED_EVENT, expect.any(Function)); + }); + }); + describe('when the description is present', () => { beforeEach(async () => { createComponent({ descriptionHtml: '
Update ADOPTERS.md' }); diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js index c920159375f..55a24089d48 100644 --- a/spec/frontend/repository/components/new_directory_modal_spec.js +++ b/spec/frontend/repository/components/new_directory_modal_spec.js @@ -124,7 +124,7 @@ describe('NewDirectoryModal', () => { }); describe('form submission', () => { - beforeEach(async () => { + beforeEach(() => { mock = new MockAdapter(axios); }); diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js index 8a88c5b9c61..316ddfb5731 100644 --- a/spec/frontend/repository/components/preview/index_spec.js +++ b/spec/frontend/repository/components/preview/index_spec.js @@ -54,7 +54,7 @@ describe('Repository file preview component', () => { expect(handleLocationHash).toHaveBeenCalled(); }); - it('renders loading icon', async () => { + it('renders loading icon', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 055616d6e8e..02b505c828c 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -1,7 +1,10 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { GlBadge, GlLink, GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import refQuery from '~/repository/queries/ref.query.graphql'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import createMockApollo from 'helpers/mock_apollo_helper'; import TableRow from '~/repository/components/table/row.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; @@ -9,23 +12,37 @@ import { ROW_APPEAR_DELAY } from '~/repository/constants'; const COMMIT_MOCK = { lockLabel: 'Locked by Root', committedDate: '2019-01-01' }; -let vm; +let wrapper; let $router; -function factory(propsData = {}) { +const createMockApolloProvider = (mockData) => { + Vue.use(VueApollo); + const apolloProver = createMockApollo([]); + apolloProver.clients.defaultClient.cache.writeQuery({ query: refQuery, data: { ...mockData } }); + + return apolloProver; +}; + +function factory({ mockData = { ref: 'main', escapedRef: 'main' }, propsData = {} } = {}) { $router = { push: jest.fn(), }; - vm = shallowMount(TableRow, { + wrapper = shallowMount(TableRow, { + apolloProvider: createMockApolloProvider(mockData), propsData: { + id: '1', + sha: '0as4k', commitInfo: COMMIT_MOCK, - ...propsData, - name: propsData.path, + name: 'name', + currentPath: 'gitlab-org/gitlab-ce', projectPath: 'gitlab-org/gitlab-ce', url: `https://test.com`, totalEntries: 10, rowNumber: 123, + path: 'gitlab-org/gitlab-ce', + type: 'tree', + ...propsData, }, directives: { GlHoverLoad: createMockDirective('gl-hover-load'), @@ -37,63 +54,67 @@ function factory(propsData = {}) { RouterLink: RouterLinkStub, }, }); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - vm.setData({ escapedRef: 'main' }); } describe('Repository table row component', () => { - const findRouterLink = () => vm.findComponent(RouterLinkStub); - const findIntersectionObserver = () => vm.findComponent(GlIntersectionObserver); + const findIcon = () => wrapper.findComponent(GlIcon); + const findFileIcon = () => wrapper.findComponent(FileIcon); + const findBadge = () => wrapper.findComponent(GlBadge); + const findRouterLink = () => wrapper.findComponent(RouterLinkStub); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); - it('renders table row', async () => { + it('renders table row', () => { factory({ - id: '1', - sha: '123', - path: 'test', - type: 'file', - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path: 'test', + type: 'file', + currentPath: '/', + }, }); - await nextTick(); - expect(vm.element).toMatchSnapshot(); + expect(wrapper.element).toMatchSnapshot(); }); - it('renders a symlink table row', async () => { + it('renders a symlink table row', () => { factory({ - id: '1', - sha: '123', - path: 'test', - type: 'blob', - currentPath: '/', - mode: FILE_SYMLINK_MODE, + propsData: { + id: '1', + sha: '123', + path: 'test', + type: 'blob', + currentPath: '/', + mode: FILE_SYMLINK_MODE, + }, }); - await nextTick(); - expect(vm.element).toMatchSnapshot(); + expect(wrapper.element).toMatchSnapshot(); }); - it('renders table row for path with special character', async () => { + it('renders table row for path with special character', () => { factory({ - id: '1', - sha: '123', - path: 'test$/test', - type: 'file', - currentPath: 'test$', + propsData: { + id: '1', + sha: '123', + path: 'test$/test', + type: 'file', + currentPath: 'test$', + }, }); - await nextTick(); - expect(vm.element).toMatchSnapshot(); + expect(wrapper.element).toMatchSnapshot(); }); it('renders a gl-hover-load directive', () => { factory({ - id: '1', - sha: '123', - path: 'test', - type: 'blob', - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path: 'test', + type: 'blob', + currentPath: '/', + }, }); const hoverLoadDirective = getBinding(findRouterLink().element, 'gl-hover-load'); @@ -107,150 +128,162 @@ describe('Repository table row component', () => { ${'tree'} | ${RouterLinkStub} | ${'RouterLink'} ${'blob'} | ${RouterLinkStub} | ${'RouterLink'} ${'commit'} | ${'a'} | ${'hyperlink'} - `('renders a $componentName for type $type', async ({ type, component }) => { + `('renders a $componentName for type $type', ({ type, component }) => { factory({ - id: '1', - sha: '123', - path: 'test', - type, - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path: 'test', + type, + currentPath: '/', + }, }); - await nextTick(); - expect(vm.findComponent(component).exists()).toBe(true); + expect(wrapper.findComponent(component).exists()).toBe(true); }); it.each` path ${'test#'} ${'Änderungen'} - `('renders link for $path', async ({ path }) => { + `('renders link for $path', ({ path }) => { factory({ - id: '1', - sha: '123', - path, - type: 'tree', - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path, + type: 'tree', + currentPath: '/', + }, }); - await nextTick(); - expect(vm.findComponent({ ref: 'link' }).props('to')).toEqual({ + expect(wrapper.findComponent({ ref: 'link' }).props('to')).toEqual({ path: `/-/tree/main/${encodeURIComponent(path)}`, }); }); - it('renders link for directory with hash', async () => { + it('renders link for directory with hash', () => { factory({ - id: '1', - sha: '123', - path: 'test#', - type: 'tree', - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path: 'test#', + type: 'tree', + currentPath: '/', + }, }); - await nextTick(); - expect(vm.find('.tree-item-link').props('to')).toEqual({ path: '/-/tree/main/test%23' }); + expect(wrapper.find('.tree-item-link').props('to')).toEqual({ path: '/-/tree/main/test%23' }); }); - it('renders commit ID for submodule', async () => { + it('renders commit ID for submodule', () => { factory({ - id: '1', - sha: '123', - path: 'test', - type: 'commit', - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path: 'test', + type: 'commit', + currentPath: '/', + }, }); - await nextTick(); - expect(vm.find('.commit-sha').text()).toContain('1'); + expect(wrapper.find('.commit-sha').text()).toContain('1'); }); - it('renders link with href', async () => { + it('renders link with href', () => { factory({ - id: '1', - sha: '123', - path: 'test', - type: 'blob', - url: 'https://test.com', - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path: 'test', + type: 'blob', + url: 'https://test.com', + currentPath: '/', + }, }); - await nextTick(); - expect(vm.find('a').attributes('href')).toEqual('https://test.com'); + expect(wrapper.find('a').attributes('href')).toEqual('https://test.com'); }); - it('renders LFS badge', async () => { + it('renders LFS badge', () => { factory({ - id: '1', - sha: '123', - path: 'test', - type: 'commit', - currentPath: '/', - lfsOid: '1', + propsData: { + id: '1', + sha: '123', + path: 'test', + type: 'commit', + currentPath: '/', + lfsOid: '1', + }, }); - await nextTick(); - expect(vm.findComponent(GlBadge).exists()).toBe(true); + expect(findBadge().exists()).toBe(true); }); - it('renders commit and web links with href for submodule', async () => { + it('renders commit and web links with href for submodule', () => { factory({ - id: '1', - sha: '123', - path: 'test', - type: 'commit', - url: 'https://test.com', - submoduleTreeUrl: 'https://test.com/commit', - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path: 'test', + type: 'commit', + url: 'https://test.com', + submoduleTreeUrl: 'https://test.com/commit', + currentPath: '/', + }, }); - await nextTick(); - expect(vm.find('a').attributes('href')).toEqual('https://test.com'); - expect(vm.findComponent(GlLink).attributes('href')).toEqual('https://test.com/commit'); + expect(wrapper.find('a').attributes('href')).toEqual('https://test.com'); + expect(wrapper.findComponent(GlLink).attributes('href')).toEqual('https://test.com/commit'); }); - it('renders lock icon', async () => { + it('renders lock icon', () => { factory({ - id: '1', - sha: '123', - path: 'test', - type: 'tree', - currentPath: '/', + propsData: { + id: '1', + sha: '123', + path: 'test', + type: 'tree', + currentPath: '/', + }, }); - await nextTick(); - expect(vm.findComponent(GlIcon).exists()).toBe(true); - expect(vm.findComponent(GlIcon).props('name')).toBe('lock'); + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe('lock'); }); it('renders loading icon when path is loading', () => { factory({ - id: '1', - sha: '1', - path: 'test', - type: 'tree', - currentPath: '/', - loadingPath: 'test', + propsData: { + id: '1', + sha: '1', + path: 'test', + type: 'tree', + currentPath: '/', + loadingPath: 'test', + }, }); - expect(vm.findComponent(FileIcon).props('loading')).toBe(true); + expect(findFileIcon().props('loading')).toBe(true); }); describe('row visibility', () => { beforeEach(() => { factory({ - id: '1', - sha: '1', - path: 'test', - type: 'tree', - currentPath: '/', - commitInfo: null, + propsData: { + id: '1', + sha: '1', + path: 'test', + type: 'tree', + currentPath: '/', + commitInfo: null, + }, }); }); afterAll(() => jest.useRealTimers()); - it('emits a `row-appear` event', async () => { + it('emits a `row-appear` event', () => { const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); findIntersectionObserver().vm.$emit('appear'); @@ -258,7 +291,7 @@ describe('Repository table row component', () => { expect(setTimeoutSpy).toHaveBeenCalledTimes(1); expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), ROW_APPEAR_DELAY); - expect(vm.emitted('row-appear')).toEqual([[123]]); + expect(wrapper.emitted('row-appear')).toEqual([[123]]); }); }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 9597d8a7b77..5b874656d39 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -99,7 +99,7 @@ describe('Repository table component', () => { describe('FileTable showMore', () => { describe('when is present', () => { - beforeEach(async () => { + beforeEach(() => { factory('/'); }); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index 418a93a10cc..afa183c0616 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -123,12 +123,16 @@ export const propsForkInfo = { selectedBranch: 'main', sourceName: 'gitLab', sourcePath: 'gitlab-org/gitlab', + canSyncBranch: true, aheadComparePath: '/nataliia/myGitLab/-/compare/main...ref?from_project_id=1', behindComparePath: 'gitlab-org/gitlab/-/compare/ref...main?from_project_id=2', + createMrPath: 'path/to/new/mr', + canUserCreateMrInFork: true, }; export const propsConflictsModal = { sourceDefaultBranch: 'branch-name', sourceName: 'source-name', sourcePath: 'path/to/project', + selectedBranch: 'my-branch', }; diff --git a/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap deleted file mode 100644 index 204afc744e7..00000000000 --- a/spec/frontend/saved_replies/components/__snapshots__/list_item_spec.js.snap +++ /dev/null @@ -1,67 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Saved replies list item component renders list item 1`] = ` -<li - class="gl-mb-5" -> - <div - class="gl-display-flex gl-align-items-center" - > - <strong - data-testid="saved-reply-name" - > - test - </strong> - - <div - class="gl-ml-auto" - > - <gl-button-stub - aria-label="Edit" - buttontextclasses="" - category="primary" - class="gl-mr-3" - data-testid="saved-reply-edit-btn" - icon="pencil" - size="medium" - title="Edit" - to="[object Object]" - variant="default" - /> - - <gl-button-stub - aria-label="Delete" - buttontextclasses="" - category="secondary" - data-testid="saved-reply-delete-btn" - icon="remove" - size="medium" - title="Delete" - variant="danger" - /> - </div> - </div> - - <div - class="gl-mt-3 gl-font-monospace" - > - /assign_reviewer - </div> - - <gl-modal-stub - actionprimary="[object Object]" - actionsecondary="[object Object]" - arialabel="" - dismisslabel="Close" - modalclass="" - modalid="delete-saved-reply-2" - size="sm" - title="Delete saved reply" - titletag="h4" - > - <gl-sprintf-stub - message="Are you sure you want to delete %{name}? This action cannot be undone." - /> - </gl-modal-stub> -</li> -`; diff --git a/spec/frontend/saved_replies/components/list_item_spec.js b/spec/frontend/saved_replies/components/list_item_spec.js deleted file mode 100644 index f1ecdfecb15..00000000000 --- a/spec/frontend/saved_replies/components/list_item_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { shallowMount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { createMockDirective } from 'helpers/vue_mock_directive'; -import waitForPromises from 'helpers/wait_for_promises'; -import ListItem from '~/saved_replies/components/list_item.vue'; -import deleteSavedReplyMutation from '~/saved_replies/queries/delete_saved_reply.mutation.graphql'; - -let wrapper; -let deleteSavedReplyMutationResponse; - -function createComponent(propsData = {}) { - Vue.use(VueApollo); - - deleteSavedReplyMutationResponse = jest - .fn() - .mockResolvedValue({ data: { savedReplyDestroy: { errors: [] } } }); - - return shallowMount(ListItem, { - propsData, - directives: { - GlModal: createMockDirective('gl-modal'), - }, - apolloProvider: createMockApollo([ - [deleteSavedReplyMutation, deleteSavedReplyMutationResponse], - ]), - }); -} - -describe('Saved replies list item component', () => { - it('renders list item', async () => { - wrapper = createComponent({ reply: { name: 'test', content: '/assign_reviewer' } }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('delete button', () => { - it('calls Apollo mutate', async () => { - wrapper = createComponent({ reply: { name: 'test', content: '/assign_reviewer', id: 1 } }); - - wrapper.findComponent(GlModal).vm.$emit('primary'); - - await waitForPromises(); - - expect(deleteSavedReplyMutationResponse).toHaveBeenCalledWith({ id: 1 }); - }); - }); -}); diff --git a/spec/frontend/scripts/frontend/__fixtures__/locale/de/converted.json b/spec/frontend/scripts/frontend/__fixtures__/locale/de/converted.json new file mode 100644 index 00000000000..570980b1c27 --- /dev/null +++ b/spec/frontend/scripts/frontend/__fixtures__/locale/de/converted.json @@ -0,0 +1,21 @@ +{ + "domain": "app", + "locale_data": { + "app": { + "": { + "domain": "app", + "lang": "de" + }, + " %{start} to %{end}": [ + " %{start} bis %{end}" + ], + "%d Alert:": [ + "%d Warnung:", + "%d Warnungen:" + ], + "Example": [ + "" + ] + } + } +} diff --git a/spec/frontend/scripts/frontend/__fixtures__/locale/de/gitlab.po b/spec/frontend/scripts/frontend/__fixtures__/locale/de/gitlab.po new file mode 100644 index 00000000000..fe80cb72c29 --- /dev/null +++ b/spec/frontend/scripts/frontend/__fixtures__/locale/de/gitlab.po @@ -0,0 +1,13 @@ +# Simple translated string +msgid " %{start} to %{end}" +msgstr " %{start} bis %{end}" + +# Simple translated, pluralized string +msgid "%d Alert:" +msgid_plural "%d Alerts:" +msgstr[0] "%d Warnung:" +msgstr[1] "%d Warnungen:" + +# Simple string without translation +msgid "Example" +msgstr "" diff --git a/spec/frontend/scripts/frontend/po_to_json_spec.js b/spec/frontend/scripts/frontend/po_to_json_spec.js new file mode 100644 index 00000000000..858e3c9d3c7 --- /dev/null +++ b/spec/frontend/scripts/frontend/po_to_json_spec.js @@ -0,0 +1,244 @@ +import { join } from 'path'; +import { tmpdir } from 'os'; +import { readFile, rm, mkdtemp, stat } from 'fs/promises'; +import { + convertPoToJed, + convertPoFileForLocale, + main, +} from '../../../../scripts/frontend/po_to_json'; + +describe('PoToJson', () => { + const LOCALE = 'de'; + const LOCALE_DIR = join(__dirname, '__fixtures__/locale'); + const PO_FILE = join(LOCALE_DIR, LOCALE, 'gitlab.po'); + const CONVERTED_FILE = join(LOCALE_DIR, LOCALE, 'converted.json'); + let DE_CONVERTED = null; + + beforeAll(async () => { + DE_CONVERTED = Object.freeze(JSON.parse(await readFile(CONVERTED_FILE, 'utf-8'))); + }); + + describe('tests writing to the file system', () => { + let resultDir = null; + + afterEach(async () => { + if (resultDir) { + await rm(resultDir, { recursive: true, force: true }); + } + }); + + beforeEach(async () => { + resultDir = await mkdtemp(join(tmpdir(), 'locale-test')); + }); + + describe('#main', () => { + it('throws without arguments', () => { + return expect(main()).rejects.toThrow(/doesn't seem to be a folder/); + }); + + it('throws if outputDir does not exist', () => { + return expect( + main({ + localeRoot: LOCALE_DIR, + outputDir: 'i-do-not-exist', + }), + ).rejects.toThrow(/doesn't seem to be a folder/); + }); + + it('throws if localeRoot does not exist', () => { + return expect( + main({ + localeRoot: 'i-do-not-exist', + outputDir: resultDir, + }), + ).rejects.toThrow(/doesn't seem to be a folder/); + }); + + it('converts folder of po files to app.js files', async () => { + expect((await stat(resultDir)).isDirectory()).toBe(true); + await main({ localeRoot: LOCALE_DIR, outputDir: resultDir }); + + const resultFile = join(resultDir, LOCALE, 'app.js'); + expect((await stat(resultFile)).isFile()).toBe(true); + + window.translations = null; + await import(resultFile); + expect(window.translations).toEqual(DE_CONVERTED); + }); + }); + + describe('#convertPoFileForLocale', () => { + it('converts simple PO to app.js, which exposes translations on the window', async () => { + await convertPoFileForLocale({ locale: 'de', localeFile: PO_FILE, resultDir }); + + const resultFile = join(resultDir, 'app.js'); + expect((await stat(resultFile)).isFile()).toBe(true); + + window.translations = null; + await import(resultFile); + expect(window.translations).toEqual(DE_CONVERTED); + }); + }); + }); + + describe('#convertPoToJed', () => { + it('converts simple PO to JED compatible JSON', async () => { + const poContent = await readFile(PO_FILE, 'utf-8'); + + expect(convertPoToJed(poContent, LOCALE).jed).toEqual(DE_CONVERTED); + }); + + it('returns null for empty string', () => { + const poContent = ''; + + expect(convertPoToJed(poContent, LOCALE).jed).toEqual(null); + }); + + describe('PO File headers', () => { + it('parses headers properly', () => { + const poContent = ` +msgid "" +msgstr "" +"Project-Id-Version: gitlab-ee\\n" +"Report-Msgid-Bugs-To: \\n" +"X-Crowdin-Project: gitlab-ee\\n" +`; + + expect(convertPoToJed(poContent, LOCALE).jed).toEqual({ + domain: 'app', + locale_data: { + app: { + '': { + 'Project-Id-Version': 'gitlab-ee', + 'Report-Msgid-Bugs-To': '', + 'X-Crowdin-Project': 'gitlab-ee', + domain: 'app', + lang: LOCALE, + }, + }, + }, + }); + }); + + // JED needs that property, hopefully we could get + // rid of this in a future iteration + it("exposes 'Plural-Forms' as 'plural_forms' for `jed`", () => { + const poContent = ` +msgid "" +msgstr "" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" +`; + + expect(convertPoToJed(poContent, LOCALE).jed).toEqual({ + domain: 'app', + locale_data: { + app: { + '': { + 'Plural-Forms': 'nplurals=2; plural=(n != 1);', + plural_forms: 'nplurals=2; plural=(n != 1);', + domain: 'app', + lang: LOCALE, + }, + }, + }, + }); + }); + + it('removes POT-Creation-Date', () => { + const poContent = ` +msgid "" +msgstr "" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" +`; + + expect(convertPoToJed(poContent, LOCALE).jed).toEqual({ + domain: 'app', + locale_data: { + app: { + '': { + 'Plural-Forms': 'nplurals=2; plural=(n != 1);', + plural_forms: 'nplurals=2; plural=(n != 1);', + domain: 'app', + lang: LOCALE, + }, + }, + }, + }); + }); + }); + + describe('escaping', () => { + it('escapes quotes in msgid and translation', () => { + const poContent = ` +# Escaped quotes in msgid and msgstr +msgid "Changes the title to \\"%{title_param}\\"." +msgstr "Ändert den Titel in \\"%{title_param}\\"." +`; + + expect(convertPoToJed(poContent, LOCALE).jed).toEqual({ + domain: 'app', + locale_data: { + app: { + '': { + domain: 'app', + lang: LOCALE, + }, + 'Changes the title to \\"%{title_param}\\".': [ + 'Ändert den Titel in \\"%{title_param}\\".', + ], + }, + }, + }); + }); + + it('escapes backslashes in msgid and translation', () => { + const poContent = ` +# Escaped backslashes in msgid and msgstr +msgid "Example: ssh\\\\:\\\\/\\\\/" +msgstr "Beispiel: ssh\\\\:\\\\/\\\\/" +`; + + expect(convertPoToJed(poContent, LOCALE).jed).toEqual({ + domain: 'app', + locale_data: { + app: { + '': { + domain: 'app', + lang: LOCALE, + }, + 'Example: ssh\\\\:\\\\/\\\\/': ['Beispiel: ssh\\\\:\\\\/\\\\/'], + }, + }, + }); + }); + + // This is potentially faulty behavior but demands further investigation + // See also the escapeMsgstr method + it('escapes \\n and \\t in translation', () => { + const poContent = ` +# Escaped \\n +msgid "Outdent line" +msgstr "Désindenter la ligne\\n" + +# Escaped \\t +msgid "Headers" +msgstr "Cabeçalhos\\t" +`; + + expect(convertPoToJed(poContent, LOCALE).jed).toEqual({ + domain: 'app', + locale_data: { + app: { + '': { + domain: 'app', + lang: LOCALE, + }, + Headers: ['Cabeçalhos\\t'], + 'Outdent line': ['Désindenter la ligne\\n'], + }, + }, + }); + }); + }); + }); +}); diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js index 15cff436076..91fc97c15ae 100644 --- a/spec/frontend/search/highlight_blob_search_result_spec.js +++ b/spec/frontend/search/highlight_blob_search_result_spec.js @@ -1,11 +1,11 @@ +import htmlPipelineSchedulesEdit from 'test_fixtures/search/blob_search_result.html'; import setHighlightClass from '~/search/highlight_blob_search_result'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -const fixture = 'search/blob_search_result.html'; const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79 describe('search/highlight_blob_search_result', () => { - beforeEach(() => loadHTMLFixture(fixture)); + beforeEach(() => setHTMLFixture(htmlPipelineSchedulesEdit)); afterEach(() => { resetHTMLFixture(); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index 0aa4f0e1c84..f8dd6f6df27 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -6,6 +6,7 @@ export const MOCK_QUERY = { state: 'all', confidential: null, group_id: 1, + language: ['C', 'JavaScript'], }; export const MOCK_GROUP = { @@ -193,214 +194,6 @@ export const MOCK_NAVIGATION_ACTION_MUTATION = { payload: { key: 'projects', count: '13' }, }; -export const MOCK_AGGREGATIONS = [ - { - name: 'language', - buckets: [ - { key: 'random-label-edumingos0', count: 1 }, - { key: 'random-label-rbourgourd1', count: 2 }, - { key: 'random-label-dfearnside2', count: 3 }, - { key: 'random-label-gewins3', count: 4 }, - { key: 'random-label-telverstone4', count: 5 }, - { key: 'random-label-ygerriets5', count: 6 }, - { key: 'random-label-lmoffet6', count: 7 }, - { key: 'random-label-ehinnerk7', count: 8 }, - { key: 'random-label-flanceley8', count: 9 }, - { key: 'random-label-adoyle9', count: 10 }, - { key: 'random-label-rmcgirla', count: 11 }, - { key: 'random-label-dwhellansb', count: 12 }, - { key: 'random-label-apitkethlyc', count: 13 }, - { key: 'random-label-senevoldsend', count: 14 }, - { key: 'random-label-tlardnare', count: 15 }, - { key: 'random-label-fcoilsf', count: 16 }, - { key: 'random-label-qgeckg', count: 17 }, - { key: 'random-label-rgrabenh', count: 18 }, - { key: 'random-label-lashardi', count: 19 }, - { key: 'random-label-sadamovitchj', count: 20 }, - { key: 'random-label-rlyddiardk', count: 21 }, - { key: 'random-label-jpoell', count: 22 }, - { key: 'random-label-kcharitym', count: 23 }, - { key: 'random-label-cbertenshawn', count: 24 }, - { key: 'random-label-jsturgeso', count: 25 }, - { key: 'random-label-ohouldcroftp', count: 26 }, - { key: 'random-label-rheijnenq', count: 27 }, - { key: 'random-label-snortheyr', count: 28 }, - { key: 'random-label-vpairpoints', count: 29 }, - { key: 'random-label-odavidovicit', count: 30 }, - { key: 'random-label-fmccartu', count: 31 }, - { key: 'random-label-cwansburyv', count: 32 }, - { key: 'random-label-bdimontw', count: 33 }, - { key: 'random-label-adocketx', count: 34 }, - { key: 'random-label-obavridgey', count: 35 }, - { key: 'random-label-jperezz', count: 36 }, - { key: 'random-label-gdeneve10', count: 37 }, - { key: 'random-label-rmckeand11', count: 38 }, - { key: 'random-label-kwestmerland12', count: 39 }, - { key: 'random-label-mpryer13', count: 40 }, - { key: 'random-label-rmcneil14', count: 41 }, - { key: 'random-label-ablondel15', count: 42 }, - { key: 'random-label-wbalducci16', count: 43 }, - { key: 'random-label-swigley17', count: 44 }, - { key: 'random-label-gferroni18', count: 45 }, - { key: 'random-label-icollings19', count: 46 }, - { key: 'random-label-wszymanski1a', count: 47 }, - { key: 'random-label-jelson1b', count: 48 }, - { key: 'random-label-fsambrook1c', count: 49 }, - { key: 'random-label-kconey1d', count: 50 }, - { key: 'random-label-agoodread1e', count: 51 }, - { key: 'random-label-nmewton1f', count: 52 }, - { key: 'random-label-gcodman1g', count: 53 }, - { key: 'random-label-rpoplee1h', count: 54 }, - { key: 'random-label-mhug1i', count: 55 }, - { key: 'random-label-ggowrie1j', count: 56 }, - { key: 'random-label-ctonepohl1k', count: 57 }, - { key: 'random-label-cstillman1l', count: 58 }, - { key: 'random-label-dcollyer1m', count: 59 }, - { key: 'random-label-idimelow1n', count: 60 }, - { key: 'random-label-djarley1o', count: 61 }, - { key: 'random-label-omclleese1p', count: 62 }, - { key: 'random-label-dstivers1q', count: 63 }, - { key: 'random-label-svose1r', count: 64 }, - { key: 'random-label-clanfare1s', count: 65 }, - { key: 'random-label-aport1t', count: 66 }, - { key: 'random-label-hcarlett1u', count: 67 }, - { key: 'random-label-dstillmann1v', count: 68 }, - { key: 'random-label-ncorpe1w', count: 69 }, - { key: 'random-label-mjacobsohn1x', count: 70 }, - { key: 'random-label-ycleiment1y', count: 71 }, - { key: 'random-label-owherton1z', count: 72 }, - { key: 'random-label-anowaczyk20', count: 73 }, - { key: 'random-label-rmckennan21', count: 74 }, - { key: 'random-label-cmoulding22', count: 75 }, - { key: 'random-label-sswate23', count: 76 }, - { key: 'random-label-cbarge24', count: 77 }, - { key: 'random-label-agrainger25', count: 78 }, - { key: 'random-label-ncosin26', count: 79 }, - { key: 'random-label-pkears27', count: 80 }, - { key: 'random-label-cmcarthur28', count: 81 }, - { key: 'random-label-jmantripp29', count: 82 }, - { key: 'random-label-cjekel2a', count: 83 }, - { key: 'random-label-hdilleway2b', count: 84 }, - { key: 'random-label-lbovaird2c', count: 85 }, - { key: 'random-label-mweld2d', count: 86 }, - { key: 'random-label-marnowitz2e', count: 87 }, - { key: 'random-label-nbertomieu2f', count: 88 }, - { key: 'random-label-mledward2g', count: 89 }, - { key: 'random-label-mhince2h', count: 90 }, - { key: 'random-label-baarons2i', count: 91 }, - { key: 'random-label-kfrancie2j', count: 92 }, - { key: 'random-label-ishooter2k', count: 93 }, - { key: 'random-label-glowmass2l', count: 94 }, - { key: 'random-label-rgeorgi2m', count: 95 }, - { key: 'random-label-bproby2n', count: 96 }, - { key: 'random-label-hsteffan2o', count: 97 }, - { key: 'random-label-doruane2p', count: 98 }, - { key: 'random-label-rlunny2q', count: 99 }, - { key: 'random-label-geles2r', count: 100 }, - { key: 'random-label-nmaggiore2s', count: 101 }, - { key: 'random-label-aboocock2t', count: 102 }, - { key: 'random-label-eguilbert2u', count: 103 }, - { key: 'random-label-emccutcheon2v', count: 104 }, - { key: 'random-label-hcowser2w', count: 105 }, - { key: 'random-label-dspeeding2x', count: 106 }, - { key: 'random-label-oseebright2y', count: 107 }, - { key: 'random-label-hpresdee2z', count: 108 }, - { key: 'random-label-pesseby30', count: 109 }, - { key: 'random-label-hpusey31', count: 110 }, - { key: 'random-label-dmanthorpe32', count: 111 }, - { key: 'random-label-natley33', count: 112 }, - { key: 'random-label-iferentz34', count: 113 }, - { key: 'random-label-adyble35', count: 114 }, - { key: 'random-label-dlockitt36', count: 115 }, - { key: 'random-label-acoxwell37', count: 116 }, - { key: 'random-label-amcgarvey38', count: 117 }, - { key: 'random-label-rmcgougan39', count: 118 }, - { key: 'random-label-mscole3a', count: 119 }, - { key: 'random-label-lmalim3b', count: 120 }, - { key: 'random-label-cends3c', count: 121 }, - { key: 'random-label-dmannie3d', count: 122 }, - { key: 'random-label-lgoodricke3e', count: 123 }, - { key: 'random-label-rcaghy3f', count: 124 }, - { key: 'random-label-mprozillo3g', count: 125 }, - { key: 'random-label-mcardnell3h', count: 126 }, - { key: 'random-label-gericssen3i', count: 127 }, - { key: 'random-label-fspooner3j', count: 128 }, - { key: 'random-label-achadney3k', count: 129 }, - { key: 'random-label-corchard3l', count: 130 }, - { key: 'random-label-lyerill3m', count: 131 }, - { key: 'random-label-jrusk3n', count: 132 }, - { key: 'random-label-lbonelle3o', count: 133 }, - { key: 'random-label-eduny3p', count: 134 }, - { key: 'random-label-mhutchence3q', count: 135 }, - { key: 'random-label-rmargeram3r', count: 136 }, - { key: 'random-label-smaudlin3s', count: 137 }, - { key: 'random-label-sfarrance3t', count: 138 }, - { key: 'random-label-eclendennen3u', count: 139 }, - { key: 'random-label-cyabsley3v', count: 140 }, - { key: 'random-label-ahensmans3w', count: 141 }, - { key: 'random-label-tsenchenko3x', count: 142 }, - { key: 'random-label-ryurchishin3y', count: 143 }, - { key: 'random-label-teby3z', count: 144 }, - { key: 'random-label-dvaillant40', count: 145 }, - { key: 'random-label-kpetyakov41', count: 146 }, - { key: 'random-label-cmorrison42', count: 147 }, - { key: 'random-label-ltwiddy43', count: 148 }, - { key: 'random-label-ineame44', count: 149 }, - { key: 'random-label-blucock45', count: 150 }, - { key: 'random-label-kdunsford46', count: 151 }, - { key: 'random-label-dducham47', count: 152 }, - { key: 'random-label-javramovitz48', count: 153 }, - { key: 'random-label-mascraft49', count: 154 }, - { key: 'random-label-bloughead4a', count: 155 }, - { key: 'random-label-sduckit4b', count: 156 }, - { key: 'random-label-hhardman4c', count: 157 }, - { key: 'random-label-cstaniforth4d', count: 158 }, - { key: 'random-label-jedney4e', count: 159 }, - { key: 'random-label-bobbard4f', count: 160 }, - { key: 'random-label-cgiraux4g', count: 161 }, - { key: 'random-label-tkiln4h', count: 162 }, - { key: 'random-label-jwansbury4i', count: 163 }, - { key: 'random-label-dquinlan4j', count: 164 }, - { key: 'random-label-hgindghill4k', count: 165 }, - { key: 'random-label-jjowle4l', count: 166 }, - { key: 'random-label-egambrell4m', count: 167 }, - { key: 'random-label-jmcgloughlin4n', count: 168 }, - { key: 'random-label-bbabb4o', count: 169 }, - { key: 'random-label-achuck4p', count: 170 }, - { key: 'random-label-tsyers4q', count: 171 }, - { key: 'random-label-jlandon4r', count: 172 }, - { key: 'random-label-wteather4s', count: 173 }, - { key: 'random-label-dfoskin4t', count: 174 }, - { key: 'random-label-gmorlon4u', count: 175 }, - { key: 'random-label-jseely4v', count: 176 }, - { key: 'random-label-cbrass4w', count: 177 }, - { key: 'random-label-fmanilo4x', count: 178 }, - { key: 'random-label-bfrangleton4y', count: 179 }, - { key: 'random-label-vbartkiewicz4z', count: 180 }, - { key: 'random-label-tclymer50', count: 181 }, - { key: 'random-label-pqueen51', count: 182 }, - { key: 'random-label-bpol52', count: 183 }, - { key: 'random-label-jclaeskens53', count: 184 }, - { key: 'random-label-cstranieri54', count: 185 }, - { key: 'random-label-drumbelow55', count: 186 }, - { key: 'random-label-wbrumham56', count: 187 }, - { key: 'random-label-azeal57', count: 188 }, - { key: 'random-label-msnooks58', count: 189 }, - { key: 'random-label-blapre59', count: 190 }, - { key: 'random-label-cduckers5a', count: 191 }, - { key: 'random-label-mgumary5b', count: 192 }, - { key: 'random-label-rtebbs5c', count: 193 }, - { key: 'random-label-eroe5d', count: 194 }, - { key: 'random-label-rconfait5e', count: 195 }, - { key: 'random-label-fsinderland5f', count: 196 }, - { key: 'random-label-tdallywater5g', count: 197 }, - { key: 'random-label-glindenman5h', count: 198 }, - { key: 'random-label-fbauser5i', count: 199 }, - { key: 'random-label-bdownton5j', count: 200 }, - ], - }, -]; - export const MOCK_LANGUAGE_AGGREGATIONS_BUCKETS = [ { key: 'random-label-edumingos0', count: 1 }, { key: 'random-label-rbourgourd1', count: 2 }, @@ -604,13 +397,27 @@ export const MOCK_LANGUAGE_AGGREGATIONS_BUCKETS = [ { key: 'random-label-bdownton5j', count: 200 }, ]; +export const MOCK_AGGREGATIONS = [ + { + name: 'language', + buckets: MOCK_LANGUAGE_AGGREGATIONS_BUCKETS, + }, +]; + +export const SORTED_MOCK_AGGREGATIONS = [ + { + name: 'language', + buckets: MOCK_LANGUAGE_AGGREGATIONS_BUCKETS.reverse(), + }, +]; + export const MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION = [ { type: types.REQUEST_AGGREGATIONS, }, { type: types.RECEIVE_AGGREGATIONS_SUCCESS, - payload: MOCK_AGGREGATIONS, + payload: SORTED_MOCK_AGGREGATIONS, }, ]; @@ -660,3 +467,78 @@ export const SMALL_MOCK_AGGREGATIONS = [ buckets: TEST_RAW_BUCKETS, }, ]; + +export const MOCK_NAVIGATION_ITEMS = [ + { + title: 'Projects', + icon: 'project', + link: '/search?scope=projects&search=et', + is_active: false, + pill_count: '10K+', + items: [], + }, + { + title: 'Code', + icon: 'code', + link: '/search?scope=blobs&search=et', + is_active: false, + pill_count: '0', + items: [], + }, + { + title: 'Issues', + icon: 'issues', + link: '/search?scope=issues&search=et', + is_active: true, + pill_count: '2.4K', + items: [], + }, + { + title: 'Merge requests', + icon: 'merge-request', + link: '/search?scope=merge_requests&search=et', + is_active: false, + pill_count: '0', + items: [], + }, + { + title: 'Wiki', + icon: 'overview', + link: '/search?scope=wiki_blobs&search=et', + is_active: false, + pill_count: '0', + items: [], + }, + { + title: 'Commits', + icon: 'commit', + link: '/search?scope=commits&search=et', + is_active: false, + pill_count: '0', + items: [], + }, + { + title: 'Comments', + icon: 'comments', + link: '/search?scope=notes&search=et', + is_active: false, + pill_count: '0', + items: [], + }, + { + title: 'Milestones', + icon: 'tag', + link: '/search?scope=milestones&search=et', + is_active: false, + pill_count: '0', + items: [], + }, + { + title: 'Users', + icon: 'users', + link: '/search?scope=users&search=et', + is_active: false, + pill_count: '0', + items: [], + }, +]; diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js index 8a35ae96d5e..963b73aeae5 100644 --- a/spec/frontend/search/sidebar/components/app_spec.js +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -5,7 +5,7 @@ import { MOCK_QUERY } from 'jest/search/mock_data'; import GlobalSearchSidebar from '~/search/sidebar/components/app.vue'; import ResultsFilters from '~/search/sidebar/components/results_filters.vue'; import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue'; -import LanguageFilter from '~/search/sidebar/components/language_filter.vue'; +import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue'; Vue.use(Vuex); diff --git a/spec/frontend/search/sidebar/components/checkbox_filter_spec.js b/spec/frontend/search/sidebar/components/checkbox_filter_spec.js index f7b35c7bb14..3907e199cae 100644 --- a/spec/frontend/search/sidebar/components/checkbox_filter_spec.js +++ b/spec/frontend/search/sidebar/components/checkbox_filter_spec.js @@ -1,17 +1,22 @@ import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { MOCK_QUERY, MOCK_LANGUAGE_AGGREGATIONS_BUCKETS } from 'jest/search/mock_data'; -import CheckboxFilter from '~/search/sidebar/components/checkbox_filter.vue'; +import CheckboxFilter, { + TRACKING_LABEL_CHECKBOX, + TRACKING_LABEL_SET, +} from '~/search/sidebar/components/checkbox_filter.vue'; -import { languageFilterData } from '~/search/sidebar/constants/language_filter_data'; +import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { convertFiltersData } from '~/search/sidebar/utils'; Vue.use(Vuex); describe('CheckboxFilter', () => { let wrapper; + let trackingSpy; const actionSpies = { setQuery: jest.fn(), @@ -23,9 +28,10 @@ describe('CheckboxFilter', () => { const defaultProps = { filtersData: convertFiltersData(MOCK_LANGUAGE_AGGREGATIONS_BUCKETS), + trackingNamespace: 'testNameSpace', }; - const createComponent = () => { + const createComponent = (Props = defaultProps) => { const store = new Vuex.Store({ state: { query: MOCK_QUERY, @@ -37,13 +43,13 @@ describe('CheckboxFilter', () => { wrapper = shallowMountExtended(CheckboxFilter, { store, propsData: { - ...defaultProps, + ...Props, }, }); }; - beforeEach(() => { - createComponent(); + afterEach(() => { + unmockTracking(); }); const findFormCheckboxGroup = () => wrapper.findComponent(GlFormCheckboxGroup); @@ -52,6 +58,11 @@ describe('CheckboxFilter', () => { const fintAllCheckboxLabelCounts = () => wrapper.findAllByTestId('labelCount'); describe('Renders correctly', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); + }); + it('renders form', () => { expect(findFormCheckboxGroup().exists()).toBe(true); }); @@ -76,15 +87,34 @@ describe('CheckboxFilter', () => { }); describe('actions', () => { - it('triggers setQuery', () => { - const filter = - defaultProps.filtersData.filters[Object.keys(defaultProps.filtersData.filters)[0]].value; - findFormCheckboxGroup().vm.$emit('input', filter); + const checkedLanguageName = MOCK_LANGUAGE_AGGREGATIONS_BUCKETS[0].key; + + beforeEach(() => { + defaultProps.filtersData = convertFiltersData(MOCK_LANGUAGE_AGGREGATIONS_BUCKETS.slice(0, 3)); + CheckboxFilter.computed.selectedFilter.get = jest.fn(() => checkedLanguageName); + + createComponent(); + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); + findFormCheckboxGroup().vm.$emit('input', checkedLanguageName); + }); + it('triggers setQuery', () => { expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), { key: languageFilterData.filterParam, - value: filter, + value: checkedLanguageName, }); }); + + it('sends tracking information when setQuery', () => { + findFormCheckboxGroup().vm.$emit('input', checkedLanguageName); + expect(trackingSpy).toHaveBeenCalledWith( + defaultProps.trackingNamespace, + TRACKING_LABEL_CHECKBOX, + { + label: TRACKING_LABEL_SET, + property: checkedLanguageName, + }, + ); + }); }); }); diff --git a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js index 4f146757454..1f65884e959 100644 --- a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js +++ b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js @@ -1,25 +1,52 @@ import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue'; import RadioFilter from '~/search/sidebar/components/radio_filter.vue'; +Vue.use(Vuex); + describe('ConfidentialityFilter', () => { let wrapper; - const createComponent = (initProps) => { + const createComponent = (state) => { + const store = new Vuex.Store({ + state, + }); + wrapper = shallowMount(ConfidentialityFilter, { - ...initProps, + store, }); }; const findRadioFilter = () => wrapper.findComponent(RadioFilter); + const findHR = () => wrapper.findComponent('hr'); - describe('template', () => { + describe('old sidebar', () => { beforeEach(() => { - createComponent(); + createComponent({ useNewNavigation: false }); }); it('renders the component', () => { expect(findRadioFilter().exists()).toBe(true); }); + + it('renders the divider', () => { + expect(findHR().exists()).toBe(true); + }); + }); + + describe('new sidebar', () => { + beforeEach(() => { + createComponent({ useNewNavigation: true }); + }); + + it('renders the component', () => { + expect(findRadioFilter().exists()).toBe(true); + }); + + it("doesn't render the divider", () => { + expect(findHR().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/search/sidebar/components/language_filter_spec.js b/spec/frontend/search/sidebar/components/language_filter_spec.js index 17656ba749b..5821def5b43 100644 --- a/spec/frontend/search/sidebar/components/language_filter_spec.js +++ b/spec/frontend/search/sidebar/components/language_filter_spec.js @@ -1,20 +1,35 @@ import { GlAlert, GlFormCheckbox, GlForm } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { MOCK_QUERY, MOCK_AGGREGATIONS, MOCK_LANGUAGE_AGGREGATIONS_BUCKETS, } from 'jest/search/mock_data'; -import LanguageFilter from '~/search/sidebar/components/language_filter.vue'; +import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue'; import CheckboxFilter from '~/search/sidebar/components/checkbox_filter.vue'; -import { MAX_ITEM_LENGTH } from '~/search/sidebar/constants/language_filter_data'; + +import { + TRACKING_LABEL_SHOW_MORE, + TRACKING_CATEGORY, + TRACKING_PROPERTY_MAX, + TRACKING_LABEL_MAX, + TRACKING_LABEL_FILTERS, + TRACKING_ACTION_SHOW, + TRACKING_ACTION_CLICK, + TRACKING_LABEL_APPLY, + TRACKING_LABEL_ALL, +} from '~/search/sidebar/components/language_filter/tracking'; + +import { MAX_ITEM_LENGTH } from '~/search/sidebar/components/language_filter/data'; Vue.use(Vuex); describe('GlobalSearchSidebarLanguageFilter', () => { let wrapper; + let trackingSpy; const actionSpies = { fetchLanguageAggregation: jest.fn(), @@ -46,6 +61,10 @@ describe('GlobalSearchSidebarLanguageFilter', () => { }); }; + afterEach(() => { + unmockTracking(); + }); + const findForm = () => wrapper.findComponent(GlForm); const findCheckboxFilter = () => wrapper.findComponent(CheckboxFilter); const findApplyButton = () => wrapper.findByTestId('apply-button'); @@ -58,6 +77,7 @@ describe('GlobalSearchSidebarLanguageFilter', () => { describe('Renders correctly', () => { beforeEach(() => { createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); it('renders form', () => { @@ -130,20 +150,40 @@ describe('GlobalSearchSidebarLanguageFilter', () => { describe('Show All button works', () => { beforeEach(() => { createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); it(`renders ${MAX_ITEM_LENGTH} amount of items`, async () => { findShowMoreButton().vm.$emit('click'); + await nextTick(); + expect(findAllCheckboxes()).toHaveLength(MAX_ITEM_LENGTH); }); + it('sends tracking information when show more clicked', () => { + findShowMoreButton().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_CLICK, TRACKING_LABEL_SHOW_MORE, { + label: TRACKING_LABEL_ALL, + }); + }); + it(`renders more then ${MAX_ITEM_LENGTH} text`, async () => { findShowMoreButton().vm.$emit('click'); await nextTick(); expect(findHasOverMax().exists()).toBe(true); }); + it('sends tracking information when show more clicked and max item reached', () => { + findShowMoreButton().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_SHOW, TRACKING_LABEL_FILTERS, { + label: TRACKING_LABEL_MAX, + property: TRACKING_PROPERTY_MAX, + }); + }); + it(`doesn't render show more button after click`, async () => { findShowMoreButton().vm.$emit('click'); await nextTick(); @@ -154,6 +194,7 @@ describe('GlobalSearchSidebarLanguageFilter', () => { describe('actions', () => { beforeEach(() => { createComponent({}); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); it('uses getter languageAggregationBuckets', () => { @@ -169,5 +210,13 @@ describe('GlobalSearchSidebarLanguageFilter', () => { expect(actionSpies.applyQuery).toHaveBeenCalled(); }); + + it('sends tracking information clicking ApplyButton', () => { + findForm().vm.$emit('submit', { preventDefault: () => {} }); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, { + label: TRACKING_CATEGORY, + }); + }); }); }); diff --git a/spec/frontend/search/sidebar/components/scope_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_navigation_spec.js index 3b2d528e1d7..e8737384f27 100644 --- a/spec/frontend/search/sidebar/components/scope_navigation_spec.js +++ b/spec/frontend/search/sidebar/components/scope_navigation_spec.js @@ -121,4 +121,22 @@ describe('ScopeNavigation', () => { expect(findGlNavItemActive().findComponent(GlIcon).exists()).toBe(true); }); }); + + describe.each` + searchTherm | hasBeenCalled + ${null} | ${0} + ${'test'} | ${1} + `('fetchSidebarCount', ({ searchTherm, hasBeenCalled }) => { + beforeEach(() => { + createComponent({ + urlQuery: { + search: searchTherm, + }, + }); + }); + + it('is only called when search term is set', () => { + expect(actionSpies.fetchSidebarCount).toHaveBeenCalledTimes(hasBeenCalled); + }); + }); }); diff --git a/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js new file mode 100644 index 00000000000..105beae8638 --- /dev/null +++ b/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; +import { MOCK_QUERY, MOCK_NAVIGATION, MOCK_NAVIGATION_ITEMS } from '../../mock_data'; + +Vue.use(Vuex); + +describe('ScopeNewNavigation', () => { + let wrapper; + + const actionSpies = { + fetchSidebarCount: jest.fn(), + }; + + const getterSpies = { + currentScope: jest.fn(() => 'issues'), + navigationItems: jest.fn(() => MOCK_NAVIGATION_ITEMS), + }; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + urlQuery: MOCK_QUERY, + navigation: MOCK_NAVIGATION, + ...initialState, + }, + actions: actionSpies, + getters: getterSpies, + }); + + wrapper = shallowMount(ScopeNewNavigation, { + store, + stubs: { + NavItem, + }, + }); + }; + + const findNavElement = () => wrapper.findComponent('nav'); + const findNavItems = () => wrapper.findAllComponents(NavItem); + const findNavItemActive = () => wrapper.find('[aria-current=page]'); + const findNavItemActiveLabel = () => + findNavItemActive().find('[class="gl-pr-3 gl-text-gray-900 gl-truncate-end"]'); + + describe('scope navigation', () => { + beforeEach(() => { + createComponent({ urlQuery: { ...MOCK_QUERY, search: 'test' } }); + }); + + it('renders section', () => { + expect(findNavElement().exists()).toBe(true); + }); + + it('calls proper action when rendered', async () => { + await nextTick(); + expect(actionSpies.fetchSidebarCount).toHaveBeenCalled(); + }); + + it('renders all nav item components', () => { + expect(findNavItems()).toHaveLength(9); + }); + + it('has all proper links', () => { + const linkAtPosition = 3; + const { link } = MOCK_NAVIGATION[Object.keys(MOCK_NAVIGATION)[linkAtPosition]]; + + expect(findNavItems().at(linkAtPosition).findComponent('a').attributes('href')).toBe(link); + }); + }); + + describe('scope navigation sets proper state with url scope set', () => { + beforeEach(() => { + createComponent(); + }); + + it('has correct active item', () => { + expect(findNavItemActive().exists()).toBe(true); + expect(findNavItemActiveLabel().text()).toBe('Issues'); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/status_filter_spec.js b/spec/frontend/search/sidebar/components/status_filter_spec.js index 6704634ef36..a332a43e624 100644 --- a/spec/frontend/search/sidebar/components/status_filter_spec.js +++ b/spec/frontend/search/sidebar/components/status_filter_spec.js @@ -1,25 +1,52 @@ import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; import RadioFilter from '~/search/sidebar/components/radio_filter.vue'; import StatusFilter from '~/search/sidebar/components/status_filter.vue'; +Vue.use(Vuex); + describe('StatusFilter', () => { let wrapper; - const createComponent = (initProps) => { + const createComponent = (state) => { + const store = new Vuex.Store({ + state, + }); + wrapper = shallowMount(StatusFilter, { - ...initProps, + store, }); }; const findRadioFilter = () => wrapper.findComponent(RadioFilter); + const findHR = () => wrapper.findComponent('hr'); - describe('template', () => { + describe('old sidebar', () => { beforeEach(() => { - createComponent(); + createComponent({ useNewNavigation: false }); }); it('renders the component', () => { expect(findRadioFilter().exists()).toBe(true); }); + + it('renders the divider', () => { + expect(findHR().exists()).toBe(true); + }); + }); + + describe('new sidebar', () => { + beforeEach(() => { + createComponent({ useNewNavigation: true }); + }); + + it('renders the component', () => { + expect(findRadioFilter().exists()).toBe(true); + }); + + it("doesn't render the divider", () => { + expect(findHR().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/search/store/getters_spec.js b/spec/frontend/search/store/getters_spec.js index 0ef0922c4b0..e3b8e7575a4 100644 --- a/spec/frontend/search/store/getters_spec.js +++ b/spec/frontend/search/store/getters_spec.js @@ -10,6 +10,7 @@ import { MOCK_LANGUAGE_AGGREGATIONS_BUCKETS, TEST_FILTER_DATA, MOCK_NAVIGATION, + MOCK_NAVIGATION_ITEMS, } from '../mock_data'; describe('Global Search Store Getters', () => { @@ -68,4 +69,11 @@ describe('Global Search Store Getters', () => { expect(getters.currentUrlQueryHasLanguageFilters(state)).toBe(result); }); }); + + describe('navigationItems', () => { + it('returns the re-mapped navigation data', () => { + state.navigation = MOCK_NAVIGATION; + expect(getters.navigationItems(state)).toStrictEqual(MOCK_NAVIGATION_ITEMS); + }); + }); }); diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js index dfe4e801f11..802c5219799 100644 --- a/spec/frontend/search/store/utils_spec.js +++ b/spec/frontend/search/store/utils_spec.js @@ -8,6 +8,7 @@ import { formatSearchResultCount, getAggregationsUrl, prepareSearchAggregations, + addCountOverLimit, } from '~/search/store/utils'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { @@ -288,4 +289,18 @@ describe('Global Search Store Utils', () => { expect(prepareSearchAggregations({ query }, data)).toStrictEqual(result); }); }); + + describe('addCountOverLimit', () => { + it("should return '+' if count includes '+'", () => { + expect(addCountOverLimit('10+')).toEqual('+'); + }); + + it("should return empty string if count does not include '+'", () => { + expect(addCountOverLimit('10')).toEqual(''); + }); + + it('should return empty string if count is not provided', () => { + expect(addCountOverLimit()).toEqual(''); + }); + }); }); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js index 5e5f46ff34e..f7d847674eb 100644 --- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js +++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js @@ -189,7 +189,7 @@ describe('Global Search Searchable Dropdown', () => { findGlDropdown().vm.$emit('show'); }); - it('$emits @search and @first-open on the first open', async () => { + it('$emits @search and @first-open on the first open', () => { expect(wrapper.emitted('search')[0]).toStrictEqual(['']); expect(wrapper.emitted('first-open')[0]).toStrictEqual([]); }); diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js deleted file mode 100644 index dfb31eeda78..00000000000 --- a/spec/frontend/search_autocomplete_spec.js +++ /dev/null @@ -1,292 +0,0 @@ -import AxiosMockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import axios from '~/lib/utils/axios_utils'; -import initSearchAutocomplete from '~/search_autocomplete'; -import '~/lib/utils/common_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; - -describe('Search autocomplete dropdown', () => { - let widget = null; - - const userName = 'root'; - const userId = 1; - const dashboardIssuesPath = '/dashboard/issues'; - const dashboardMRsPath = '/dashboard/merge_requests'; - const projectIssuesPath = '/gitlab-org/gitlab-foss/issues'; - const projectMRsPath = '/gitlab-org/gitlab-foss/-/merge_requests'; - const groupIssuesPath = '/groups/gitlab-org/-/issues'; - const groupMRsPath = '/groups/gitlab-org/-/merge_requests'; - const autocompletePath = '/search/autocomplete'; - const projectName = 'GitLab Community Edition'; - const groupName = 'Gitlab Org'; - - const removeBodyAttributes = () => { - const { body } = document; - - delete body.dataset.page; - delete body.dataset.project; - delete body.dataset.group; - }; - - // Add required attributes to body before starting the test. - // section would be dashboard|group|project - const addBodyAttributes = (section = 'dashboard') => { - removeBodyAttributes(); - - const { body } = document; - switch (section) { - case 'dashboard': - body.dataset.page = 'root:index'; - break; - case 'group': - body.dataset.page = 'groups:show'; - body.dataset.group = 'gitlab-org'; - break; - case 'project': - body.dataset.page = 'projects:show'; - body.dataset.project = 'gitlab-ce'; - break; - default: - break; - } - }; - - const disableProjectIssues = () => { - document.querySelector('.js-search-project-options').dataset.issuesDisabled = true; - }; - - // Mock `gl` object in window for dashboard specific page. App code will need it. - const mockDashboardOptions = () => { - window.gl.dashboardOptions = { - issuesPath: dashboardIssuesPath, - mrPath: dashboardMRsPath, - }; - }; - - // Mock `gl` object in window for project specific page. App code will need it. - const mockProjectOptions = () => { - window.gl.projectOptions = { - 'gitlab-ce': { - issuesPath: projectIssuesPath, - mrPath: projectMRsPath, - projectName, - }, - }; - }; - - const mockGroupOptions = () => { - window.gl.groupOptions = { - 'gitlab-org': { - issuesPath: groupIssuesPath, - mrPath: groupMRsPath, - projectName: groupName, - }, - }; - }; - - const assertLinks = (list, issuesPath, mrsPath) => { - if (issuesPath) { - const issuesAssignedToMeLink = `a[href="${issuesPath}/?assignee_username=${userName}"]`; - const issuesIHaveCreatedLink = `a[href="${issuesPath}/?author_username=${userName}"]`; - - expect(list.find(issuesAssignedToMeLink).length).toBe(1); - expect(list.find(issuesAssignedToMeLink).text()).toBe('Issues assigned to me'); - expect(list.find(issuesIHaveCreatedLink).length).toBe(1); - expect(list.find(issuesIHaveCreatedLink).text()).toBe("Issues I've created"); - } - const mrsAssignedToMeLink = `a[href="${mrsPath}/?assignee_username=${userName}"]`; - const mrsIHaveCreatedLink = `a[href="${mrsPath}/?author_username=${userName}"]`; - - expect(list.find(mrsAssignedToMeLink).length).toBe(1); - expect(list.find(mrsAssignedToMeLink).text()).toBe('Merge requests assigned to me'); - expect(list.find(mrsIHaveCreatedLink).length).toBe(1); - expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created"); - }; - - beforeEach(() => { - loadHTMLFixture('static/search_autocomplete.html'); - - window.gon = {}; - window.gon.current_user_id = userId; - window.gon.current_username = userName; - window.gl = window.gl || (window.gl = {}); - - widget = initSearchAutocomplete({ autocompletePath }); - }); - - afterEach(() => { - // Undo what we did to the shared <body> - removeBodyAttributes(); - - resetHTMLFixture(); - }); - - it('should show Dashboard specific dropdown menu', () => { - addBodyAttributes(); - mockDashboardOptions(); - widget.searchInput.triggerHandler('focus'); - const list = widget.wrap.find('.dropdown-menu').find('ul'); - return assertLinks(list, dashboardIssuesPath, dashboardMRsPath); - }); - - it('should show Group specific dropdown menu', () => { - addBodyAttributes('group'); - mockGroupOptions(); - widget.searchInput.triggerHandler('focus'); - const list = widget.wrap.find('.dropdown-menu').find('ul'); - return assertLinks(list, groupIssuesPath, groupMRsPath); - }); - - it('should show Project specific dropdown menu', () => { - addBodyAttributes('project'); - mockProjectOptions(); - widget.searchInput.triggerHandler('focus'); - const list = widget.wrap.find('.dropdown-menu').find('ul'); - return assertLinks(list, projectIssuesPath, projectMRsPath); - }); - - it('should show only Project mergeRequest dropdown menu items when project issues are disabled', () => { - addBodyAttributes('project'); - disableProjectIssues(); - mockProjectOptions(); - widget.searchInput.triggerHandler('focus'); - const list = widget.wrap.find('.dropdown-menu').find('ul'); - assertLinks(list, null, projectMRsPath); - }); - - it('should not show category related menu if there is text in the input', () => { - addBodyAttributes('project'); - mockProjectOptions(); - widget.searchInput.val('help'); - widget.searchInput.triggerHandler('focus'); - const list = widget.wrap.find('.dropdown-menu').find('ul'); - const link = `a[href='${projectIssuesPath}/?assignee_username=${userName}']`; - - expect(list.find(link).length).toBe(0); - }); - - it('should not submit the search form when selecting an autocomplete row with the keyboard', () => { - const ENTER = 13; - const DOWN = 40; - addBodyAttributes(); - mockDashboardOptions(true); - const submitSpy = jest.spyOn(document.querySelector('form'), 'submit'); - widget.searchInput.triggerHandler('focus'); - widget.wrap.trigger($.Event('keydown', { which: DOWN })); - const enterKeyEvent = $.Event('keydown', { which: ENTER }); - widget.searchInput.trigger(enterKeyEvent); - - // This does not currently catch failing behavior. For security reasons, - // browsers will not trigger default behavior (form submit, in this - // example) on JavaScript-created keypresses. - expect(submitSpy).not.toHaveBeenCalled(); - }); - - describe('show autocomplete results', () => { - beforeEach(() => { - widget.enableAutocomplete(); - - const axiosMock = new AxiosMockAdapter(axios); - const autocompleteUrl = new RegExp(autocompletePath); - - axiosMock.onGet(autocompleteUrl).reply(HTTP_STATUS_OK, [ - { - category: 'Projects', - id: 1, - value: 'Gitlab Test', - label: 'Gitlab Org / Gitlab Test', - url: '/gitlab-org/gitlab-test', - avatar_url: '', - }, - { - category: 'Groups', - id: 1, - value: 'Gitlab Org', - label: 'Gitlab Org', - url: '/gitlab-org', - avatar_url: '', - }, - ]); - }); - - function triggerAutocomplete() { - return new Promise((resolve) => { - const dropdown = widget.searchInput.data('deprecatedJQueryDropdown'); - const filterCallback = dropdown.filter.options.callback; - dropdown.filter.options.callback = jest.fn((data) => { - filterCallback(data); - - resolve(); - }); - - widget.searchInput.val('Gitlab'); - widget.searchInput.triggerHandler('input'); - }); - } - - it('suggest Projects', async () => { - await triggerAutocomplete(); - - const list = widget.wrap.find('.dropdown-menu').find('ul'); - const link = "a[href$='/gitlab-org/gitlab-test']"; - - expect(list.find(link).length).toBe(1); - }); - - it('suggest Groups', async () => { - await triggerAutocomplete(); - - const list = widget.wrap.find('.dropdown-menu').find('ul'); - const link = "a[href$='/gitlab-org']"; - - expect(list.find(link).length).toBe(1); - }); - }); - - describe('disableAutocomplete', () => { - beforeEach(() => { - widget.enableAutocomplete(); - }); - - it('should close the Dropdown', () => { - const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown'); - - widget.dropdown.addClass('show'); - widget.disableAutocomplete(); - - expect(toggleSpy).toHaveBeenCalledWith('toggle'); - }); - }); - - describe('enableAutocomplete', () => { - let toggleSpy; - let trackingSpy; - - beforeEach(() => { - toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown'); - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); - document.body.dataset.page = 'some:page'; // default tracking for category - }); - - afterEach(() => { - unmockTracking(); - }); - - it('should open the Dropdown', () => { - widget.enableAutocomplete(); - - expect(toggleSpy).toHaveBeenCalledWith('toggle'); - }); - - it('should track the opening', () => { - widget.enableAutocomplete(); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_search_bar', { - label: 'main_navigation', - property: 'navigation', - }); - }); - }); -}); diff --git a/spec/frontend/search_autocomplete_utils_spec.js b/spec/frontend/search_autocomplete_utils_spec.js deleted file mode 100644 index 4fdec717e93..00000000000 --- a/spec/frontend/search_autocomplete_utils_spec.js +++ /dev/null @@ -1,114 +0,0 @@ -import { - isInGroupsPage, - isInProjectPage, - getGroupSlug, - getProjectSlug, -} from '~/search_autocomplete_utils'; - -describe('search_autocomplete_utils', () => { - let originalBody; - - beforeEach(() => { - originalBody = document.body; - document.body = document.createElement('body'); - }); - - afterEach(() => { - document.body = originalBody; - }); - - describe('isInGroupsPage', () => { - it.each` - page | result - ${'groups:index'} | ${true} - ${'groups:show'} | ${true} - ${'projects:show'} | ${false} - `(`returns $result in for page $page`, ({ page, result }) => { - document.body.dataset.page = page; - - expect(isInGroupsPage()).toBe(result); - }); - }); - - describe('isInProjectPage', () => { - it.each` - page | result - ${'projects:index'} | ${true} - ${'projects:show'} | ${true} - ${'groups:show'} | ${false} - `(`returns $result in for page $page`, ({ page, result }) => { - document.body.dataset.page = page; - - expect(isInProjectPage()).toBe(result); - }); - }); - - describe('getProjectSlug', () => { - it('returns null when no project is present or on project page', () => { - expect(getProjectSlug()).toBe(null); - }); - - it('returns null when not on project page', () => { - document.body.dataset.project = 'gitlab'; - - expect(getProjectSlug()).toBe(null); - }); - - it('returns null when project is missing', () => { - document.body.dataset.page = 'projects'; - - expect(getProjectSlug()).toBe(undefined); - }); - - it('returns project', () => { - document.body.dataset.page = 'projects'; - document.body.dataset.project = 'gitlab'; - - expect(getProjectSlug()).toBe('gitlab'); - }); - - it('returns project in edit page', () => { - document.body.dataset.page = 'projects:edit'; - document.body.dataset.project = 'gitlab'; - - expect(getProjectSlug()).toBe('gitlab'); - }); - }); - - describe('getGroupSlug', () => { - it('returns null when no group is present or on group page', () => { - expect(getGroupSlug()).toBe(null); - }); - - it('returns null when not on group page', () => { - document.body.dataset.group = 'gitlab-org'; - - expect(getGroupSlug()).toBe(null); - }); - - it('returns null when group is missing on groups page', () => { - document.body.dataset.page = 'groups'; - - expect(getGroupSlug()).toBe(undefined); - }); - - it('returns null when group is missing on project page', () => { - document.body.dataset.page = 'project'; - - expect(getGroupSlug()).toBe(null); - }); - - it.each` - page - ${'groups'} - ${'groups:edit'} - ${'projects'} - ${'projects:edit'} - `(`returns group in page $page`, ({ page }) => { - document.body.dataset.page = page; - document.body.dataset.group = 'gitlab-org'; - - expect(getGroupSlug()).toBe('gitlab-org'); - }); - }); -}); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 0ca350f9ed7..ec47cda1cb2 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -26,8 +26,6 @@ import { REPORT_TYPE_LICENSE_COMPLIANCE, REPORT_TYPE_SAST, } from '~/vue_shared/security_reports/constants'; -import { USER_FACING_ERROR_MESSAGE_PREFIX } from '~/lib/utils/error_message'; -import { manageViaMRErrorMessage } from '../constants'; const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; @@ -202,21 +200,20 @@ describe('App component', () => { }); }); - describe('when user facing error occurs', () => { + describe('when error occurs', () => { + const errorMessage = 'There was a manage via MR error'; + it('should show Alert with error Message', async () => { expect(findManageViaMRErrorAlert().exists()).toBe(false); - // Prefixed with USER_FACING_ERROR_MESSAGE_PREFIX as used in lib/gitlab/utils/error_message.rb to indicate a user facing error - findFeatureCards() - .at(1) - .vm.$emit('error', `${USER_FACING_ERROR_MESSAGE_PREFIX} ${manageViaMRErrorMessage}`); + findFeatureCards().at(1).vm.$emit('error', errorMessage); await nextTick(); expect(findManageViaMRErrorAlert().exists()).toBe(true); - expect(findManageViaMRErrorAlert().text()).toEqual(manageViaMRErrorMessage); + expect(findManageViaMRErrorAlert().text()).toBe(errorMessage); }); it('should hide Alert when it is dismissed', async () => { - findFeatureCards().at(1).vm.$emit('error', manageViaMRErrorMessage); + findFeatureCards().at(1).vm.$emit('error', errorMessage); await nextTick(); expect(findManageViaMRErrorAlert().exists()).toBe(true); @@ -226,17 +223,6 @@ describe('App component', () => { expect(findManageViaMRErrorAlert().exists()).toBe(false); }); }); - - describe('when non-user facing error occurs', () => { - it('should show Alert with generic error Message', async () => { - expect(findManageViaMRErrorAlert().exists()).toBe(false); - findFeatureCards().at(1).vm.$emit('error', manageViaMRErrorMessage); - - await nextTick(); - expect(findManageViaMRErrorAlert().exists()).toBe(true); - expect(findManageViaMRErrorAlert().text()).toEqual(i18n.genericErrorText); - }); - }); }); describe('Auto DevOps hint alert', () => { @@ -436,7 +422,7 @@ describe('App component', () => { describe('Vulnerability management', () => { const props = { securityTrainingEnabled: true }; - beforeEach(async () => { + beforeEach(() => { createComponent({ ...props, }); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index 23edd8a69de..983a66a7fd3 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -1,10 +1,15 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { securityFeatures } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; -import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; +import { + REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + REPORT_TYPE_SAST, + REPORT_TYPE_SAST_IAC, +} from '~/vue_shared/security_reports/constants'; import { manageViaMRErrorMessage } from '../constants'; import { makeFeature } from './utils'; @@ -265,6 +270,56 @@ describe('FeatureCard component', () => { expect(links.exists()).toBe(false); }); }); + + describe('given an available secondary with a configuration guide', () => { + beforeEach(() => { + feature = makeFeature({ + available: true, + configurationHelpPath: null, + secondary: { + name: 'secondary name', + description: 'secondary description', + configurationHelpPath: '/secondary', + configurationText: null, + }, + }); + createComponent({ feature }); + }); + + it('shows the secondary action', () => { + const links = findLinks({ + text: 'Configuration guide', + href: feature.secondary.configurationHelpPath, + }); + expect(links.exists()).toBe(true); + expect(links).toHaveLength(1); + }); + }); + + describe('given an unavailable secondary with a configuration guide', () => { + beforeEach(() => { + feature = makeFeature({ + available: false, + configurationHelpPath: null, + secondary: { + name: 'secondary name', + description: 'secondary description', + configurationHelpPath: '/secondary', + configurationText: null, + }, + }); + createComponent({ feature }); + }); + + it('does not show the secondary action', () => { + const links = findLinks({ + text: 'Configuration guide', + href: feature.secondary.configurationHelpPath, + }); + expect(links.exists()).toBe(false); + expect(links).toHaveLength(0); + }); + }); }); describe('information badge', () => { @@ -290,4 +345,48 @@ describe('FeatureCard component', () => { }); }); }); + + describe('status and badge', () => { + describe.each` + context | available | configured | expectedStatus + ${'configured BAS feature'} | ${true} | ${true} | ${null} + ${'unavailable BAS feature'} | ${false} | ${false} | ${'Available with Ultimate'} + ${'unconfigured BAS feature'} | ${true} | ${false} | ${null} + `('given $context', ({ available, configured, expectedStatus }) => { + beforeEach(() => { + const securityFeature = securityFeatures.find( + ({ type }) => REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION === type, + ); + feature = { ...securityFeature, available, configured }; + createComponent({ feature }); + }); + + it('should show an incubating feature badge', () => { + expect(findBadge().exists()).toBe(true); + }); + + if (expectedStatus) { + it(`should show the status "${expectedStatus}"`, () => { + expect(wrapper.findByTestId('feature-status').text()).toBe(expectedStatus); + }); + } + }); + + describe.each` + context | available | configured + ${'configured SAST IaC feature'} | ${true} | ${true} + ${'unavailable SAST IaC feature'} | ${false} | ${false} + ${'unconfigured SAST IaC feature'} | ${true} | ${false} + `('given $context', ({ available, configured }) => { + beforeEach(() => { + const securityFeature = securityFeatures.find(({ type }) => REPORT_TYPE_SAST_IAC === type); + feature = { ...securityFeature, available, configured }; + createComponent({ feature }); + }); + + it(`should not show a status`, () => { + expect(wrapper.findByTestId('feature-status').exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index 1f8f306c931..d35fc00057a 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -253,7 +253,7 @@ describe('TrainingProviderList component', () => { expect(findLogos().at(provider).attributes('role')).toBe('presentation'); }); - it.each(providerIndexArray)('renders the svg content for provider %s', async (provider) => { + it.each(providerIndexArray)('renders the svg content for provider %s', (provider) => { expect(findLogos().at(provider).html()).toContain( TEMP_PROVIDER_LOGOS[testProviderName[provider]].svg, ); diff --git a/spec/frontend/sentry/sentry_browser_wrapper_spec.js b/spec/frontend/sentry/sentry_browser_wrapper_spec.js index f4d646bab78..55354eceb8d 100644 --- a/spec/frontend/sentry/sentry_browser_wrapper_spec.js +++ b/spec/frontend/sentry/sentry_browser_wrapper_spec.js @@ -25,7 +25,7 @@ describe('SentryBrowserWrapper', () => { let mockCaptureMessage; let mockWithScope; - beforeEach(async () => { + beforeEach(() => { mockCaptureException = jest.fn(); mockCaptureMessage = jest.fn(); mockWithScope = jest.fn(); diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index e859d435f48..d1371ca0ef9 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -119,10 +119,21 @@ describe('Shortcuts', () => { }); describe('focusSearch', () => { - it('focuses the search bar', () => { - Shortcuts.focusSearch(createEvent('KeyboardEvent')); + describe('when super sidebar is NOT enabled', () => { + let originalGon; + beforeEach(() => { + originalGon = window.gon; + window.gon = { use_new_navigation: false }; + }); - expect(document.querySelector('#search').focus).toHaveBeenCalled(); + afterEach(() => { + window.gon = originalGon; + }); + + it('focuses the search bar', () => { + Shortcuts.focusSearch(createEvent('KeyboardEvent')); + expect(document.querySelector('#search').focus).toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js index 7895274ab6d..25a19b5808b 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js @@ -36,12 +36,13 @@ describe('Sidebar participant component', () => { createComponent(); expect(findAvatar().props('label')).toBe(user.name); + expect(wrapper.text()).not.toContain('Busy'); }); it('shows `Busy` status when user is busy', () => { createComponent({ status: { availability: 'BUSY' } }); - expect(findAvatar().props('label')).toBe(`${user.name} (Busy)`); + expect(wrapper.text()).toContain('Busy'); }); it('does not render a warning icon', () => { diff --git a/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js b/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js index 877d7cd61ee..e54ba31a30c 100644 --- a/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js +++ b/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js @@ -37,7 +37,7 @@ describe('UserNameWithStatus', () => { }); it('will render "Busy"', () => { - expect(wrapper.text()).toContain('(Busy)'); + expect(wrapper.text()).toContain('Busy'); }); }); @@ -49,7 +49,7 @@ describe('UserNameWithStatus', () => { }); it("renders user's name with pronouns", () => { - expect(wrapper.text()).toMatchInterpolatedText(`${name} (${pronouns})`); + expect(wrapper.text()).toMatchInterpolatedText(`${name}(${pronouns})`); }); }); diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js index b9c8655d5d8..8f82a2d1258 100644 --- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js +++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js @@ -11,7 +11,12 @@ import SidebarInheritDate from '~/sidebar/components/date/sidebar_inherit_date.v import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql'; import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; -import { issuableDueDateResponse, issuableStartDateResponse } from '../../mock_data'; +import issueDueDateSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql'; +import { + issuableDueDateResponse, + issuableStartDateResponse, + issueDueDateSubscriptionResponse, +} from '../../mock_data'; jest.mock('~/alert'); @@ -29,6 +34,7 @@ describe('Sidebar date Widget', () => { const createComponent = ({ dueDateQueryHandler = jest.fn().mockResolvedValue(issuableDueDateResponse()), startDateQueryHandler = jest.fn().mockResolvedValue(issuableStartDateResponse()), + dueDateSubscriptionHandler = jest.fn().mockResolvedValue(issueDueDateSubscriptionResponse()), canInherit = false, dateType = undefined, issuableType = 'issue', @@ -36,6 +42,7 @@ describe('Sidebar date Widget', () => { fakeApollo = createMockApollo([ [issueDueDateQuery, dueDateQueryHandler], [epicStartDateQuery, startDateQueryHandler], + [issueDueDateSubscription, dueDateSubscriptionHandler], ]); wrapper = shallowMount(SidebarDateWidget, { @@ -124,18 +131,30 @@ describe('Sidebar date Widget', () => { it('uses a correct prop to set the initial date and first day of the week for GlDatePicker', () => { expect(findDatePicker().props()).toMatchObject({ - value: null, + value: new Date(date), autocomplete: 'off', defaultDate: expect.any(Object), firstDay: window.gon.first_day_of_week, }); }); - it('renders GlDatePicker', async () => { + it('renders GlDatePicker', () => { expect(findDatePicker().exists()).toBe(true); }); }); + describe('real time issue due date feature', () => { + it('should call the subscription', async () => { + const dueDateSubscriptionHandler = jest + .fn() + .mockResolvedValue(issueDueDateSubscriptionResponse()); + createComponent({ dueDateSubscriptionHandler }); + await waitForPromises(); + + expect(dueDateSubscriptionHandler).toHaveBeenCalled(); + }); + }); + it.each` canInherit | component | componentName | expected ${true} | ${SidebarFormattedDate} | ${'SidebarFormattedDate'} | ${false} @@ -152,7 +171,7 @@ describe('Sidebar date Widget', () => { }, ); - it('does not render SidebarInheritDate when canInherit is true and date is loading', async () => { + it('does not render SidebarInheritDate when canInherit is true and date is loading', () => { createComponent({ canInherit: true }); expect(wrapper.findComponent(SidebarInheritDate).exists()).toBe(false); diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js index bb7554ff21d..7e53fcfe850 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js @@ -1,73 +1,70 @@ import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue'; import labelSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockConfig, mockSuggestedColors } from './mock_data'; Vue.use(Vuex); -const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store(labelSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownContentsCreateView, { - store, - }); -}; - describe('DropdownContentsCreateView', () => { let wrapper; const colors = Object.keys(mockSuggestedColors).map((color) => ({ [color]: mockSuggestedColors[color], })); + const createComponent = (initialState = mockConfig) => { + const store = new Vuex.Store(labelSelectModule()); + + store.dispatch('setInitialState', initialState); + + wrapper = shallowMountExtended(DropdownContentsCreateView, { + store, + }); + }; + + const findColorSelectorInput = () => wrapper.findByTestId('selected-color'); + const findLabelTitleInput = () => wrapper.findByTestId('label-title'); + const findCreateClickButton = () => wrapper.findByTestId('create-click'); + const findAllLinks = () => wrapper.find('.dropdown-content').findAllComponents(GlLink); + beforeEach(() => { gon.suggested_label_colors = mockSuggestedColors; - wrapper = createComponent(); + createComponent(); }); describe('computed', () => { describe('disableCreate', () => { it('returns `true` when label title and color is not defined', () => { - expect(wrapper.vm.disableCreate).toBe(true); + expect(findCreateClickButton().props('disabled')).toBe(true); }); it('returns `true` when `labelCreateInProgress` is true', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - labelTitle: 'Foo', - selectedColor: '#ff0000', - }); + await findColorSelectorInput().vm.$emit('input', '#ff0000'); + await findLabelTitleInput().vm.$emit('input', 'Foo'); wrapper.vm.$store.dispatch('requestCreateLabel'); await nextTick(); - expect(wrapper.vm.disableCreate).toBe(true); + + expect(findCreateClickButton().props('disabled')).toBe(true); }); it('returns `false` when label title and color is defined and create request is not already in progress', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - labelTitle: 'Foo', - selectedColor: '#ff0000', - }); + await findColorSelectorInput().vm.$emit('input', '#ff0000'); + await findLabelTitleInput().vm.$emit('input', 'Foo'); - await nextTick(); - expect(wrapper.vm.disableCreate).toBe(false); + expect(findCreateClickButton().props('disabled')).toBe(false); }); }); describe('suggestedColors', () => { it('returns array of color objects containing color code and name', () => { colors.forEach((color, index) => { - expect(wrapper.vm.suggestedColors[index]).toEqual(expect.objectContaining(color)); + expect(findAllLinks().at(index).attributes('title')).toBe(Object.values(color)[0]); }); }); }); @@ -82,29 +79,29 @@ describe('DropdownContentsCreateView', () => { describe('getColorName', () => { it('returns color name from color object', () => { + expect(findAllLinks().at(0).attributes('title')).toBe(Object.values(colors[0]).pop()); expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop()); }); }); describe('handleColorClick', () => { - it('sets provided `color` param to `selectedColor` prop', () => { - wrapper.vm.handleColorClick(colors[0]); + it('sets provided `color` param to `selectedColor` prop', async () => { + await findAllLinks() + .at(0) + .vm.$emit('click', { preventDefault: () => {} }); - expect(wrapper.vm.selectedColor).toBe(Object.keys(colors[0]).pop()); + expect(findColorSelectorInput().attributes('value')).toBe(Object.keys(colors[0]).pop()); }); }); describe('handleCreateClick', () => { it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', async () => { jest.spyOn(wrapper.vm, 'createLabel').mockImplementation(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - labelTitle: 'Foo', - selectedColor: '#ff0000', - }); - wrapper.vm.handleCreateClick(); + await findColorSelectorInput().vm.$emit('input', '#ff0000'); + await findLabelTitleInput().vm.$emit('input', 'Foo'); + + findCreateClickButton().vm.$emit('click'); await nextTick(); expect(wrapper.vm.createLabel).toHaveBeenCalledWith( @@ -154,20 +151,14 @@ describe('DropdownContentsCreateView', () => { }); it('renders color block element for all suggested colors', () => { - const colorBlocksEl = wrapper.find('.dropdown-content').findAllComponents(GlLink); - - colorBlocksEl.wrappers.forEach((colorBlock, index) => { + findAllLinks().wrappers.forEach((colorBlock, index) => { expect(colorBlock.attributes('style')).toContain('background-color'); expect(colorBlock.attributes('title')).toBe(Object.values(colors[index]).pop()); }); }); it('renders color input element', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - selectedColor: '#ff0000', - }); + await findColorSelectorInput().vm.$emit('input', '#ff0000'); await nextTick(); const colorPreviewEl = wrapper diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js index 4ca0a813da2..9c8d9656955 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js @@ -8,11 +8,13 @@ import { createAlert } from '~/alert'; import { workspaceLabelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql'; +import { DEFAULT_LABEL_COLOR } from '~/sidebar/components/labels/labels_select_widget/constants'; import { mockRegularLabel, mockSuggestedColors, createLabelSuccessfulResponse, workspaceLabelsQueryResponse, + workspaceLabelsQueryEmptyResponse, } from './mock_data'; jest.mock('~/alert'); @@ -61,14 +63,16 @@ describe('DropdownContentsCreateView', () => { mutationHandler = createLabelSuccessHandler, labelCreateType = 'project', workspaceType = 'project', + labelsResponse = workspaceLabelsQueryResponse, + searchTerm = '', } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); mockApollo.clients.defaultClient.cache.writeQuery({ query: workspaceLabelsQueries[workspaceType].query, - data: workspaceLabelsQueryResponse.data, + data: labelsResponse.data, variables: { fullPath: '', - searchTerm: '', + searchTerm, }, }); @@ -94,7 +98,7 @@ describe('DropdownContentsCreateView', () => { it('selects a color after clicking on colored block', async () => { createComponent(); - expect(findSelectedColor().attributes('style')).toBeUndefined(); + expect(findSelectedColorText().attributes('value')).toBe(DEFAULT_LABEL_COLOR); findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); await nextTick(); @@ -104,7 +108,7 @@ describe('DropdownContentsCreateView', () => { it('shows correct color hex code after selecting a color', async () => { createComponent(); - expect(findSelectedColorText().attributes('value')).toBe(''); + expect(findSelectedColorText().attributes('value')).toBe(DEFAULT_LABEL_COLOR); findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); await nextTick(); @@ -123,6 +127,7 @@ describe('DropdownContentsCreateView', () => { it('disables a Create button if color is not set', async () => { createComponent(); findLabelTitleInput().vm.$emit('input', 'Test title'); + findSelectedColorText().vm.$emit('input', ''); await nextTick(); expect(findCreateButton().props('disabled')).toBe(true); @@ -232,4 +237,21 @@ describe('DropdownContentsCreateView', () => { titleTakenError.data.labelCreate.errors[0], ); }); + + describe('when empty labels response', () => { + it('is able to create label with searched text when empty response', async () => { + createComponent({ searchTerm: '', labelsResponse: workspaceLabelsQueryEmptyResponse }); + + findLabelTitleInput().vm.$emit('input', 'random'); + + findCreateButton().vm.$emit('click'); + await waitForPromises(); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: DEFAULT_LABEL_COLOR, + projectPath: '', + title: 'random', + }); + }); + }); }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js index c351a60735b..1abc708b72f 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -96,11 +96,11 @@ describe('DropdownContentsLabelsView', () => { await waitForPromises(); }); - it('does not render loading icon', async () => { + it('does not render loading icon', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('renders labels list', async () => { + it('renders labels list', () => { expect(findLabelsList().exists()).toBe(true); expect(findLabels()).toHaveLength(2); }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js index 824f91812fb..4861d2ca55e 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js @@ -45,7 +45,7 @@ describe('DropdownHeader', () => { expect(findGoBackButton().exists()).toBe(true); }); - it('does not render search input field', async () => { + it('does not render search input field', () => { expect(findSearchInput().exists()).toBe(false); }); }); @@ -81,7 +81,7 @@ describe('DropdownHeader', () => { expect(findSearchInput().exists()).toBe(true); }); - it('does not render title', async () => { + it('does not render title', () => { expect(findDropdownTitle().exists()).toBe(false); }); }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js index 3101fd90f2e..b0a080ba1ef 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js @@ -199,7 +199,7 @@ describe('LabelsSelectRoot', () => { }); }); - it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', async () => { + it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', () => { const label = { id: 'gid://gitlab/ProjectLabel/1' }; createComponent({ config: { ...mockConfig, iid: undefined } }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js index 5d5a7e9a200..b0b473625bb 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js @@ -117,6 +117,17 @@ export const workspaceLabelsQueryResponse = { }, }; +export const workspaceLabelsQueryEmptyResponse = { + data: { + workspace: { + id: 'gid://gitlab/Project/126', + labels: { + nodes: [], + }, + }, + }, +}; + export const issuableLabelsQueryResponse = { data: { workspace: { diff --git a/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js b/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js index e247f5d27fa..7c168a0ac41 100644 --- a/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js +++ b/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js @@ -328,7 +328,7 @@ describe('IssuableMoveDropdown', () => { expect(axios.get).toHaveBeenCalled(); }); - it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => { + it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax wrapper.setData({ @@ -341,7 +341,7 @@ describe('IssuableMoveDropdown', () => { expect(wrapper.vm.projectItemClick).toBe(false); }); - it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', async () => { + it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', () => { findDropdownEl().vm.$emit('hide'); expect(wrapper.emitted('dropdown-close')).toHaveLength(1); diff --git a/spec/frontend/sidebar/components/move/move_issue_button_spec.js b/spec/frontend/sidebar/components/move/move_issue_button_spec.js index eb5e23c6047..e2f5414056a 100644 --- a/spec/frontend/sidebar/components/move/move_issue_button_spec.js +++ b/spec/frontend/sidebar/components/move/move_issue_button_spec.js @@ -71,10 +71,6 @@ describe('MoveIssueButton', () => { }); }; - afterEach(() => { - fakeApollo = null; - }); - it('renders the project select dropdown', () => { createComponent(); diff --git a/spec/frontend/sidebar/components/move/move_issues_button_spec.js b/spec/frontend/sidebar/components/move/move_issues_button_spec.js index 662a39c829d..2c7982a4b7f 100644 --- a/spec/frontend/sidebar/components/move/move_issues_button_spec.js +++ b/spec/frontend/sidebar/components/move/move_issues_button_spec.js @@ -346,7 +346,7 @@ describe('MoveIssuesButton', () => { expect(issuableEventHub.$emit).not.toHaveBeenCalled(); }); - it('emits `issuables:bulkMoveStarted` when issues are moving', async () => { + it('emits `issuables:bulkMoveStarted` when issues are moving', () => { createComponent({ selectedIssuables: selectedIssuesMocks.issuesTasksAndTestCases }); emitMoveIssuablesEvent(); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index 483449f924b..66bc1f393ae 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -1,9 +1,10 @@ import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue'; import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue'; -const userDataMock = () => ({ +const userDataMock = ({ approved = false } = {}) => ({ id: 1, name: 'Root', state: 'active', @@ -14,14 +15,21 @@ const userDataMock = () => ({ canMerge: true, canUpdate: true, reviewed: true, - approved: false, + approved, }, }); describe('UncollapsedReviewerList component', () => { let wrapper; - const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]'); + const findAllRerequestButtons = () => wrapper.findAll('[data-testid="re-request-button"]'); + const findAllReviewerApprovalIcons = () => wrapper.findAll('[data-testid="approved"]'); + const findAllReviewedNotApprovedIcons = () => + wrapper.findAll('[data-testid="reviewed-not-approved"]'); + const findAllReviewerAvatarLinks = () => wrapper.findAllComponents(ReviewerAvatarLink); + + const hasApprovalIconAnimation = () => + findAllReviewerApprovalIcons().at(0).classes('merge-request-approved-icon'); function createComponent(props = {}, glFeatures = {}) { const propsData = { @@ -48,27 +56,17 @@ describe('UncollapsedReviewerList component', () => { }); it('only has one user', () => { - expect(wrapper.findAllComponents(ReviewerAvatarLink).length).toBe(1); + expect(findAllReviewerAvatarLinks()).toHaveLength(1); }); it('shows one user with avatar, and author name', () => { - expect(wrapper.text()).toContain(user.name); + expect(wrapper.text()).toBe(user.name); }); it('renders re-request loading icon', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - await wrapper.setData({ loadingStates: { 1: 'loading' } }); - - expect(wrapper.find('[data-testid="re-request-button"]').props('loading')).toBe(true); - }); - - it('renders re-request success icon', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - await wrapper.setData({ loadingStates: { 1: 'success' } }); + await findAllRerequestButtons().at(0).vm.$emit('click'); - expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true); + expect(findAllRerequestButtons().at(0).props('loading')).toBe(true); }); }); @@ -84,51 +82,126 @@ describe('UncollapsedReviewerList component', () => { approved: true, }, }; + const user3 = { + ...user, + id: 3, + name: 'lizabeth-wilderman', + username: 'lizabeth-wilderman', + mergeRequestInteraction: { + ...user.mergeRequestInteraction, + approved: false, + reviewed: true, + }, + }; beforeEach(() => { createComponent({ - users: [user, user2], + users: [user, user2, user3], }); }); - it('has both users', () => { - expect(wrapper.findAllComponents(ReviewerAvatarLink).length).toBe(2); + it('has three users', () => { + expect(findAllReviewerAvatarLinks()).toHaveLength(3); }); - it('shows both users with avatar, and author name', () => { + it('shows all users with avatar, and author name', () => { expect(wrapper.text()).toContain(user.name); expect(wrapper.text()).toContain(user2.name); + expect(wrapper.text()).toContain(user3.name); }); it('renders approval icon', () => { - expect(reviewerApprovalIcons().length).toBe(1); + expect(findAllReviewerApprovalIcons()).toHaveLength(1); }); it('shows that hello-world approved', () => { - const icon = reviewerApprovalIcons().at(0); + const icon = findAllReviewerApprovalIcons().at(0); + + expect(icon.attributes('title')).toBe('Approved by @hello-world'); + }); + + it('shows that lizabeth-wilderman reviewed but did not approve', () => { + const icon = findAllReviewedNotApprovedIcons().at(1); - expect(icon.attributes('title')).toEqual('Approved by @hello-world'); + expect(icon.attributes('title')).toBe('Reviewed by @lizabeth-wilderman but not yet approved'); }); it('renders re-request loading icon', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - await wrapper.setData({ loadingStates: { 2: 'loading' } }); + await findAllRerequestButtons().at(1).vm.$emit('click'); - expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(2); - expect(wrapper.findAll('[data-testid="re-request-button"]').at(1).props('loading')).toBe( - true, - ); + const allRerequestButtons = findAllRerequestButtons(); + + expect(allRerequestButtons).toHaveLength(3); + expect(allRerequestButtons.at(1).props('loading')).toBe(true); + }); + }); + + describe('when updating reviewers list', () => { + it('does not animate icon on initial page load', () => { + const user = userDataMock({ approved: true }); + createComponent({ users: [user] }); + + expect(hasApprovalIconAnimation()).toBe(false); }); - it('renders re-request success icon', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - await wrapper.setData({ loadingStates: { 2: 'success' } }); + it('does not animate icon when adding a new reviewer', async () => { + const user = userDataMock({ approved: true }); + const anotherUser = { ...user, id: 2 }; + createComponent({ users: [user] }); + + await wrapper.setProps({ users: [user, anotherUser] }); + + expect( + findAllReviewerApprovalIcons().wrappers.every((w) => + w.classes('merge-request-approved-icon'), + ), + ).toBe(false); + }); + + it('removes animation CSS class after 1500ms', async () => { + const previousUserState = userDataMock({ approved: false }); + const currentUserState = userDataMock({ approved: true }); + + createComponent({ + users: [previousUserState], + }); - expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(1); - expect(wrapper.findAll('[data-testid="re-request-success"]').length).toBe(1); - expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true); + await wrapper.setProps({ + users: [currentUserState], + }); + + expect(hasApprovalIconAnimation()).toBe(true); + + jest.advanceTimersByTime(1500); + await nextTick(); + + expect(findAllReviewerApprovalIcons().at(0).classes('merge-request-approved-icon')).toBe( + false, + ); + }); + + describe('when reviewer was present in the list', () => { + it.each` + previousApprovalState | currentApprovalState | shouldAnimate + ${false} | ${true} | ${true} + ${true} | ${true} | ${false} + `( + 'when approval state changes from $previousApprovalState to $currentApprovalState', + async ({ previousApprovalState, currentApprovalState, shouldAnimate }) => { + const previousUserState = userDataMock({ approved: previousApprovalState }); + const currentUserState = userDataMock({ approved: currentApprovalState }); + + createComponent({ + users: [previousUserState], + }); + + await wrapper.setProps({ + users: [currentUserState], + }); + + expect(hasApprovalIconAnimation()).toBe(shouldAnimate); + }, + ); }); }); }); diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js index 8a27e82093d..bee90d2b2b6 100644 --- a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js +++ b/spec/frontend/sidebar/components/severity/sidebar_severity_widget_spec.js @@ -1,5 +1,7 @@ import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; @@ -11,15 +13,18 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit jest.mock('~/alert'); -describe('SidebarSeverity', () => { +Vue.use(VueApollo); + +describe('SidebarSeverityWidget', () => { let wrapper; - let mutate; + let mockApollo; const projectPath = 'gitlab-org/gitlab-test'; const iid = '1'; const severity = 'CRITICAL'; - let canUpdate = true; - function createComponent(props = {}) { + function createComponent({ props, canUpdate = true, mutationMock } = {}) { + mockApollo = createMockApollo([[updateIssuableSeverity, mutationMock]]); + const propsData = { projectPath, iid, @@ -27,102 +32,101 @@ describe('SidebarSeverity', () => { initialSeverity: severity, ...props, }; - mutate = jest.fn(); + wrapper = mountExtended(SidebarSeverityWidget, { propsData, provide: { canUpdate, }, - mocks: { - $apollo: { - mutate, - }, - }, + apolloProvider: mockApollo, stubs: { GlSprintf, }, }); } - beforeEach(() => { - createComponent(); + afterEach(() => { + mockApollo = null; }); const findSeverityToken = () => wrapper.findAllComponents(SeverityToken); const findEditBtn = () => wrapper.findByTestId('edit-button'); const findDropdown = () => wrapper.findComponent(GlDropdown); - const findCriticalSeverityDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findCriticalSeverityDropdownItem = () => wrapper.findComponent(GlDropdownItem); // First dropdown item is critical severity const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findTooltip = () => wrapper.findComponent(GlTooltip); const findCollapsedSeverity = () => wrapper.findComponent({ ref: 'severity' }); describe('Severity widget', () => { it('renders severity dropdown and token', () => { + createComponent(); + expect(findSeverityToken().exists()).toBe(true); expect(findDropdown().exists()).toBe(true); }); describe('edit button', () => { it('is rendered when `canUpdate` provided as `true`', () => { + createComponent(); + expect(findEditBtn().exists()).toBe(true); }); it('is NOT rendered when `canUpdate` provided as `false`', () => { - canUpdate = false; - createComponent(); + createComponent({ canUpdate: false }); + expect(findEditBtn().exists()).toBe(false); }); }); }); describe('Update severity', () => { - it('calls `$apollo.mutate` with `updateIssuableSeverity`', () => { - jest - .spyOn(wrapper.vm.$apollo, 'mutate') - .mockResolvedValueOnce({ data: { issueSetSeverity: { issue: { severity } } } }); + it('calls mutate with `updateIssuableSeverity`', () => { + const mutationMock = jest.fn().mockResolvedValue({ + data: { issueSetSeverity: { issue: { severity } } }, + }); + createComponent({ mutationMock }); findCriticalSeverityDropdownItem().vm.$emit('click'); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateIssuableSeverity, - variables: { - iid, - projectPath, - severity, - }, + + expect(mutationMock).toHaveBeenCalledWith({ + iid, + projectPath, + severity, }); }); it('shows error alert when severity update fails', async () => { - const errorMsg = 'Something went wrong'; - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValueOnce(errorMsg); - findCriticalSeverityDropdownItem().vm.$emit('click'); + const mutationMock = jest.fn().mockRejectedValue('Something went wrong'); + createComponent({ mutationMock }); + findCriticalSeverityDropdownItem().vm.$emit('click'); await waitForPromises(); expect(createAlert).toHaveBeenCalled(); }); it('shows loading icon while updating', async () => { - let resolvePromise; - wrapper.vm.$apollo.mutate = jest.fn( - () => - new Promise((resolve) => { - resolvePromise = resolve; - }), - ); - findCriticalSeverityDropdownItem().vm.$emit('click'); + const mutationMock = jest.fn().mockRejectedValue({}); + createComponent({ mutationMock }); + findCriticalSeverityDropdownItem().vm.$emit('click'); await nextTick(); + expect(findLoadingIcon().exists()).toBe(true); - resolvePromise(); await waitForPromises(); + expect(findLoadingIcon().exists()).toBe(false); }); }); describe('Switch between collapsed/expanded view of the sidebar', () => { describe('collapsed', () => { + beforeEach(() => { + createComponent({ canUpdate: false }); + }); + it('should have collapsed icon class', () => { expect(findCollapsedSeverity().classes('sidebar-collapsed-icon')).toBe(true); }); @@ -136,17 +140,19 @@ describe('SidebarSeverity', () => { describe('expanded', () => { it('toggles dropdown with edit button', async () => { - canUpdate = true; createComponent(); await nextTick(); + expect(findDropdown().isVisible()).toBe(false); findEditBtn().vm.$emit('click'); await nextTick(); + expect(findDropdown().isVisible()).toBe(true); findEditBtn().vm.$emit('click'); await nextTick(); + expect(findDropdown().isVisible()).toBe(false); }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index 53d81e3fcaf..27ab347775a 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlLink, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlLink, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { shallowMount, mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -133,7 +133,7 @@ describe('SidebarDropdownWidget', () => { $apollo: { mutate: mutationPromise(), queries: { - currentAttribute: { loading: false }, + issuable: { loading: false }, attributesList: { loading: false }, ...queries, }, @@ -145,26 +145,22 @@ describe('SidebarDropdownWidget', () => { stubs: { SidebarEditableItem, GlSearchBoxByType, - GlDropdown, }, }), ); wrapper.vm.$refs.dropdown.show = jest.fn(); - - // We need to mock out `showDropdown` which - // invokes `show` method of BDropdown used inside GlDropdown. - jest.spyOn(wrapper.vm, 'showDropdown').mockImplementation(); }; describe('when not editing', () => { beforeEach(() => { createComponent({ data: { - currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl', dueDate: '2021-09-09' }, + issuable: { + attribute: { id: 'id', title: 'title', webUrl: 'webUrl', dueDate: '2021-09-09' }, + }, }, stubs: { - GlDropdown, SidebarEditableItem, }, }); @@ -185,7 +181,7 @@ describe('SidebarDropdownWidget', () => { it('shows a loading spinner while fetching the current attribute', () => { createComponent({ queries: { - currentAttribute: { loading: true }, + issuable: { loading: true }, }, }); @@ -199,7 +195,7 @@ describe('SidebarDropdownWidget', () => { selectedTitle: 'Some milestone title', }, queries: { - currentAttribute: { loading: false }, + issuable: { loading: false }, }, }); @@ -224,10 +220,10 @@ describe('SidebarDropdownWidget', () => { createComponent({ data: { hasCurrentAttribute: true, - currentAttribute: null, + issuable: {}, }, queries: { - currentAttribute: { loading: false }, + issuable: { loading: false }, }, }); @@ -251,7 +247,9 @@ describe('SidebarDropdownWidget', () => { { id: '123', title: '123' }, { id: 'id', title: 'title' }, ], - currentAttribute: { id: '123' }, + issuable: { + attribute: { id: '123' }, + }, }, mutationPromise: mutationResp, }); diff --git a/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js index cae21189ee0..b644b7a9421 100644 --- a/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js +++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js @@ -1,23 +1,24 @@ import { GlToggle } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; import eventHub from '~/sidebar/event_hub'; describe('Subscriptions', () => { let wrapper; + let trackingSpy; const findToggleButton = () => wrapper.findComponent(GlToggle); + const findTooltip = () => wrapper.findComponent({ ref: 'tooltip' }); - const mountComponent = (propsData) => - extendedWrapper( - shallowMount(Subscriptions, { - propsData, - }), - ); + const mountComponent = (propsData) => { + wrapper = shallowMountExtended(Subscriptions, { + propsData, + }); + }; it('shows loading spinner when loading', () => { - wrapper = mountComponent({ + mountComponent({ loading: true, subscribed: undefined, }); @@ -26,7 +27,7 @@ describe('Subscriptions', () => { }); it('is toggled "off" when currently not subscribed', () => { - wrapper = mountComponent({ + mountComponent({ subscribed: false, }); @@ -34,7 +35,7 @@ describe('Subscriptions', () => { }); it('is toggled "on" when currently subscribed', () => { - wrapper = mountComponent({ + mountComponent({ subscribed: true, }); @@ -43,44 +44,38 @@ describe('Subscriptions', () => { it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => { const id = 42; - wrapper = mountComponent({ subscribed: true, id }); + mountComponent({ subscribed: true, id }); const eventHubSpy = jest.spyOn(eventHub, '$emit'); - const wrapperEmitSpy = jest.spyOn(wrapper.vm, '$emit'); - wrapper.vm.toggleSubscription(); + findToggleButton().vm.$emit('change'); expect(eventHubSpy).toHaveBeenCalledWith('toggleSubscription', id); - expect(wrapperEmitSpy).toHaveBeenCalledWith('toggleSubscription', id); - eventHubSpy.mockRestore(); - wrapperEmitSpy.mockRestore(); + expect(wrapper.emitted('toggleSubscription')).toEqual([[id]]); }); it('tracks the event when toggled', () => { - wrapper = mountComponent({ subscribed: true }); - - const wrapperTrackSpy = jest.spyOn(wrapper.vm, 'track'); + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + mountComponent({ subscribed: true }); - wrapper.vm.toggleSubscription(); + findToggleButton().vm.$emit('change'); - expect(wrapperTrackSpy).toHaveBeenCalledWith('toggle_button', { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'toggle_button', { + category: undefined, + label: 'right_sidebar', property: 'notifications', value: 0, }); - wrapperTrackSpy.mockRestore(); }); it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => { - wrapper = mountComponent({ subscribed: true }); - const spy = jest.spyOn(wrapper.vm, '$emit'); - - wrapper.vm.onClickCollapsedIcon(); + mountComponent({ subscribed: true }); + findTooltip().trigger('click'); - expect(spy).toHaveBeenCalledWith('toggleSidebar'); - spy.mockRestore(); + expect(wrapper.emitted('toggleSidebar')).toHaveLength(1); }); it('has visually hidden label', () => { - wrapper = mountComponent(); + mountComponent(); expect(findToggleButton().props()).toMatchObject({ label: 'Notifications', @@ -92,7 +87,7 @@ describe('Subscriptions', () => { const subscribeDisabledDescription = 'Notifications have been disabled'; beforeEach(() => { - wrapper = mountComponent({ + mountComponent({ subscribed: false, projectEmailsDisabled: true, subscribeDisabledDescription, @@ -103,9 +98,7 @@ describe('Subscriptions', () => { expect(wrapper.findByTestId('subscription-title').text()).toContain( subscribeDisabledDescription, ); - expect(wrapper.findComponent({ ref: 'tooltip' }).attributes('title')).toBe( - subscribeDisabledDescription, - ); + expect(findTooltip().attributes('title')).toBe(subscribeDisabledDescription); }); it('does not render the toggle button', () => { diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index 91c013596d7..e23d24f9629 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -144,7 +144,7 @@ describe('Issuable Time Tracker', () => { }); describe('Comparison pane when limitToHours is true', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ props: { limitToHours: true, diff --git a/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js b/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js index 0370d5e337d..8e34a612705 100644 --- a/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js +++ b/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js @@ -25,7 +25,7 @@ describe('ToggleSidebar', () => { expect(findGlButton().props('icon')).toBe('chevron-double-lg-left'); }); - it('should render the "chevron-double-lg-right" icon when expanded', async () => { + it('should render the "chevron-double-lg-right" icon when expanded', () => { createComponent({ props: { collapsed: false } }); expect(findGlButton().props('icon')).toBe('chevron-double-lg-right'); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 844320efc1c..05a7f504fd4 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -249,6 +249,17 @@ export const issuableDueDateResponse = (dueDate = null) => ({ }, }); +export const issueDueDateSubscriptionResponse = () => ({ + data: { + issuableDatesUpdated: { + issue: { + id: 'gid://gitlab/Issue/4', + dueDate: '2022-12-31', + }, + }, + }, +}); + export const issuableStartDateResponse = (startDate = null) => ({ data: { workspace: { diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index f4ebc5c3e3f..ed54582ca29 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -13,7 +13,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = target="_blank" > <gl-icon-stub - name="question" + name="question-o" size="12" /> </gl-link-stub> diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 0d0e78e9179..957cfc0bc22 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -256,7 +256,7 @@ describe('Snippet Edit app', () => { VISIBILITY_LEVEL_PRIVATE_STRING, VISIBILITY_LEVEL_INTERNAL_STRING, VISIBILITY_LEVEL_PUBLIC_STRING, - ])('marks %s visibility by default', async (visibility) => { + ])('marks %s visibility by default', (visibility) => { createComponent({ props: { snippetGid: '' }, selectedLevel: visibility, diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index 840bca8c9c8..05ff64c2296 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -1,5 +1,6 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; import { Blob as BlobMock, SimpleViewerMock, @@ -7,6 +8,7 @@ import { RichBlobContentMock, SimpleBlobContentMock, } from 'jest/blob/components/mock_data'; +import GetBlobContent from 'shared_queries/snippet/snippet_blob_content.query.graphql'; import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; import { @@ -17,9 +19,13 @@ import { import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue'; import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants'; import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; describe('Blob Embeddable', () => { let wrapper; + let requestHandlers; + const snippet = { id: 'gid://foo.bar/snippet', webUrl: 'https://foo.bar', @@ -29,23 +35,47 @@ describe('Blob Embeddable', () => { activeViewerType: SimpleViewerMock.type, }; + const mockDefaultHandler = ({ path, nodes } = { path: BlobMock.path }) => { + const renderedNodes = nodes || [ + { __typename: 'Blob', path, richData: 'richData', plainData: 'plainData' }, + ]; + + return jest.fn().mockResolvedValue({ + data: { + snippets: { + __typename: 'Snippet', + id: '1', + nodes: [ + { + __typename: 'Snippet', + id: '2', + blobs: { + __typename: 'Blob', + hasUnretrievableBlobs: false, + nodes: renderedNodes, + }, + }, + ], + }, + }, + }); + }; + + const createMockApolloProvider = (handler) => { + Vue.use(VueApollo); + + requestHandlers = handler; + return createMockApollo([[GetBlobContent, requestHandlers]]); + }; + function createComponent({ snippetProps = {}, data = dataMock, blob = BlobMock, - contentLoading = false, + handler = mockDefaultHandler(), } = {}) { - const $apollo = { - queries: { - blobContent: { - loading: contentLoading, - refetch: jest.fn(), - skip: true, - }, - }, - }; - - wrapper = mount(SnippetBlobView, { + wrapper = shallowMount(SnippetBlobView, { + apolloProvider: createMockApolloProvider(handler), propsData: { snippet: { ...snippet, @@ -58,41 +88,56 @@ describe('Blob Embeddable', () => { ...data, }; }, - mocks: { $apollo }, + stubs: { + BlobHeader, + BlobContent, + }, }); } + const findBlobHeader = () => wrapper.findComponent(BlobHeader); + const findBlobContent = () => wrapper.findComponent(BlobContent); + const findSimpleViewer = () => wrapper.findComponent(SimpleViewer); + const findRichViewer = () => wrapper.findComponent(RichViewer); + describe('rendering', () => { it('renders correct components', () => { createComponent(); - expect(wrapper.findComponent(BlobHeader).exists()).toBe(true); - expect(wrapper.findComponent(BlobContent).exists()).toBe(true); + expect(findBlobHeader().exists()).toBe(true); + expect(findBlobContent().exists()).toBe(true); }); - it('sets simple viewer correctly', () => { + it('sets simple viewer correctly', async () => { createComponent(); - expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); + await waitForPromises(); + + expect(findSimpleViewer().exists()).toBe(true); }); - it('sets rich viewer correctly', () => { + it('sets rich viewer correctly', async () => { const data = { ...dataMock, activeViewerType: RichViewerMock.type }; createComponent({ data, }); - expect(wrapper.findComponent(RichViewer).exists()).toBe(true); + await waitForPromises(); + expect(findRichViewer().exists()).toBe(true); }); it('correctly switches viewer type', async () => { createComponent(); - expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); + await waitForPromises(); + + expect(findSimpleViewer().exists()).toBe(true); - wrapper.vm.switchViewer(RichViewerMock.type); + findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, RichViewerMock.type); + await waitForPromises(); - await nextTick(); - expect(wrapper.findComponent(RichViewer).exists()).toBe(true); - await wrapper.vm.switchViewer(SimpleViewerMock.type); + expect(findRichViewer().exists()).toBe(true); - expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); + findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, SimpleViewerMock.type); + await waitForPromises(); + + expect(findSimpleViewer().exists()).toBe(true); }); it('passes information about render error down to blob header', () => { @@ -106,7 +151,7 @@ describe('Blob Embeddable', () => { }, }); - expect(wrapper.findComponent(BlobHeader).props('hasRenderError')).toBe(true); + expect(findBlobHeader().props('hasRenderError')).toBe(true); }); describe('bob content in multi-file scenario', () => { @@ -119,47 +164,38 @@ describe('Blob Embeddable', () => { richData: 'Another Rich Foo', }; + const MixedSimpleBlobContentMock = { + ...SimpleBlobContentMock, + richData: '<h1>Rich</h1>', + }; + + const MixedRichBlobContentMock = { + ...RichBlobContentMock, + plainData: 'Plain', + }; + it.each` - snippetBlobs | description | currentBlob | expectedContent - ${[SimpleBlobContentMock]} | ${'one existing textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} - ${[RichBlobContentMock]} | ${'one existing rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} - ${[SimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} - ${[SimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} - ${[SimpleBlobContentMock, SimpleBlobContentMock2]} | ${'textual blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} - ${[RichBlobContentMock, RichBlobContentMock2]} | ${'rich blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} + snippetBlobs | description | currentBlob | expectedContent | activeViewerType + ${[SimpleBlobContentMock]} | ${'one existing textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} | ${SimpleViewerMock.type} + ${[RichBlobContentMock]} | ${'one existing rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} | ${RichViewerMock.type} + ${[SimpleBlobContentMock, MixedRichBlobContentMock]} | ${'mixed blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} | ${SimpleViewerMock.type} + ${[MixedSimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} | ${RichViewerMock.type} + ${[SimpleBlobContentMock, SimpleBlobContentMock2]} | ${'textual blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} | ${SimpleViewerMock.type} + ${[RichBlobContentMock, RichBlobContentMock2]} | ${'rich blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} | ${RichViewerMock.type} `( 'renders correct content for $description', - async ({ snippetBlobs, currentBlob, expectedContent }) => { - const apolloData = { - snippets: { - nodes: [ - { - blobs: { - nodes: snippetBlobs, - }, - }, - ], - }, - }; + async ({ snippetBlobs, currentBlob, expectedContent, activeViewerType }) => { createComponent({ + handler: mockDefaultHandler({ path: currentBlob.path, nodes: snippetBlobs }), + data: { activeViewerType }, blob: { ...BlobMock, path: currentBlob.path, }, }); + await waitForPromises(); - // mimic apollo's update - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - blobContent: wrapper.vm.onContentUpdate(apolloData), - }); - - await nextTick(); - - const findContent = () => wrapper.findComponent(BlobContent); - - expect(findContent().props('content')).toBe(expectedContent); + expect(findBlobContent().props('content')).toBe(expectedContent); }, ); }); @@ -174,28 +210,32 @@ describe('Blob Embeddable', () => { window.location.hash = '#LC2'; }); - it('renders simple viewer by default', () => { + it('renders simple viewer by default', async () => { createComponent({ data: {}, }); + await waitForPromises(); - expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); - expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); + expect(findBlobHeader().props('activeViewerType')).toBe(SimpleViewerMock.type); + expect(findSimpleViewer().exists()).toBe(true); }); describe('switchViewer()', () => { it('switches to the passed viewer', async () => { createComponent(); + await waitForPromises(); + + findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, RichViewerMock.type); + await waitForPromises(); - wrapper.vm.switchViewer(RichViewerMock.type); + expect(findBlobHeader().props('activeViewerType')).toBe(RichViewerMock.type); + expect(findRichViewer().exists()).toBe(true); - await nextTick(); - expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type); - expect(wrapper.findComponent(RichViewer).exists()).toBe(true); + findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, SimpleViewerMock.type); + await waitForPromises(); - await wrapper.vm.switchViewer(SimpleViewerMock.type); - expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); - expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); + expect(findBlobHeader().props('activeViewerType')).toBe(SimpleViewerMock.type); + expect(findSimpleViewer().exists()).toBe(true); }); }); }); @@ -205,28 +245,32 @@ describe('Blob Embeddable', () => { window.location.hash = '#last-headline'; }); - it('renders rich viewer by default', () => { + it('renders rich viewer by default', async () => { createComponent({ data: {}, }); + await waitForPromises(); - expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type); - expect(wrapper.findComponent(RichViewer).exists()).toBe(true); + expect(findBlobHeader().props('activeViewerType')).toBe(RichViewerMock.type); + expect(findRichViewer().exists()).toBe(true); }); describe('switchViewer()', () => { it('switches to the passed viewer', async () => { createComponent(); + await waitForPromises(); - wrapper.vm.switchViewer(SimpleViewerMock.type); + findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, SimpleViewerMock.type); + await waitForPromises(); - await nextTick(); - expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type); - expect(wrapper.findComponent(SimpleViewer).exists()).toBe(true); + expect(findBlobHeader().props('activeViewerType')).toBe(SimpleViewerMock.type); + expect(findSimpleViewer().exists()).toBe(true); - await wrapper.vm.switchViewer(RichViewerMock.type); - expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type); - expect(wrapper.findComponent(RichViewer).exists()).toBe(true); + findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE, RichViewerMock.type); + await waitForPromises(); + + expect(findBlobHeader().props('activeViewerType')).toBe(RichViewerMock.type); + expect(findRichViewer().exists()).toBe(true); }); }); }); @@ -235,19 +279,21 @@ describe('Blob Embeddable', () => { describe('functionality', () => { describe('render error', () => { - const findContentEl = () => wrapper.findComponent(BlobContent); - it('correctly sets blob on the blob-content-error component', () => { createComponent(); - expect(findContentEl().props('blob')).toEqual(BlobMock); + expect(findBlobContent().props('blob')).toEqual(BlobMock); }); - it(`refetches blob content on ${BLOB_RENDER_EVENT_LOAD} event`, () => { + it(`refetches blob content on ${BLOB_RENDER_EVENT_LOAD} event`, async () => { createComponent(); + await waitForPromises(); + + expect(requestHandlers).toHaveBeenCalledTimes(1); + + findBlobContent().vm.$emit(BLOB_RENDER_EVENT_LOAD); + await waitForPromises(); - expect(wrapper.vm.$apollo.queries.blobContent.refetch).not.toHaveBeenCalled(); - findContentEl().vm.$emit(BLOB_RENDER_EVENT_LOAD); - expect(wrapper.vm.$apollo.queries.blobContent.refetch).toHaveBeenCalledTimes(1); + expect(requestHandlers).toHaveBeenCalledTimes(2); }); it(`sets '${SimpleViewerMock.type}' as active on ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => { @@ -257,7 +303,7 @@ describe('Blob Embeddable', () => { }, }); - findContentEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE); + findBlobContent().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE); expect(wrapper.vm.activeViewerType).toEqual(SimpleViewerMock.type); }); }); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 994cf65c1f5..4bf64bfd3cd 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -256,7 +256,7 @@ describe('Snippet header component', () => { }); describe('submit snippet as spam', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); }); diff --git a/spec/frontend/super_sidebar/components/context_switcher_spec.js b/spec/frontend/super_sidebar/components/context_switcher_spec.js index 538e87cf843..11da6ef1243 100644 --- a/spec/frontend/super_sidebar/components/context_switcher_spec.js +++ b/spec/frontend/super_sidebar/components/context_switcher_spec.js @@ -1,10 +1,11 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlSearchBoxByType } from '@gitlab/ui'; +import { GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; import ProjectsList from '~/super_sidebar/components/projects_list.vue'; import GroupsList from '~/super_sidebar/components/groups_list.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -22,7 +23,11 @@ jest.mock('~/super_sidebar/utils', () => ({ .formatContextSwitcherItems, trackContextAccess: jest.fn(), })); +const focusInputMock = jest.fn(); +const persistentLinks = [ + { title: 'Explore', link: '/explore', icon: 'compass', link_classes: 'persistent-link-class' }, +]; const username = 'root'; const projectsPath = 'projectsPath'; const groupsPath = 'groupsPath'; @@ -33,9 +38,12 @@ describe('ContextSwitcher component', () => { let wrapper; let mockApollo; + const findNavItems = () => wrapper.findAllComponents(NavItem); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findProjectsList = () => wrapper.findComponent(ProjectsList); const findGroupsList = () => wrapper.findComponent(GroupsList); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findComponent(GlAlert); const triggerSearchQuery = async () => { findSearchBox().vm.$emit('input', 'foo'); @@ -60,6 +68,7 @@ describe('ContextSwitcher component', () => { wrapper = shallowMountExtended(ContextSwitcher, { apolloProvider: mockApollo, propsData: { + persistentLinks, username, projectsPath, groupsPath, @@ -68,6 +77,7 @@ describe('ContextSwitcher component', () => { stubs: { GlSearchBoxByType: stubComponent(GlSearchBoxByType, { props: ['placeholder'], + methods: { focusInput: focusInputMock }, }), ProjectsList: stubComponent(ProjectsList, { props: ['username', 'viewAllLink', 'isSearch', 'searchResults'], @@ -84,9 +94,20 @@ describe('ContextSwitcher component', () => { createWrapper(); }); + it('renders the persistent links', () => { + const navItems = findNavItems(); + const firstNavItem = navItems.at(0); + + expect(navItems.length).toBe(persistentLinks.length); + expect(firstNavItem.props('item')).toBe(persistentLinks[0]); + expect(firstNavItem.props('linkClasses')).toEqual({ + [persistentLinks[0].link_classes]: persistentLinks[0].link_classes, + }); + }); + it('passes the placeholder to the search box', () => { expect(findSearchBox().props('placeholder')).toBe( - s__('Navigation|Search for projects or groups'), + s__('Navigation|Search your projects or groups'), ); }); @@ -108,9 +129,22 @@ describe('ContextSwitcher component', () => { }); }); + it('focuses the search input when focusInput is called', () => { + wrapper.vm.focusInput(); + + expect(focusInputMock).toHaveBeenCalledTimes(1); + }); + it('does not trigger the search query on mount', () => { expect(searchUserProjectsAndGroupsHandlerSuccess).not.toHaveBeenCalled(); }); + + it('shows a loading spinner when search query is typed in', async () => { + findSearchBox().vm.$emit('input', 'foo'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); }); describe('item access tracking', () => { @@ -138,10 +172,18 @@ describe('ContextSwitcher component', () => { return triggerSearchQuery(); }); + it('hides persistent links', () => { + expect(findNavItems().length).toBe(0); + }); + it('triggers the search query on search', () => { expect(searchUserProjectsAndGroupsHandlerSuccess).toHaveBeenCalled(); }); + it('hides the loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + it('passes the projects to the frequent projects list', () => { expect(findProjectsList().props('isSearch')).toBe(true); expect(findProjectsList().props('searchResults')).toEqual( @@ -192,7 +234,7 @@ describe('ContextSwitcher component', () => { jest.spyOn(Sentry, 'captureException'); }); - it('captures exception if response is formatted incorrectly', async () => { + it('captures exception and shows an alert if response is formatted incorrectly', async () => { createWrapper({ requestHandlers: { searchUserProjectsAndGroupsQueryHandler: jest.fn().mockResolvedValue({ @@ -203,9 +245,10 @@ describe('ContextSwitcher component', () => { await triggerSearchQuery(); expect(Sentry.captureException).toHaveBeenCalled(); + expect(findAlert().exists()).toBe(true); }); - it('captures exception if query fails', async () => { + it('captures exception and shows an alert if query fails', async () => { createWrapper({ requestHandlers: { searchUserProjectsAndGroupsQueryHandler: jest.fn().mockRejectedValue(), @@ -214,6 +257,7 @@ describe('ContextSwitcher component', () => { await triggerSearchQuery(); expect(Sentry.captureException).toHaveBeenCalled(); + expect(findAlert().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js index b24c6b8de7f..e05b5d30e69 100644 --- a/spec/frontend/super_sidebar/components/create_menu_spec.js +++ b/spec/frontend/super_sidebar/components/create_menu_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; @@ -23,6 +24,14 @@ describe('CreateMenu component', () => { createWrapper(); }); + it('passes popper options to the dropdown', () => { + createWrapper(); + + expect(findGlDisclosureDropdown().props('popperOptions')).toEqual({ + modifiers: [{ name: 'offset', options: { offset: [-147, 4] } }], + }); + }); + it("sets the toggle's label", () => { expect(findGlDisclosureDropdown().props('toggleText')).toBe(__('Create new...')); }); @@ -35,5 +44,20 @@ describe('CreateMenu component', () => { expect(findGlDisclosureDropdown().props('toggleId')).toBe(wrapper.vm.$options.toggleId); expect(findGlTooltip().props('target')).toBe(`#${wrapper.vm.$options.toggleId}`); }); + + it('hides the tooltip when the dropdown is opened', async () => { + findGlDisclosureDropdown().vm.$emit('shown'); + await nextTick(); + + expect(findGlTooltip().exists()).toBe(false); + }); + + it('shows the tooltip when the dropdown is closed', async () => { + findGlDisclosureDropdown().vm.$emit('shown'); + findGlDisclosureDropdown().vm.$emit('hidden'); + await nextTick(); + + expect(findGlTooltip().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js index 1e98db091f2..86cec3f3d13 100644 --- a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js +++ b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js @@ -1,3 +1,4 @@ +import { GlIcon, GlButton } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { s__ } from '~/locale'; import FrequentItemsList from '~/super_sidebar/components//frequent_items_list.vue'; @@ -16,6 +17,7 @@ describe('FrequentItemsList component', () => { let wrapper; const findListTitle = () => wrapper.findByTestId('list-title'); + const findListEditButton = () => findListTitle().findComponent(GlButton); const findItemsList = () => wrapper.findComponent(ItemsList); const findEmptyText = () => wrapper.findByTestId('empty-text'); @@ -64,5 +66,38 @@ describe('FrequentItemsList component', () => { it('does not render the empty text slot', () => { expect(findEmptyText().exists()).toBe(false); }); + + describe('items editing', () => { + it('renders edit button within header', () => { + const itemsEditButton = findListEditButton(); + + expect(itemsEditButton.exists()).toBe(true); + expect(itemsEditButton.attributes('title')).toBe('Toggle edit mode'); + expect(itemsEditButton.findComponent(GlIcon).props('name')).toBe('pencil'); + }); + + it('clicking edit button makes items list editable', async () => { + // Off by default + expect(findItemsList().props('editable')).toBe(false); + + // On when clicked + await findListEditButton().vm.$emit('click'); + expect(findItemsList().props('editable')).toBe(true); + + // Off when clicked again + await findListEditButton().vm.$emit('click'); + expect(findItemsList().props('editable')).toBe(false); + }); + + it('remove-item event emission from items-list causes list item to be removed', async () => { + const localStorageProjects = findItemsList().props('items'); + await findListEditButton().vm.$emit('click'); + + await findItemsList().vm.$emit('remove-item', localStorageProjects[0]); + + expect(findItemsList().props('items')).toHaveLength(maxItems - 1); + expect(findItemsList().props('items')).not.toContain(localStorageProjects[0]); + }); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js index e5ba1c63996..aac321bd8e0 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js @@ -1,31 +1,24 @@ -import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert, GlDropdownDivider } from '@gitlab/ui'; +import { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlLoadingIcon, + GlAvatar, + GlAlert, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Vuex from 'vuex'; -import HeaderSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; -import { - LARGE_AVATAR_PX, - SMALL_AVATAR_PX, -} from '~/super_sidebar/components/global_search/constants'; -import { - PROJECTS_CATEGORY, - GROUPS_CATEGORY, - ISSUES_CATEGORY, - MERGE_REQUEST_CATEGORY, - RECENT_EPICS_CATEGORY, -} from '~/vue_shared/global_search/constants'; +import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; + import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP, - MOCK_SEARCH, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2, } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchAutocompleteItems', () => { +describe('GlobalSearchAutocompleteItems', () => { let wrapper; const createComponent = (initialState, mockGetters, props) => { @@ -36,30 +29,34 @@ describe('HeaderSearchAutocompleteItems', () => { }, getters: { autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMount(HeaderSearchAutocompleteItems, { + wrapper = shallowMount(GlobalSearchAutocompleteItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + }, }); }; - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => - findDropdownItems().wrappers.map((w) => w.findAll('span').at(1).text()); - const findDropdownItemSubTitles = () => - findDropdownItems() - .wrappers.filter((w) => w.findAll('span').length > 2) - .map((w) => w.findAll('span').at(2).text()); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemTitles = () => + findItems().wrappers.map((w) => w.find('[data-testid="autocomplete-item-name"]').text()); + const findItemSubTitles = () => + findItems() + .wrappers.map((w) => w.find('[data-testid="autocomplete-item-namespace"]')) + .filter((w) => w.exists()) + .map((w) => w.text()); + const findItemLinks = () => findItems().wrappers.map((w) => w.find('a').attributes('href')); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findGlAvatar = () => wrapper.findComponent(GlAvatar); + const findAvatars = () => wrapper.findAllComponents(GlAvatar).wrappers.map((w) => w.props('src')); const findGlAlert = () => wrapper.findComponent(GlAlert); describe('template', () => { @@ -73,7 +70,7 @@ describe('HeaderSearchAutocompleteItems', () => { }); it('does not render autocomplete options', () => { - expect(findDropdownItems()).toHaveLength(0); + expect(findItems()).toHaveLength(0); }); }); @@ -86,6 +83,7 @@ describe('HeaderSearchAutocompleteItems', () => { expect(findGlAlert().exists()).toBe(true); }); }); + describe('when loading is false', () => { beforeEach(() => { createComponent({ loading: false }); @@ -95,143 +93,35 @@ describe('HeaderSearchAutocompleteItems', () => { expect(findGlLoadingIcon().exists()).toBe(false); }); - describe('Dropdown items', () => { + describe('Search results items', () => { it('renders item for each option in autocomplete option', () => { - expect(findDropdownItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length); + expect(findItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length); }); it('renders titles correctly', () => { - const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.label); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.text); + expect(findItemTitles()).toStrictEqual(expectedTitles); }); it('renders sub-titles correctly', () => { const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map( - (o) => o.label, + (o) => o.namespace, ); - expect(findDropdownItemSubTitles()).toStrictEqual(expectedSubTitles); - }); - - it('renders links correctly', () => { - const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); - }); - }); - - describe.each` - item | showAvatar | avatarSize | searchContext | entityId | entityName - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 29 } }} | ${'29'} | ${''} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 12 } }} | ${'12'} | ${''} - ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'0'} | ${''} - ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} | ${null} | ${false} | ${false} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'1'} | ${'test1'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'2'} | ${'test2'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'3'} | ${'test3'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'4'} | ${'test4'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'5'} | ${'test5'} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 6, group_name: 'test6' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'6'} | ${'test6'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 7, project_name: 'test7' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'7'} | ${'test7'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 8, project_name: 'test8' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'8'} | ${'test8'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 9, project_name: 'test9' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'9'} | ${'test9'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 10, group_name: 'test10' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'10'} | ${'test10'} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 11, group_name: 'test11' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'11'} | ${'test11'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 12, project_name: 'test12' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'12'} | ${'test12'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 13, project_name: 'test13' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'13'} | ${'test13'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 14, project_name: 'test14' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'14'} | ${'test14'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 15, group_name: 'test15' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'15'} | ${'test15'} - `('GlAvatar', ({ item, showAvatar, avatarSize, searchContext, entityId, entityName }) => { - describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => { - beforeEach(() => { - createComponent({ searchContext }, { autocompleteGroupedSearchOptions: () => [item] }); - }); - - it(`should${showAvatar ? '' : ' not'} render`, () => { - expect(findGlAvatar().exists()).toBe(showAvatar); - }); - - it(`should set avatarSize to ${avatarSize}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize); - }); - - it(`should set avatar entityId to ${entityId}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('entityid')).toBe(entityId); - }); - - it(`should set avatar entityName to ${entityName}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('entityname')).toBe( - entityName, - ); - }); - }); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, {}, { currentFocusedOption }); - }); - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); + expect(findItemSubTitles()).toStrictEqual(expectedSubTitles); }); - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + it('renders links correctly', () => { + const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.href); + expect(findItemLinks()).toStrictEqual(expectedLinks); }); - }); - }); - describe.each` - search | items | dividerCount - ${null} | ${[]} | ${0} - ${''} | ${[]} | ${0} - ${'1'} | ${[]} | ${0} - ${')'} | ${[]} | ${0} - ${'t'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP} | ${1} - ${'te'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP} | ${0} - ${'tes'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} - ${MOCK_SEARCH} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} - `('Header Search Dropdown Dividers', ({ search, items, dividerCount }) => { - describe(`when search is ${search}`, () => { - beforeEach(() => { - createComponent( - { search }, - { - autocompleteGroupedSearchOptions: () => items, - }, - {}, + it('renders avatars', () => { + const expectedAvatars = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.avatar_url).filter( + Boolean, ); + expect(findAvatars()).toStrictEqual(expectedAvatars); }); - - it(`component should have ${dividerCount} dividers`, () => { - expect(findGlDropdownDividers()).toHaveLength(dividerCount); - }); - }); - }); - }); - - describe('watchers', () => { - describe('currentFocusedOption', () => { - beforeEach(() => { - createComponent(); - }); - - it('when focused changes to existing element calls scroll into view on the newly focused element', async () => { - const focusedElement = findFirstDropdownItem().element; - const scrollSpy = jest.spyOn(focusedElement, 'scrollIntoView'); - - wrapper.setProps({ currentFocusedOption: MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0] }); - - await nextTick(); - - expect(scrollSpy).toHaveBeenCalledWith(false); - scrollSpy.mockRestore(); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js index 132f8e60598..52e9aa52c14 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js @@ -1,13 +1,13 @@ -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; -import HeaderSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchDefaultItems', () => { +describe('GlobalSearchDefaultItems', () => { let wrapper; const createComponent = (initialState, props) => { @@ -21,19 +21,19 @@ describe('HeaderSearchDefaultItems', () => { }, }); - wrapper = shallowMount(HeaderSearchDefaultItems, { + wrapper = shallowMountExtended(GlobalSearchDefaultItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + }, }); }; - const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemsData = () => findItems().wrappers.map((w) => w.props('item')); describe('template', () => { describe('Dropdown items', () => { @@ -42,26 +42,20 @@ describe('HeaderSearchDefaultItems', () => { }); it('renders item for each option in defaultSearchOptions', () => { - expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); - }); - - it('renders titles correctly', () => { - const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + expect(findItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); }); - it('renders links correctly', () => { - const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + it('provides the `item` prop to the `GlDisclosureDropdownItem` component', () => { + expect(findItemsData()).toStrictEqual(MOCK_DEFAULT_SEARCH_OPTIONS); }); }); describe.each` - group | project | dropdownTitle + group | project | groupHeader ${null} | ${null} | ${'All GitLab'} ${{ name: 'Test Group' }} | ${null} | ${'Test Group'} ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'} - `('Dropdown Header', ({ group, project, dropdownTitle }) => { + `('Group Header', ({ group, project, groupHeader }) => { describe(`when group is ${group?.name} and project is ${project?.name}`, () => { beforeEach(() => { createComponent({ @@ -72,29 +66,8 @@ describe('HeaderSearchDefaultItems', () => { }); }); - it(`should render as ${dropdownTitle}`, () => { - expect(findDropdownHeader().text()).toBe(dropdownTitle); - }); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, { currentFocusedOption }); - }); - - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); - }); - - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + it(`should render as ${groupHeader}`, () => { + expect(wrapper.text()).toContain(groupHeader); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js index fa91ef43ced..4976f3be4cd 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js @@ -1,21 +1,21 @@ -import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlToken, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; -import HeaderSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; +import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; import { truncate } from '~/lib/utils/text_utility'; import { SCOPE_TOKEN_MAX_LENGTH } from '~/super_sidebar/components/global_search/constants'; import { MSG_IN_ALL_GITLAB } from '~/vue_shared/global_search/constants'; import { MOCK_SEARCH, - MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_GROUP, MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchScopedItems', () => { +describe('GlobalSearchScopedItems', () => { let wrapper; const createComponent = (initialState, mockGetters, props) => { @@ -25,96 +25,67 @@ describe('HeaderSearchScopedItems', () => { ...initialState, }, getters: { - scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + scopedSearchGroup: () => MOCK_SCOPED_SEARCH_GROUP, autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMount(HeaderSearchScopedItems, { + wrapper = shallowMount(GlobalSearchScopedItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + }, }); }; - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemsText = () => findItems().wrappers.map((w) => trimText(w.text())); const findScopeTokens = () => wrapper.findAllComponents(GlToken); const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text())); const findScopeTokensIcons = () => findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon)); - const findDropdownItemAriaLabels = () => - findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label'))); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); - - describe('template', () => { - describe('Dropdown items', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders item for each option in scopedSearchOptions', () => { - expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length); - }); + const findItemLinks = () => findItems().wrappers.map((w) => w.find('a').attributes('href')); - it('renders titles correctly', () => { - findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH)); - }); - - it('renders scope names correctly', () => { - const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH), - ); + describe('Search results scoped items', () => { + beforeEach(() => { + createComponent(); + }); - expect(findScopeTokensText()).toStrictEqual(expectedTitles); - }); + it('renders item for each item in scopedSearchGroup', () => { + expect(findItems()).toHaveLength(MOCK_SCOPED_SEARCH_GROUP.items.length); + }); - it('renders scope icons correctly', () => { - findScopeTokensIcons().forEach((icon, i) => { - const w = icon.wrappers[0]; - expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon); - }); - }); + it('renders titles correctly', () => { + findItemsText().forEach((title) => expect(title).toContain(MOCK_SEARCH)); + }); - it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => { - expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false); - }); + it('renders scope names correctly', () => { + const expectedTitles = MOCK_SCOPED_SEARCH_GROUP.items.map((o) => + truncate(trimText(`in ${o.scope || o.description}`), SCOPE_TOKEN_MAX_LENGTH), + ); - it('renders aria-labels correctly', () => { - const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`), - ); - expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels); - }); + expect(findScopeTokensText()).toStrictEqual(expectedTitles); + }); - it('renders links correctly', () => { - const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + it('renders scope icons correctly', () => { + findScopeTokensIcons().forEach((icon, i) => { + const w = icon.wrappers[0]; + expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_GROUP.items[i].icon); }); }); - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, {}, { currentFocusedOption }); - }); - - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); - }); + it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => { + expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false); + }); - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); - }); - }); + it('renders links correctly', () => { + const expectedLinks = MOCK_SCOPED_SEARCH_GROUP.items.map((o) => o.href); + expect(findItemLinks()).toStrictEqual(expectedLinks); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js index 0dcfc448125..eb8801f68c6 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js @@ -1,30 +1,25 @@ import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking } from 'helpers/tracking_helper'; import { s__, sprintf } from '~/locale'; -import HeaderSearchApp from '~/super_sidebar/components/global_search/components/global_search.vue'; -import HeaderSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; -import HeaderSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; -import HeaderSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; +import GlobalSearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; +import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; +import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; +import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, - SEARCH_BOX_INDEX, ICON_PROJECT, ICON_GROUP, ICON_SUBGROUP, SCOPE_TOKEN_MAX_LENGTH, IS_SEARCHING, - IS_NOT_FOCUSED, - IS_FOCUSED, SEARCH_SHORTCUTS_MIN_CHARACTERS, } from '~/super_sidebar/components/global_search/constants'; -import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; -import { ENTER_KEY } from '~/lib/utils/keys'; -import { visitUrl } from '~/lib/utils/url_utility'; import { truncate } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { ENTER_KEY } from '~/lib/utils/keys'; import { MOCK_SEARCH, MOCK_SEARCH_QUERY, @@ -32,6 +27,8 @@ import { MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, MOCK_SEARCH_CONTEXT_FULL, + MOCK_PROJECT, + MOCK_GROUP, } from '../mock_data'; Vue.use(Vuex); @@ -40,7 +37,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), })); -describe('HeaderSearchApp', () => { +describe('GlobalSearchModal', () => { let wrapper; const actionSpies = { @@ -49,21 +46,31 @@ describe('HeaderSearchApp', () => { clearAutocomplete: jest.fn(), }; - const createComponent = (initialState, mockGetters) => { + const deafaultMockState = { + searchContext: { + project: MOCK_PROJECT, + group: MOCK_GROUP, + }, + }; + + const createComponent = (initialState, mockGetters, stubs) => { const store = new Vuex.Store({ state: { + ...deafaultMockState, ...initialState, }, actions: actionSpies, getters: { searchQuery: () => MOCK_SEARCH_QUERY, searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMountExtended(HeaderSearchApp, { + wrapper = shallowMountExtended(GlobalSearchModal, { store, + stubs, }); }; @@ -80,16 +87,13 @@ describe('HeaderSearchApp', () => { ); }; - const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form'); - const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findGlobalSearchForm = () => wrapper.findByTestId('global-search-form'); + const findGlobalSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findScopeToken = () => wrapper.findComponent(GlToken); - const findHeaderSearchInputKBD = () => wrapper.find('.keyboard-shortcut-helper'); - const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); - const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); - const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); - const findHeaderSearchAutocompleteItems = () => - wrapper.findComponent(HeaderSearchAutocompleteItems); - const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation); + const findGlobalSearchDefaultItems = () => wrapper.findComponent(GlobalSearchDefaultItems); + const findGlobalSearchScopedItems = () => wrapper.findComponent(GlobalSearchScopedItems); + const findGlobalSearchAutocompleteItems = () => + wrapper.findComponent(GlobalSearchAutocompleteItems); const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`); const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION); @@ -99,16 +103,8 @@ describe('HeaderSearchApp', () => { createComponent(); }); - it('Header Search Input', () => { - expect(findHeaderSearchInput().exists()).toBe(true); - }); - - it('Header Search Input KBD hint', () => { - expect(findHeaderSearchInputKBD().exists()).toBe(true); - expect(findHeaderSearchInputKBD().text()).toContain('/'); - expect(findHeaderSearchInputKBD().attributes('title')).toContain( - 'Use the shortcut key <kbd>/</kbd> to start a search', - ); + it('Global Search Input', () => { + expect(findGlobalSearchInput().exists()).toBe(true); }); it('Search Input Description', () => { @@ -121,26 +117,6 @@ describe('HeaderSearchApp', () => { }); describe.each` - showDropdown | username | showSearchDropdown - ${false} | ${null} | ${false} - ${false} | ${MOCK_USERNAME} | ${false} - ${true} | ${null} | ${false} - ${true} | ${MOCK_USERNAME} | ${true} - `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => { - describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => { - beforeEach(() => { - window.gon.current_username = username; - createComponent(); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); - }); - - it(`should${showSearchDropdown ? '' : ' not'} render`, () => { - expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown); - }); - }); - }); - - describe.each` search | showDefault | showScoped | showAutocomplete ${null} | ${true} | ${false} | ${false} ${''} | ${true} | ${false} | ${false} @@ -148,71 +124,40 @@ describe('HeaderSearchApp', () => { ${'te'} | ${false} | ${false} | ${true} ${'tes'} | ${false} | ${true} | ${true} ${MOCK_SEARCH} | ${false} | ${true} | ${true} - `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { + `('Global Search Result Items', ({ search, showDefault, showScoped, showAutocomplete }) => { describe(`when search is ${search}`, () => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent({ search }, {}); - findHeaderSearchInput().vm.$emit('click'); - }); - - it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { - expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); - }); - - it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { - expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); - }); - - it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => { - expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); - }); - - it(`should render the Dropdown Navigation Component`, () => { - expect(findDropdownKeyboardNavigation().exists()).toBe(true); + findGlobalSearchInput().vm.$emit('click'); }); - it(`should close the dropdown when press escape key`, async () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 })); - await nextTick(); - expect(findHeaderSearchDropdown().exists()).toBe(false); - expect(wrapper.emitted().expandSearchBar.length).toBe(1); + it(`should${showDefault ? '' : ' not'} render the Default Items`, () => { + expect(findGlobalSearchDefaultItems().exists()).toBe(showDefault); }); - }); - }); - describe.each` - username | showDropdown | expectedDesc - ${null} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN} - ${null} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN} - ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN} - ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN} - `('Search Input Description', ({ username, showDropdown, expectedDesc }) => { - describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => { - beforeEach(() => { - window.gon.current_username = username; - createComponent(); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); + it(`should${showScoped ? '' : ' not'} render the Scoped Items`, () => { + expect(findGlobalSearchScopedItems().exists()).toBe(showScoped); }); - it(`sets description to ${expectedDesc}`, () => { - expect(findSearchInputDescription().text()).toBe(expectedDesc); + it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Items`, () => { + expect(findGlobalSearchAutocompleteItems().exists()).toBe(showAutocomplete); }); }); }); describe.each` - username | showDropdown | search | loading | searchOptions | expectedDesc - ${null} | ${true} | ${''} | ${false} | ${[]} | ${''} - ${MOCK_USERNAME} | ${false} | ${''} | ${false} | ${[]} | ${''} - ${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} - ${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} - ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`} - ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.SEARCH_RESULTS_LOADING} + username | search | loading | searchOptions | expectedDesc + ${null} | ${'gi'} | ${false} | ${[]} | ${GlobalSearchModal.i18n.MIN_SEARCH_TERM} + ${MOCK_USERNAME} | ${'gi'} | ${false} | ${[]} | ${GlobalSearchModal.i18n.MIN_SEARCH_TERM} + ${MOCK_USERNAME} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} + ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${GlobalSearchModal.i18n.SEARCH_RESULTS_LOADING} + ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`} + ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${GlobalSearchModal.i18n.SEARCH_RESULTS_LOADING} `( 'Search Results Description', - ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => { - describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => { + ({ username, search, loading, searchOptions, expectedDesc }) => { + describe(`search is "${search}" and loading is ${loading}`, () => { beforeEach(() => { window.gon.current_username = username; createComponent( @@ -224,7 +169,6 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); it(`sets description to ${expectedDesc}`, () => { @@ -253,7 +197,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit('click'); + findGlobalSearchInput().vm.$emit('click'); }); it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${ @@ -274,42 +218,31 @@ describe('HeaderSearchApp', () => { describe('form', () => { describe.each` - searchContext | search | searchOptions | isFocused - ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${false} - ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${null} | ${null} | ${[]} | ${true} - `('wrapper', ({ searchContext, search, searchOptions, isFocused }) => { + searchContext | search | searchOptions + ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${null} | ${[]} + `('wrapper', ({ searchContext, search, searchOptions }) => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent({ search, searchContext }, { searchOptions: () => searchOptions }); - if (isFocused) { - findHeaderSearchInput().vm.$emit('click'); - } }); const isSearching = search?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; it(`classes ${isSearching ? 'contain' : 'do not contain'} "${IS_SEARCHING}"`, () => { if (isSearching) { - expect(findHeaderSearchForm().classes()).toContain(IS_SEARCHING); + expect(findGlobalSearchForm().classes()).toContain(IS_SEARCHING); return; } if (!isSearching) { - expect(findHeaderSearchForm().classes()).not.toContain(IS_SEARCHING); + expect(findGlobalSearchForm().classes()).not.toContain(IS_SEARCHING); } }); - - it(`classes ${isSearching ? 'contain' : 'do not contain'} "${ - isFocused ? IS_FOCUSED : IS_NOT_FOCUSED - }"`, () => { - expect(findHeaderSearchForm().classes()).toContain( - isFocused ? IS_FOCUSED : IS_NOT_FOCUSED, - ); - }); }); }); @@ -328,7 +261,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit('click'); + findGlobalSearchInput().vm.$emit('click'); }); it(`icon for data set type "${searchOptions[0]?.html_id}" ${ @@ -350,56 +283,15 @@ describe('HeaderSearchApp', () => { describe('events', () => { beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; createComponent(); + window.gon.current_username = MOCK_USERNAME; }); - describe('Header Search Input', () => { - describe('when dropdown is closed', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - it('onFocus opens dropdown and triggers snowplow event', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(false); - findHeaderSearchInput().vm.$emit('focus'); - - await nextTick(); - - expect(findHeaderSearchDropdown().exists()).toBe(true); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - }); - - it('onClick opens dropdown and triggers snowplow event', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(false); - findHeaderSearchInput().vm.$emit('click'); - - await nextTick(); - - expect(findHeaderSearchDropdown().exists()).toBe(true); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - }); - - it('onClick followed by onFocus only triggers a single snowplow event', async () => { - findHeaderSearchInput().vm.$emit('click'); - findHeaderSearchInput().vm.$emit('focus'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - }); - }); - + describe('Global Search Input', () => { describe('onInput', () => { describe('when search has text', () => { beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + findGlobalSearchInput().vm.$emit('input', MOCK_SEARCH); }); it('calls setSearch with search term', () => { @@ -417,7 +309,7 @@ describe('HeaderSearchApp', () => { describe('when search is emptied', () => { beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', ''); + findGlobalSearchInput().vm.$emit('input', ''); }); it('calls setSearch with empty term', () => { @@ -433,83 +325,29 @@ describe('HeaderSearchApp', () => { }); }); }); - }); - - describe('Dropdown Keyboard Navigation', () => { - beforeEach(() => { - findHeaderSearchInput().vm.$emit('click'); - }); - - it('closes dropdown when @tab is emitted', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(true); - findDropdownKeyboardNavigation().vm.$emit('tab'); - - await nextTick(); - - expect(findHeaderSearchDropdown().exists()).toBe(false); - }); - }); - }); - - describe('computed', () => { - describe.each` - MOCK_INDEX | search - ${1} | ${null} - ${SEARCH_BOX_INDEX} | ${'test'} - ${2} | ${'test1'} - `('currentFocusedOption', ({ MOCK_INDEX, search }) => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent({ search }); - findHeaderSearchInput().vm.$emit('click'); - }); - - it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => { - findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); - expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]); - }); - }); - }); - - describe('Submitting a search', () => { - describe('with no currentFocusedOption', () => { - beforeEach(() => { - createComponent(); - }); - it('onKey-enter submits a search', () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - - expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); - }); - }); - - describe('with less than min characters and no dropdown results', () => { - beforeEach(() => { - createComponent({ search: 'x' }); - }); - - it('onKey-enter will NOT submit a search', () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + describe('Submitting a search', () => { + beforeEach(() => { + createComponent(); + }); - expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); - }); - }); + it('onKey-enter submits a search', () => { + findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - describe('with currentFocusedOption', () => { - const MOCK_INDEX = 1; + expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent(); - findHeaderSearchInput().vm.$emit('click'); - }); + describe('with less than min characters', () => { + beforeEach(() => { + createComponent({ search: 'x' }); + }); - it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => { - findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); + it('onKey-enter will NOT submit a search', () => { + findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url); + expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); + }); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/mock_data.js b/spec/frontend/super_sidebar/components/global_search/mock_data.js index 58e578e4c4c..0884fce567c 100644 --- a/spec/frontend/super_sidebar/components/global_search/mock_data.js +++ b/spec/frontend/super_sidebar/components/global_search/mock_data.js @@ -3,6 +3,7 @@ import { ICON_GROUP, ICON_SUBGROUP, } from '~/super_sidebar/components/global_search/constants'; + import { PROJECTS_CATEGORY, GROUPS_CATEGORY, @@ -77,90 +78,107 @@ export const MOCK_SEARCH_CONTEXT_FULL = { export const MOCK_DEFAULT_SEARCH_OPTIONS = [ { - html_id: 'default-issues-assigned', - title: MSG_ISSUES_ASSIGNED_TO_ME, - url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`, + text: MSG_ISSUES_ASSIGNED_TO_ME, + href: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`, }, { - html_id: 'default-issues-created', - title: MSG_ISSUES_IVE_CREATED, - url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`, + text: MSG_ISSUES_IVE_CREATED, + href: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`, }, { - html_id: 'default-mrs-assigned', - title: MSG_MR_ASSIGNED_TO_ME, - url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`, + text: MSG_MR_ASSIGNED_TO_ME, + href: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`, }, { - html_id: 'default-mrs-reviewer', - title: MSG_MR_IM_REVIEWER, - url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`, + text: MSG_MR_IM_REVIEWER, + href: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`, }, { - html_id: 'default-mrs-created', - title: MSG_MR_IVE_CREATED, - url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, + text: MSG_MR_IVE_CREATED, + href: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, }, ]; - -export const MOCK_SCOPED_SEARCH_OPTIONS = [ +export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ { - html_id: 'scoped-in-project', + text: 'scoped-in-project', scope: MOCK_PROJECT.name, scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, - url: MOCK_PROJECT.path, - }, - { - html_id: 'scoped-in-project-long', - scope: MOCK_PROJECT_LONG.name, - scopeCategory: PROJECTS_CATEGORY, - icon: ICON_PROJECT, - url: MOCK_PROJECT_LONG.path, + href: MOCK_PROJECT.path, }, { - html_id: 'scoped-in-group', + text: 'scoped-in-group', scope: MOCK_GROUP.name, scopeCategory: GROUPS_CATEGORY, icon: ICON_GROUP, - url: MOCK_GROUP.path, - }, - { - html_id: 'scoped-in-subgroup', - scope: MOCK_SUBGROUP.name, - scopeCategory: GROUPS_CATEGORY, - icon: ICON_SUBGROUP, - url: MOCK_SUBGROUP.path, + href: MOCK_GROUP.path, }, { - html_id: 'scoped-in-all', + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, - url: MOCK_ALL_PATH, + href: MOCK_ALL_PATH, }, ]; - -export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ +export const MOCK_SCOPED_SEARCH_OPTIONS = [ { - html_id: 'scoped-in-project', + text: 'scoped-in-project', scope: MOCK_PROJECT.name, scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, url: MOCK_PROJECT.path, }, { - html_id: 'scoped-in-group', + text: 'scoped-in-project-long', + scope: MOCK_PROJECT_LONG.name, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + url: MOCK_PROJECT_LONG.path, + }, + { + text: 'scoped-in-group', scope: MOCK_GROUP.name, scopeCategory: GROUPS_CATEGORY, icon: ICON_GROUP, url: MOCK_GROUP.path, }, { - html_id: 'scoped-in-all', + text: 'scoped-in-subgroup', + scope: MOCK_SUBGROUP.name, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_SUBGROUP, + url: MOCK_SUBGROUP.path, + }, + { + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, url: MOCK_ALL_PATH, }, ]; +export const MOCK_SCOPED_SEARCH_GROUP = { + items: [ + { + text: 'scoped-in-project', + scope: MOCK_PROJECT.name, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + href: MOCK_PROJECT.path, + }, + { + text: 'scoped-in-group', + scope: MOCK_GROUP.name, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_GROUP, + href: MOCK_GROUP.path, + }, + { + text: 'scoped-in-all', + description: MSG_IN_ALL_GITLAB, + href: MOCK_ALL_PATH, + }, + ], +}; + export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ { category: 'Projects', @@ -168,8 +186,10 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ label: 'Gitlab Org / MockProject1', value: 'MockProject1', url: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', }, { + avatar_url: '/groups/avatar/1/avatar.png', category: 'Groups', id: 1, label: 'Gitlab Org / MockGroup1', @@ -177,6 +197,7 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ url: 'group/1', }, { + avatar_url: '/project/avatar/2/avatar.png', category: 'Projects', id: 2, label: 'Gitlab Org / MockProject2', @@ -193,31 +214,30 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ export const MOCK_AUTOCOMPLETE_OPTIONS = [ { category: 'Projects', - html_id: 'autocomplete-Projects-0', id: 1, label: 'Gitlab Org / MockProject1', value: 'MockProject1', url: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', }, { category: 'Groups', - html_id: 'autocomplete-Groups-1', id: 1, label: 'Gitlab Org / MockGroup1', value: 'MockGroup1', url: 'group/1', + avatar_url: '/groups/avatar/1/avatar.png', }, { category: 'Projects', - html_id: 'autocomplete-Projects-2', id: 2, label: 'Gitlab Org / MockProject2', value: 'MockProject2', url: 'project/2', + avatar_url: '/project/avatar/2/avatar.png', }, { category: 'Help', - html_id: 'autocomplete-Help-3', label: 'GitLab Help', url: 'help/gitlab', }, @@ -225,51 +245,64 @@ export const MOCK_AUTOCOMPLETE_OPTIONS = [ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ { - category: 'Groups', - data: [ + name: 'Groups', + items: [ { category: 'Groups', - html_id: 'autocomplete-Groups-1', - id: 1, label: 'Gitlab Org / MockGroup1', + namespace: 'Gitlab Org / MockGroup1', value: 'MockGroup1', - url: 'group/1', + text: 'MockGroup1', + href: 'group/1', + avatar_url: '/groups/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockGroup1', }, ], }, { - category: 'Projects', - data: [ + name: 'Projects', + items: [ { category: 'Projects', - html_id: 'autocomplete-Projects-0', - id: 1, label: 'Gitlab Org / MockProject1', + namespace: 'Gitlab Org / MockProject1', value: 'MockProject1', - url: 'project/1', + text: 'MockProject1', + href: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockProject1', }, { category: 'Projects', - html_id: 'autocomplete-Projects-2', - id: 2, - label: 'Gitlab Org / MockProject2', value: 'MockProject2', - url: 'project/2', + label: 'Gitlab Org / MockProject2', + namespace: 'Gitlab Org / MockProject2', + text: 'MockProject2', + href: 'project/2', + avatar_url: '/project/avatar/2/avatar.png', + avatar_size: 32, + entity_id: 2, + entity_name: 'MockProject2', }, ], }, { - category: 'Help', - data: [ + name: 'Help', + items: [ { category: 'Help', - html_id: 'autocomplete-Help-3', - label: 'GitLab Help', - url: 'help/gitlab', + text: 'GitLab Help', + href: 'help/gitlab', + avatar_size: 16, + entity_name: 'GitLab Help', }, ], }, @@ -278,33 +311,50 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ { category: 'Groups', - html_id: 'autocomplete-Groups-1', id: 1, label: 'Gitlab Org / MockGroup1', value: 'MockGroup1', - url: 'group/1', + text: 'MockGroup1', + href: 'group/1', + namespace: 'Gitlab Org / MockGroup1', + avatar_url: '/groups/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockGroup1', }, { + avatar_size: 32, + avatar_url: '/project/avatar/1/avatar.png', category: 'Projects', - html_id: 'autocomplete-Projects-0', + entity_id: 1, + entity_name: 'MockProject1', + href: 'project/1', id: 1, label: 'Gitlab Org / MockProject1', + namespace: 'Gitlab Org / MockProject1', + text: 'MockProject1', value: 'MockProject1', - url: 'project/1', }, { + avatar_size: 32, + avatar_url: '/project/avatar/2/avatar.png', category: 'Projects', - html_id: 'autocomplete-Projects-2', + entity_id: 2, + entity_name: 'MockProject2', + href: 'project/2', id: 2, label: 'Gitlab Org / MockProject2', + namespace: 'Gitlab Org / MockProject2', + text: 'MockProject2', value: 'MockProject2', - url: 'project/2', }, { + avatar_size: 16, + entity_name: 'GitLab Help', category: 'Help', - html_id: 'autocomplete-Help-3', label: 'GitLab Help', - url: 'help/gitlab', + text: 'GitLab Help', + href: 'help/gitlab', }, ]; @@ -315,14 +365,16 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [ { html_id: 'autocomplete-Help-1', category: 'Help', + text: 'Rake Tasks Help', label: 'Rake Tasks Help', - url: '/help/raketasks/index', + href: '/help/raketasks/index', }, { html_id: 'autocomplete-Help-2', category: 'Help', + text: 'System Hooks Help', label: 'System Hooks Help', - url: '/help/system_hooks/system_hooks', + href: '/help/system_hooks/system_hooks', }, ], }, diff --git a/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js b/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js index c87b4513309..f6d8e1f26eb 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js @@ -16,9 +16,7 @@ import { MOCK_ISSUE_PATH, } from '../mock_data'; -jest.mock('~/alert'); - -describe('Header Search Store Actions', () => { +describe('Global Search Store Actions', () => { let state; let mock; diff --git a/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js b/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js index dca96da01a7..68583d04b31 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js @@ -9,7 +9,7 @@ import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, - MOCK_SCOPED_SEARCH_OPTIONS_DEF, + MOCK_SCOPED_SEARCH_GROUP, MOCK_PROJECT, MOCK_GROUP, MOCK_ALL_PATH, @@ -17,9 +17,10 @@ import { MOCK_AUTOCOMPLETE_OPTIONS, MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS_DEF, } from '../mock_data'; -describe('Header Search Store Getters', () => { +describe('Global Search Store Getters', () => { let state; const createState = (initialState) => { @@ -288,7 +289,7 @@ describe('Header Search Store Getters', () => { describe.each` search | defaultSearchOptions | scopedSearchOptions | autocompleteGroupedSearchOptions | expectedArray - ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS} + ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_GROUP} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS} ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS} ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)} diff --git a/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js b/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js index d2dc484e825..4d275cf86c7 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js @@ -29,7 +29,7 @@ describe('Header Search Store Mutations', () => { mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES); expect(state.loading).toBe(false); - expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS); + expect(state.autocompleteOptions).toEqual(MOCK_AUTOCOMPLETE_OPTIONS); expect(state.autocompleteError).toBe(false); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/utils_spec.js new file mode 100644 index 00000000000..3b12063e733 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/utils_spec.js @@ -0,0 +1,60 @@ +import { getFormattedItem } from '~/super_sidebar/components/global_search/utils'; +import { + LARGE_AVATAR_PX, + SMALL_AVATAR_PX, +} from '~/super_sidebar/components/global_search/constants'; +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, +} from '~/vue_shared/global_search/constants'; + +describe('getFormattedItem', () => { + describe.each` + item | avatarSize | searchContext | entityId | entityName + ${{ category: PROJECTS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 29 } }} | ${29} | ${'project1'} + ${{ category: GROUPS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 12 } }} | ${12} | ${'project1'} + ${{ category: 'Help', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'} + ${{ category: 'Settings', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'} + ${{ category: GROUPS_CATEGORY, value: 'group1', label: 'Group 1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${1} | ${'group1'} + ${{ category: PROJECTS_CATEGORY, value: 'group2', label: 'Group2' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${2} | ${'group2'} + ${{ category: ISSUES_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${3} | ${'test3'} + ${{ category: MERGE_REQUEST_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${4} | ${'test4'} + ${{ category: RECENT_EPICS_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${5} | ${'test5'} + ${{ category: GROUPS_CATEGORY, group_id: 6, group_name: 'test6' }} | ${LARGE_AVATAR_PX} | ${null} | ${6} | ${'test6'} + ${{ category: PROJECTS_CATEGORY, project_id: 7, project_name: 'test7' }} | ${LARGE_AVATAR_PX} | ${null} | ${7} | ${'test7'} + ${{ category: ISSUES_CATEGORY, project_id: 8, project_name: 'test8' }} | ${SMALL_AVATAR_PX} | ${null} | ${8} | ${'test8'} + ${{ category: MERGE_REQUEST_CATEGORY, project_id: 9, project_name: 'test9' }} | ${SMALL_AVATAR_PX} | ${null} | ${9} | ${'test9'} + ${{ category: RECENT_EPICS_CATEGORY, group_id: 10, group_name: 'test10' }} | ${SMALL_AVATAR_PX} | ${null} | ${10} | ${'test10'} + ${{ category: GROUPS_CATEGORY, group_id: 11, group_name: 'test11' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${11} | ${'test11'} + ${{ category: PROJECTS_CATEGORY, project_id: 12, project_name: 'test12' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${12} | ${'test12'} + ${{ category: ISSUES_CATEGORY, project_id: 13, project_name: 'test13' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${13} | ${'test13'} + ${{ category: MERGE_REQUEST_CATEGORY, project_id: 14, project_name: 'test14' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${14} | ${'test14'} + ${{ category: RECENT_EPICS_CATEGORY, group_id: 15, group_name: 'test15' }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${15} | ${'test15'} + `('formats the item', ({ item, avatarSize, searchContext, entityId, entityName }) => { + describe(`when item is ${JSON.stringify(item)}`, () => { + let formattedItem; + beforeEach(() => { + formattedItem = getFormattedItem(item, searchContext); + }); + + it(`should set text to ${item.value || item.label}`, () => { + expect(formattedItem.text).toBe(item.value || item.label); + }); + + it(`should set avatarSize to ${avatarSize}`, () => { + expect(formattedItem.avatar_size).toBe(avatarSize); + }); + + it(`should set avatar entityId to ${entityId}`, () => { + expect(formattedItem.entity_id).toBe(entityId); + }); + + it(`should set avatar entityName to ${entityName}`, () => { + expect(formattedItem.entity_name).toBe(entityName); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/groups_list_spec.js b/spec/frontend/super_sidebar/components/groups_list_spec.js index 6aee895f611..4fa3303c12f 100644 --- a/spec/frontend/super_sidebar/components/groups_list_spec.js +++ b/spec/frontend/super_sidebar/components/groups_list_spec.js @@ -19,11 +19,14 @@ describe('GroupsList component', () => { const itRendersViewAllItem = () => { it('renders the "View all..." item', () => { - expect(findViewAllLink().props('item')).toEqual({ + const link = findViewAllLink(); + + expect(link.props('item')).toEqual({ icon: 'group', link: viewAllLink, - title: s__('Navigation|View all groups'), + title: s__('Navigation|View all your groups'), }); + expect(link.props('linkClasses')).toEqual({ 'dashboard-shortcuts-groups': true }); }); }; @@ -75,7 +78,7 @@ describe('GroupsList component', () => { it('passes the correct props to the frequent items list', () => { expect(findFrequentItemsList().props()).toEqual({ - title: s__('Navigation|Frequent groups'), + title: s__('Navigation|Frequently visited groups'), storageKey, maxItems: MAX_FREQUENT_GROUPS_COUNT, pristineText: s__('Navigation|Groups you visit often will appear here.'), diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index 1d072c0ba3c..4c0e7a89a43 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -1,4 +1,4 @@ -import { GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { within } from '@testing-library/dom'; import toggleWhatsNewDrawer from '~/whats_new'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -7,15 +7,18 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { STORAGE_KEY } from '~/whats_new/utils/notification'; +import { mockTracking } from 'helpers/tracking_helper'; import { sidebarData } from '../mock_data'; jest.mock('~/whats_new'); describe('HelpCenter component', () => { let wrapper; + let trackingSpy; const GlEmoji = { template: '<img/>' }; + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findDropdownGroup = (i = 0) => { return wrapper.findAllComponents(GlDisclosureDropdownGroup).at(i); }; @@ -28,6 +31,15 @@ describe('HelpCenter component', () => { propsData: { sidebarData }, stubs: { GlEmoji }, }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }; + + const trackingAttrs = (label) => { + return { + 'data-track-action': 'click_link', + 'data-track-property': 'nav_help_menu', + 'data-track-label': label, + }; }; describe('default', () => { @@ -37,16 +49,37 @@ describe('HelpCenter component', () => { it('renders menu items', () => { expect(findDropdownGroup(0).props('group').items).toEqual([ - { text: HelpCenter.i18n.help, href: helpPagePath() }, - { text: HelpCenter.i18n.support, href: sidebarData.support_path }, - { text: HelpCenter.i18n.docs, href: 'https://docs.gitlab.com' }, - { text: HelpCenter.i18n.plans, href: `${PROMO_URL}/pricing` }, - { text: HelpCenter.i18n.forum, href: 'https://forum.gitlab.com/' }, + { text: HelpCenter.i18n.help, href: helpPagePath(), extraAttrs: trackingAttrs('help') }, + { + text: HelpCenter.i18n.support, + href: sidebarData.support_path, + extraAttrs: trackingAttrs('support'), + }, + { + text: HelpCenter.i18n.docs, + href: 'https://docs.gitlab.com', + extraAttrs: trackingAttrs('gitlab_documentation'), + }, + { + text: HelpCenter.i18n.plans, + href: `${PROMO_URL}/pricing`, + extraAttrs: trackingAttrs('compare_gitlab_plans'), + }, + { + text: HelpCenter.i18n.forum, + href: 'https://forum.gitlab.com/', + extraAttrs: trackingAttrs('community_forum'), + }, { text: HelpCenter.i18n.contribute, href: helpPagePath('', { anchor: 'contributing-to-gitlab' }), + extraAttrs: trackingAttrs('contribute_to_gitlab'), + }, + { + text: HelpCenter.i18n.feedback, + href: 'https://about.gitlab.com/submit-feedback', + extraAttrs: trackingAttrs('submit_feedback'), }, - { text: HelpCenter.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' }, ]); expect(findDropdownGroup(1).props('group').items).toEqual([ @@ -55,6 +88,12 @@ describe('HelpCenter component', () => { ]); }); + it('passes popper options to the dropdown', () => { + expect(findDropdown().props('popperOptions')).toEqual({ + modifiers: [{ name: 'offset', options: { offset: [-4, 4] } }], + }); + }); + describe('with Gitlab version check feature enabled', () => { beforeEach(() => { createWrapper({ ...sidebarData, show_version_check: true }); @@ -62,7 +101,12 @@ describe('HelpCenter component', () => { it('shows version information as first item', () => { expect(findDropdownGroup(0).props('group').items).toEqual([ - { text: HelpCenter.i18n.version, href: helpPagePath('update/index'), version: '16.0' }, + { + text: HelpCenter.i18n.version, + href: helpPagePath('update/index'), + version: '16.0', + extraAttrs: trackingAttrs('version_help_dropdown'), + }, ]); }); }); @@ -86,11 +130,24 @@ describe('HelpCenter component', () => { // ~/behaviors/shortcuts/shortcuts.js. expect(button.classList.contains('js-shortcuts-modal-trigger')).toBe(true); }); + + it('should have Snowplow tracking attributes', () => { + expect(findButton('Keyboard shortcuts ?').dataset).toEqual( + expect.objectContaining({ + trackAction: 'click_button', + trackLabel: 'keyboard_shortcuts_help', + trackProperty: 'nav_help_menu', + }), + ); + }); }); describe('showWhatsNew', () => { beforeEach(() => { jest.spyOn(wrapper.vm.$refs.dropdown, 'close'); + beforeEach(() => { + createWrapper({ ...sidebarData, show_version_check: true }); + }); findButton("What's new 5").click(); }); @@ -107,6 +164,18 @@ describe('HelpCenter component', () => { expect(toggleWhatsNewDrawer).toHaveBeenCalledTimes(2); expect(toggleWhatsNewDrawer).toHaveBeenLastCalledWith(); }); + + it('should have Snowplow tracking attributes', () => { + createWrapper({ ...sidebarData, display_whats_new: true }); + + expect(findButton("What's new 5").dataset).toEqual( + expect.objectContaining({ + trackAction: 'click_button', + trackLabel: 'whats_new', + trackProperty: 'nav_help_menu', + }), + ); + }); }); describe('shouldShowWhatsNewNotification', () => { @@ -153,5 +222,23 @@ describe('HelpCenter component', () => { }); }); }); + + describe('toggle dropdown', () => { + it('should track Snowplow event when dropdown is shown', () => { + findDropdown().vm.$emit('shown'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', { + label: 'show_help_dropdown', + property: 'nav_help_menu', + }); + }); + + it('should track Snowplow event when dropdown is hidden', () => { + findDropdown().vm.$emit('hidden'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', { + label: 'hide_help_dropdown', + property: 'nav_help_menu', + }); + }); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/items_list_spec.js b/spec/frontend/super_sidebar/components/items_list_spec.js index 8e00984f500..d49ef35e9d8 100644 --- a/spec/frontend/super_sidebar/components/items_list_spec.js +++ b/spec/frontend/super_sidebar/components/items_list_spec.js @@ -1,4 +1,5 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { GlIcon } from '@gitlab/ui'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import ItemsList from '~/super_sidebar/components/items_list.vue'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import { cachedFrequentProjects } from '../mock_data'; @@ -11,8 +12,8 @@ describe('ItemsList component', () => { const findNavItems = () => wrapper.findAllComponents(NavItem); - const createWrapper = ({ props = {}, slots = {} } = {}) => { - wrapper = shallowMountExtended(ItemsList, { + const createWrapper = ({ props = {}, slots = {}, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(ItemsList, { propsData: { ...props, }, @@ -60,4 +61,42 @@ describe('ItemsList component', () => { expect(wrapper.findByTestId(testId).exists()).toBe(true); }); + + describe('item removal', () => { + const findRemoveButton = () => wrapper.findByTestId('item-remove'); + const mockProject = { + ...firstMockedProject, + title: firstMockedProject.name, + }; + + beforeEach(() => { + createWrapper({ + props: { + items: [mockProject], + editable: true, + }, + mountFn: mountExtended, + }); + }); + + it('renders the remove button', () => { + const itemRemoveButton = findRemoveButton(); + + expect(itemRemoveButton.exists()).toBe(true); + expect(itemRemoveButton.attributes('title')).toBe('Remove'); + expect(itemRemoveButton.findComponent(GlIcon).props('name')).toBe('close'); + }); + + it('emits `remove-item` event with item param when remove button is clicked', () => { + const itemRemoveButton = findRemoveButton(); + + itemRemoveButton.vm.$emit( + 'click', + { stopPropagation: jest.fn(), preventDefault: jest.fn() }, + mockProject, + ); + + expect(wrapper.emitted('remove-item')).toEqual([[mockProject]]); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/merge_request_menu_spec.js b/spec/frontend/super_sidebar/components/merge_request_menu_spec.js index fe87c4be9c3..9c8fd0556f1 100644 --- a/spec/frontend/super_sidebar/components/merge_request_menu_spec.js +++ b/spec/frontend/super_sidebar/components/merge_request_menu_spec.js @@ -8,7 +8,7 @@ describe('MergeRequestMenu component', () => { const findGlBadge = (at) => wrapper.findAllComponents(GlBadge).at(at); const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findLink = () => wrapper.findByRole('link'); + const findLink = (name) => wrapper.findByRole('link', { name }); const createWrapper = () => { wrapper = mountExtended(MergeRequestMenu, { @@ -27,11 +27,18 @@ describe('MergeRequestMenu component', () => { expect(findGlDisclosureDropdown().props('items')).toBe(mergeRequestMenuGroup); }); - it('renders item text and count in link', () => { - const { text, href, count } = mergeRequestMenuGroup[0].items[0]; - expect(findLink().text()).toContain(text); - expect(findLink().text()).toContain(String(count)); - expect(findLink().attributes('href')).toBe(href); + it.each(mergeRequestMenuGroup[0].items)('renders item text and count in link', (item) => { + const index = mergeRequestMenuGroup[0].items.indexOf(item); + const { text, href, count, extraAttrs } = mergeRequestMenuGroup[0].items[index]; + const link = findLink(new RegExp(text)); + + expect(link.text()).toContain(text); + expect(link.text()).toContain(String(count)); + expect(link.attributes('href')).toBe(href); + expect(link.attributes('data-track-action')).toBe(extraAttrs['data-track-action']); + expect(link.attributes('data-track-label')).toBe(extraAttrs['data-track-label']); + expect(link.attributes('data-track-property')).toBe(extraAttrs['data-track-property']); + expect(link.attributes('class')).toContain(extraAttrs.class); }); it('renders item count string in badge', () => { diff --git a/spec/frontend/super_sidebar/components/nav_item_spec.js b/spec/frontend/super_sidebar/components/nav_item_spec.js index 22989c1a5f9..d96d2b77d21 100644 --- a/spec/frontend/super_sidebar/components/nav_item_spec.js +++ b/spec/frontend/super_sidebar/components/nav_item_spec.js @@ -1,18 +1,24 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NavItem from '~/super_sidebar/components/nav_item.vue'; +import { + CLICK_MENU_ITEM_ACTION, + TRACKING_UNKNOWN_ID, + TRACKING_UNKNOWN_PANEL, +} from '~/super_sidebar/constants'; describe('NavItem component', () => { let wrapper; const findLink = () => wrapper.findByTestId('nav-item-link'); const findPill = () => wrapper.findComponent(GlBadge); - const createWrapper = (item, props = {}) => { + const createWrapper = (item, props = {}, provide = {}) => { wrapper = shallowMountExtended(NavItem, { propsData: { item, ...props, }, + provide, }); }; @@ -46,4 +52,34 @@ describe('NavItem component', () => { expect(findLink().attributes('class')).toContain(customClass); }); + + describe('Data Tracking Attributes', () => { + it('adds no labels on sections', () => { + const id = 'my-id'; + createWrapper({ title: 'Foo', id, items: [{ title: 'Baz' }] }); + + expect(findLink().attributes('data-track-action')).toBeUndefined(); + expect(findLink().attributes('data-track-label')).toBeUndefined(); + expect(findLink().attributes('data-track-property')).toBeUndefined(); + expect(findLink().attributes('data-track-extra')).toBeUndefined(); + }); + + it.each` + id | panelType | eventLabel | eventProperty | eventExtra + ${'abc'} | ${'xyz'} | ${'abc'} | ${'nav_panel_xyz'} | ${undefined} + ${undefined} | ${'xyz'} | ${TRACKING_UNKNOWN_ID} | ${'nav_panel_xyz'} | ${'{"title":"Foo"}'} + ${'abc'} | ${undefined} | ${'abc'} | ${TRACKING_UNKNOWN_PANEL} | ${'{"title":"Foo"}'} + ${undefined} | ${undefined} | ${TRACKING_UNKNOWN_ID} | ${TRACKING_UNKNOWN_PANEL} | ${'{"title":"Foo"}'} + `( + 'adds appropriate data tracking labels for id=$id and panelType=$panelType', + ({ id, eventLabel, panelType, eventProperty, eventExtra }) => { + createWrapper({ title: 'Foo', id }, {}, { panelType }); + + expect(findLink().attributes('data-track-action')).toBe(CLICK_MENU_ITEM_ACTION); + expect(findLink().attributes('data-track-label')).toBe(eventLabel); + expect(findLink().attributes('data-track-property')).toBe(eventProperty); + expect(findLink().attributes('data-track-extra')).toBe(eventExtra); + }, + ); + }); }); diff --git a/spec/frontend/super_sidebar/components/pinned_section_spec.js b/spec/frontend/super_sidebar/components/pinned_section_spec.js new file mode 100644 index 00000000000..7ead6a40895 --- /dev/null +++ b/spec/frontend/super_sidebar/components/pinned_section_spec.js @@ -0,0 +1,75 @@ +import { nextTick } from 'vue'; +import Cookies from '~/lib/utils/cookies'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import PinnedSection from '~/super_sidebar/components/pinned_section.vue'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; +import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '~/super_sidebar/constants'; +import { setCookie } from '~/lib/utils/common_utils'; + +jest.mock('~/lib/utils/common_utils', () => ({ + getCookie: jest.requireActual('~/lib/utils/common_utils').getCookie, + setCookie: jest.fn(), +})); + +describe('PinnedSection component', () => { + let wrapper; + + const findToggle = () => wrapper.find('a'); + + const createWrapper = () => { + wrapper = mountExtended(PinnedSection, { + propsData: { + items: [{ title: 'Pin 1', href: '/page1' }], + }, + }); + }; + + describe('expanded', () => { + describe('when cookie is not set', () => { + it('is expanded by default', () => { + createWrapper(); + expect(wrapper.findComponent(NavItem).isVisible()).toBe(true); + }); + }); + + describe('when cookie is set to false', () => { + beforeEach(() => { + Cookies.set(SIDEBAR_PINS_EXPANDED_COOKIE, 'false'); + createWrapper(); + }); + + it('is collapsed', () => { + expect(wrapper.findComponent(NavItem).isVisible()).toBe(false); + }); + + it('updates the cookie when expanding the section', async () => { + findToggle().trigger('click'); + await nextTick(); + + expect(setCookie).toHaveBeenCalledWith(SIDEBAR_PINS_EXPANDED_COOKIE, true, { + expires: SIDEBAR_COOKIE_EXPIRATION, + }); + }); + }); + + describe('when cookie is set to true', () => { + beforeEach(() => { + Cookies.set(SIDEBAR_PINS_EXPANDED_COOKIE, 'true'); + createWrapper(); + }); + + it('is expanded', () => { + expect(wrapper.findComponent(NavItem).isVisible()).toBe(true); + }); + + it('updates the cookie when collapsing the section', async () => { + findToggle().trigger('click'); + await nextTick(); + + expect(setCookie).toHaveBeenCalledWith(SIDEBAR_PINS_EXPANDED_COOKIE, false, { + expires: SIDEBAR_COOKIE_EXPIRATION, + }); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/projects_list_spec.js b/spec/frontend/super_sidebar/components/projects_list_spec.js index cdc003b14e0..93a414e1e8c 100644 --- a/spec/frontend/super_sidebar/components/projects_list_spec.js +++ b/spec/frontend/super_sidebar/components/projects_list_spec.js @@ -19,11 +19,14 @@ describe('ProjectsList component', () => { const itRendersViewAllItem = () => { it('renders the "View all..." item', () => { - expect(findViewAllLink().props('item')).toEqual({ + const link = findViewAllLink(); + + expect(link.props('item')).toEqual({ icon: 'project', link: viewAllLink, - title: s__('Navigation|View all projects'), + title: s__('Navigation|View all your projects'), }); + expect(link.props('linkClasses')).toEqual({ 'dashboard-shortcuts-projects': true }); }); }; @@ -70,7 +73,7 @@ describe('ProjectsList component', () => { it('passes the correct props to the frequent items list', () => { expect(findFrequentItemsList().props()).toEqual({ - title: s__('Navigation|Frequent projects'), + title: s__('Navigation|Frequently visited projects'), storageKey, maxItems: MAX_FREQUENT_PROJECTS_COUNT, pristineText: s__('Navigation|Projects you visit often will appear here.'), diff --git a/spec/frontend/super_sidebar/components/search_results_spec.js b/spec/frontend/super_sidebar/components/search_results_spec.js index dd48935c138..daec5c2a9b4 100644 --- a/spec/frontend/super_sidebar/components/search_results_spec.js +++ b/spec/frontend/super_sidebar/components/search_results_spec.js @@ -1,7 +1,9 @@ +import { GlCollapse } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { s__ } from '~/locale'; import SearchResults from '~/super_sidebar/components/search_results.vue'; import ItemsList from '~/super_sidebar/components/items_list.vue'; +import { stubComponent } from 'helpers/stub_component'; const title = s__('Navigation|PROJECTS'); const noResultsText = s__('Navigation|No project matches found'); @@ -9,7 +11,8 @@ const noResultsText = s__('Navigation|No project matches found'); describe('SearchResults component', () => { let wrapper; - const findListTitle = () => wrapper.findByTestId('list-title'); + const findSearchResultsToggle = () => wrapper.findByTestId('search-results-toggle'); + const findCollapsibleSection = () => wrapper.findComponent(GlCollapse); const findItemsList = () => wrapper.findComponent(ItemsList); const findEmptyText = () => wrapper.findByTestId('empty-text'); @@ -20,6 +23,11 @@ describe('SearchResults component', () => { noResultsText, ...props, }, + stubs: { + GlCollapse: stubComponent(GlCollapse, { + props: ['visible'], + }), + }, }); }; @@ -29,7 +37,11 @@ describe('SearchResults component', () => { }); it("renders the list's title", () => { - expect(findListTitle().text()).toBe(title); + expect(findSearchResultsToggle().text()).toBe(title); + }); + + it('is expanded', () => { + expect(findCollapsibleSection().props('visible')).toBe(true); }); it('renders the empty text', () => { diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js new file mode 100644 index 00000000000..26b146f0c8b --- /dev/null +++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js @@ -0,0 +1,151 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue'; +import { PANELS_WITH_PINS } from '~/super_sidebar/constants'; +import { sidebarData } from '../mock_data'; + +describe('SidebarMenu component', () => { + let wrapper; + + const createWrapper = (mockData) => { + wrapper = mountExtended(SidebarMenu, { + propsData: { + items: mockData.current_menu_items, + pinnedItemIds: mockData.pinned_items, + panelType: mockData.panel_type, + updatePinsUrl: mockData.update_pins_url, + }, + }); + }; + + describe('computed', () => { + const menuItems = [ + { id: 1, title: 'No subitems' }, + { id: 2, title: 'With subitems', items: [{ id: 21, title: 'Pinned subitem' }] }, + { id: 3, title: 'Empty subitems array', items: [] }, + { id: 4, title: 'Also with subitems', items: [{ id: 41, title: 'Subitem' }] }, + ]; + + describe('supportsPins', () => { + it('is true for the project sidebar', () => { + createWrapper({ ...sidebarData, panel_type: 'project' }); + expect(wrapper.vm.supportsPins).toBe(true); + }); + + it('is true for the group sidebar', () => { + createWrapper({ ...sidebarData, panel_type: 'group' }); + expect(wrapper.vm.supportsPins).toBe(true); + }); + + it('is false for any other sidebar', () => { + createWrapper({ ...sidebarData, panel_type: 'your_work' }); + expect(wrapper.vm.supportsPins).toEqual(false); + }); + }); + + describe('flatPinnableItems', () => { + it('returns all subitems in a flat array', () => { + createWrapper({ ...sidebarData, current_menu_items: menuItems }); + expect(wrapper.vm.flatPinnableItems).toEqual([ + { id: 21, title: 'Pinned subitem' }, + { id: 41, title: 'Subitem' }, + ]); + }); + }); + + describe('staticItems', () => { + describe('when the sidebar supports pins', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + panel_type: PANELS_WITH_PINS[0], + }); + }); + + it('makes everything that has no subitems a static item', () => { + expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([ + 'No subitems', + 'Empty subitems array', + ]); + }); + }); + + describe('when the sidebar does not support pins', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + panel_type: 'explore', + }); + }); + + it('returns an empty array', () => { + expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([]); + }); + }); + }); + + describe('nonStaticItems', () => { + describe('when the sidebar supports pins', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + panel_type: PANELS_WITH_PINS[0], + }); + }); + + it('keeps items that have subitems (aka "sections") as non-static', () => { + expect(wrapper.vm.nonStaticItems.map((i) => i.title)).toEqual([ + 'With subitems', + 'Also with subitems', + ]); + }); + }); + + describe('when the sidebar does not support pins', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + panel_type: 'explore', + }); + }); + + it('keeps all items as non-static', () => { + expect(wrapper.vm.nonStaticItems).toEqual(menuItems); + }); + }); + }); + + describe('pinnedItems', () => { + describe('when user has no pinned item ids stored', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + pinned_items: [], + }); + }); + + it('returns an empty array', () => { + expect(wrapper.vm.pinnedItems).toEqual([]); + }); + }); + + describe('when user has some pinned item ids stored', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + pinned_items: [21], + }); + }); + + it('returns the items matching the pinned ids', () => { + expect(wrapper.vm.pinnedItems).toEqual([{ id: 21, title: 'Pinned subitem' }]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index 32921da23aa..85f2a63943d 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -1,44 +1,57 @@ +import { nextTick } from 'vue'; +import { GlCollapse } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue'; import HelpCenter from '~/super_sidebar/components/help_center.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; import SidebarPortalTarget from '~/super_sidebar/components/sidebar_portal_target.vue'; -import { isCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; +import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue'; +import { + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, +} from '~/super_sidebar/constants'; +import { stubComponent } from 'helpers/stub_component'; import { sidebarData } from '../mock_data'; -jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager', () => ({ - isCollapsed: jest.fn(), -})); +const focusInputMock = jest.fn(); describe('SuperSidebar component', () => { let wrapper; - const findSidebar = () => wrapper.find('.super-sidebar'); + const findSidebar = () => wrapper.findByTestId('super-sidebar'); + const findHoverArea = () => wrapper.findByTestId('super-sidebar-hover-area'); const findUserBar = () => wrapper.findComponent(UserBar); const findHelpCenter = () => wrapper.findComponent(HelpCenter); const findSidebarPortalTarget = () => wrapper.findComponent(SidebarPortalTarget); - const createWrapper = (props = {}) => { + const createWrapper = ({ props = {}, provide = {}, sidebarState = {} } = {}) => { wrapper = shallowMountExtended(SuperSidebar, { + data() { + return { + ...sidebarState, + }; + }, propsData: { sidebarData, ...props, }, + stubs: { + ContextSwitcher: stubComponent(ContextSwitcher, { + methods: { focusInput: focusInputMock }, + }), + }, + provide, }); }; describe('default', () => { - it('add aria-hidden and inert attributes when collapsed', () => { - isCollapsed.mockReturnValue(true); - createWrapper(); - expect(findSidebar().attributes('aria-hidden')).toBe('true'); + it('adds inert attribute when collapsed', () => { + createWrapper({ sidebarState: { isCollapsed: true } }); expect(findSidebar().attributes('inert')).toBe('inert'); }); - it('does not add aria-hidden and inert attributes when expanded', () => { - isCollapsed.mockReturnValue(false); + it('does not add inert attribute when expanded', () => { createWrapper(); - expect(findSidebar().attributes('aria-hidden')).toBe('false'); expect(findSidebar().attributes('inert')).toBe(undefined); }); @@ -56,5 +69,120 @@ describe('SuperSidebar component', () => { createWrapper(); expect(findSidebarPortalTarget().exists()).toBe(true); }); + + it("does not call the context switcher's focusInput method initially", () => { + createWrapper(); + + expect(focusInputMock).not.toHaveBeenCalled(); + }); + + it('renders hidden shortcut links', () => { + createWrapper(); + const [linkAttrs] = sidebarData.shortcut_links; + const link = wrapper.find(`.${linkAttrs.css_class}`); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(linkAttrs.href); + expect(link.attributes('class')).toContain('gl-display-none'); + }); + }); + + describe('when peeking on hover', () => { + const peekClass = 'super-sidebar-peek'; + + it('updates inert attribute and peek class', async () => { + createWrapper({ + provide: { glFeatures: { superSidebarPeek: true } }, + sidebarState: { isCollapsed: true }, + }); + + findHoverArea().trigger('mouseenter'); + + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY - 1); + await nextTick(); + + // Not quite enough time has elapsed yet for sidebar to open + expect(findSidebar().classes()).not.toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe('inert'); + + jest.advanceTimersByTime(1); + await nextTick(); + + // Exactly enough time has elapsed to open + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + // Important: assume the cursor enters the sidebar + findSidebar().trigger('mouseenter'); + + jest.runAllTimers(); + await nextTick(); + + // Sidebar remains peeked open indefinitely without a mouseleave + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + findSidebar().trigger('mouseleave'); + + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1); + await nextTick(); + + // Not quite enough time has elapsed yet for sidebar to hide + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + jest.advanceTimersByTime(1); + await nextTick(); + + // Exactly enough time has elapsed for sidebar to hide + expect(findSidebar().classes()).not.toContain('super-sidebar-peek'); + expect(findSidebar().attributes('inert')).toBe('inert'); + }); + + it('eventually closes the sidebar if cursor never enters sidebar', async () => { + createWrapper({ + provide: { glFeatures: { superSidebarPeek: true } }, + sidebarState: { isCollapsed: true }, + }); + + findHoverArea().trigger('mouseenter'); + + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY); + await nextTick(); + + // Sidebar is now open + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + // Important: do *not* fire a mouseenter event on the sidebar here. This + // imitates what happens if the cursor moves away from the sidebar before + // it actually appears. + + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1); + await nextTick(); + + // Not quite enough time has elapsed yet for sidebar to hide + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + jest.advanceTimersByTime(1); + await nextTick(); + + // Exactly enough time has elapsed for sidebar to hide + expect(findSidebar().classes()).not.toContain('super-sidebar-peek'); + expect(findSidebar().attributes('inert')).toBe('inert'); + }); + }); + + describe('when opening the context switcher', () => { + beforeEach(() => { + createWrapper(); + wrapper.findComponent(GlCollapse).vm.$emit('input', true); + wrapper.findComponent(GlCollapse).vm.$emit('shown'); + }); + + it("calls the context switcher's focusInput method", () => { + expect(focusInputMock).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js new file mode 100644 index 00000000000..b9f94e662fe --- /dev/null +++ b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js @@ -0,0 +1,106 @@ +import { nextTick } from 'vue'; +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS } from '~/super_sidebar/constants'; +import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue'; +import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; + +jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager.js', () => ({ + toggleSuperSidebarCollapsed: jest.fn(), +})); + +describe('SuperSidebarToggle component', () => { + let wrapper; + + const findButton = () => wrapper.findComponent(GlButton); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + + const createWrapper = ({ props = {}, sidebarState = {} } = {}) => { + wrapper = shallowMountExtended(SuperSidebarToggle, { + data() { + return { + ...sidebarState, + }; + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + ...props, + }, + }); + }; + + describe('attributes', () => { + it('has aria-controls attribute', () => { + createWrapper(); + expect(findButton().attributes('aria-controls')).toBe('super-sidebar'); + }); + + it('has aria-expanded as true when expanded', () => { + createWrapper(); + expect(findButton().attributes('aria-expanded')).toBe('true'); + }); + + it('has aria-expanded as false when collapsed', () => { + createWrapper({ sidebarState: { isCollapsed: true } }); + expect(findButton().attributes('aria-expanded')).toBe('false'); + }); + + it('has aria-label attribute', () => { + createWrapper(); + expect(findButton().attributes('aria-label')).toBe(__('Navigation sidebar')); + }); + + it('is disabled when isPeek is true', () => { + createWrapper({ sidebarState: { isPeek: true } }); + expect(findButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('toolip', () => { + it('displays collapse when expanded', () => { + createWrapper(); + expect(getTooltip().title).toBe(__('Collapse sidebar')); + }); + + it('displays expand when collapsed', () => { + createWrapper({ sidebarState: { isCollapsed: true } }); + expect(getTooltip().title).toBe(__('Expand sidebar')); + }); + }); + + describe('toggle', () => { + beforeEach(() => { + setHTMLFixture(` + <button class="${JS_TOGGLE_COLLAPSE_CLASS}">Collapse</button> + <button class="${JS_TOGGLE_EXPAND_CLASS}">Expand</button> + `); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('collapses the sidebar and focuses the other toggle', async () => { + createWrapper(); + findButton().vm.$emit('click'); + await nextTick(); + expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(true, true); + expect(document.activeElement).toEqual( + document.querySelector(`.${JS_TOGGLE_COLLAPSE_CLASS}`), + ); + }); + + it('expands the sidebar and focuses the other toggle', async () => { + createWrapper({ sidebarState: { isCollapsed: true } }); + findButton().vm.$emit('click'); + await nextTick(); + expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(false, true); + expect(document.activeElement).toEqual(document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index ae15dd55644..2b75fb27972 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -1,29 +1,65 @@ import { GlBadge } from '@gitlab/ui'; +import Vuex from 'vuex'; +import Vue, { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; import CreateMenu from '~/super_sidebar/components/create_menu.vue'; +import SearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue'; import Counter from '~/super_sidebar/components/counter.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import waitForPromises from 'helpers/wait_for_promises'; +import { highCountTrim } from '~/lib/utils/text_utility'; import { sidebarData } from '../mock_data'; +import { MOCK_DEFAULT_SEARCH_OPTIONS } from './global_search/mock_data'; + +jest.mock('~/lib/utils/text_utility', () => ({ + highCountTrim: jest.fn().mockReturnValue('99+'), +})); describe('UserBar component', () => { let wrapper; const findCreateMenu = () => wrapper.findComponent(CreateMenu); const findCounter = (at) => wrapper.findAllComponents(Counter).at(at); + const findIssuesCounter = () => findCounter(0); + const findMRsCounter = () => findCounter(1); + const findTodosCounter = () => findCounter(2); const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu); const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo'); + const findCollapseButton = () => wrapper.findByTestId('super-sidebar-collapse-button'); + const findSearchButton = () => wrapper.findByTestId('super-sidebar-search-button'); + const findSearchModal = () => wrapper.findComponent(SearchModal); + const findStopImpersonationButton = () => wrapper.findByTestId('stop-impersonation-btn'); + + Vue.use(Vuex); - const createWrapper = (extraSidebarData = {}) => { + const store = new Vuex.Store({ + getters: { + searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + }, + }); + const createWrapper = ({ + hasCollapseButton = true, + extraSidebarData = {}, + provideOverrides = {}, + } = {}) => { wrapper = shallowMountExtended(UserBar, { propsData: { + hasCollapseButton, sidebarData: { ...sidebarData, ...extraSidebarData }, }, provide: { rootPath: '/', toggleNewNavEndpoint: '/-/profile/preferences', + isImpersonating: false, + ...provideOverrides, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + store, }); }; @@ -41,32 +77,69 @@ describe('UserBar component', () => { }); it('renders issues counter', () => { - expect(findCounter(0).props('count')).toBe(sidebarData.assigned_open_issues_count); - expect(findCounter(0).props('href')).toBe(sidebarData.issues_dashboard_path); - expect(findCounter(0).props('label')).toBe(__('Issues')); + const isuesCounter = findIssuesCounter(); + expect(isuesCounter.props('count')).toBe(sidebarData.assigned_open_issues_count); + expect(isuesCounter.props('href')).toBe(sidebarData.issues_dashboard_path); + expect(isuesCounter.props('label')).toBe(__('Issues')); + expect(isuesCounter.attributes('data-track-action')).toBe('click_link'); + expect(isuesCounter.attributes('data-track-label')).toBe('issues_link'); + expect(isuesCounter.attributes('data-track-property')).toBe('nav_core_menu'); + expect(isuesCounter.attributes('class')).toContain('dashboard-shortcuts-issues'); }); it('renders merge requests counter', () => { - expect(findCounter(1).props('count')).toBe(sidebarData.total_merge_requests_count); - expect(findCounter(1).props('label')).toBe(__('Merge requests')); + const mrsCounter = findMRsCounter(); + expect(mrsCounter.props('count')).toBe(sidebarData.total_merge_requests_count); + expect(mrsCounter.props('label')).toBe(__('Merge requests')); + expect(mrsCounter.attributes('data-track-action')).toBe('click_dropdown'); + expect(mrsCounter.attributes('data-track-label')).toBe('merge_requests_menu'); + expect(mrsCounter.attributes('data-track-property')).toBe('nav_core_menu'); }); - it('renders todos counter', () => { - expect(findCounter(2).props('count')).toBe(sidebarData.todos_pending_count); - expect(findCounter(2).props('href')).toBe('/dashboard/todos'); - expect(findCounter(2).props('label')).toBe(__('To-Do list')); + describe('Todos counter', () => { + it('renders it', () => { + const todosCounter = findTodosCounter(); + expect(todosCounter.props('href')).toBe('/dashboard/todos'); + expect(todosCounter.props('label')).toBe(__('To-Do list')); + expect(todosCounter.attributes('data-track-action')).toBe('click_link'); + expect(todosCounter.attributes('data-track-label')).toBe('todos_link'); + expect(todosCounter.attributes('data-track-property')).toBe('nav_core_menu'); + expect(todosCounter.attributes('class')).toContain('shortcuts-todos'); + }); + + it('should format and update todo counter when event is emitted', async () => { + createWrapper(); + const count = 100; + document.dispatchEvent(new CustomEvent('todo:toggle', { detail: { count } })); + await nextTick(); + expect(highCountTrim).toHaveBeenCalledWith(count); + expect(findTodosCounter().props('count')).toBe('99+'); + }); }); it('renders branding logo', () => { expect(findBrandLogo().exists()).toBe(true); expect(findBrandLogo().attributes('src')).toBe(sidebarData.logo_url); }); + + it('does not render the "Stop impersonating" button', () => { + expect(findStopImpersonationButton().exists()).toBe(false); + }); + + it('renders collapse button when hasCollapseButton is true', () => { + expect(findCollapseButton().exists()).toBe(true); + }); + + it('does not render collapse button when hasCollapseButton is false', () => { + createWrapper({ hasCollapseButton: false }); + expect(findCollapseButton().exists()).toBe(false); + }); }); describe('GitLab Next badge', () => { describe('when on canary', () => { it('should render a badge to switch off GitLab Next', () => { - createWrapper({ gitlab_com_and_canary: true }); + createWrapper({ extraSidebarData: { gitlab_com_and_canary: true } }); const badge = wrapper.findComponent(GlBadge); expect(badge.text()).toBe('Next'); expect(badge.attributes('href')).toBe(sidebarData.canary_toggle_com_url); @@ -75,10 +148,55 @@ describe('UserBar component', () => { describe('when not on canary', () => { it('should not render the GitLab Next badge', () => { - createWrapper({ gitlab_com_and_canary: false }); + createWrapper({ extraSidebarData: { gitlab_com_and_canary: false } }); const badge = wrapper.findComponent(GlBadge); expect(badge.exists()).toBe(false); }); }); }); + + describe('Search', () => { + beforeEach(async () => { + createWrapper(); + await waitForPromises(); + }); + + it('should render search button', () => { + expect(findSearchButton().exists()).toBe(true); + }); + + it('search button should have tooltip', () => { + const tooltip = getBinding(findSearchButton().element, 'gl-tooltip'); + expect(tooltip.value).toBe(`Search GitLab <kbd>/</kbd>`); + }); + + it('should render search modal', () => { + expect(findSearchModal().exists()).toBe(true); + }); + }); + + describe('While impersonating a user', () => { + beforeEach(() => { + createWrapper({ provideOverrides: { isImpersonating: true } }); + }); + + it('renders the "Stop impersonating" button', () => { + expect(findStopImpersonationButton().exists()).toBe(true); + }); + + it('sets the correct label on the button', () => { + const btn = findStopImpersonationButton(); + const label = __('Stop impersonating'); + + expect(btn.attributes('title')).toBe(label); + expect(btn.attributes('aria-label')).toBe(label); + }); + + it('sets the href and data-method attributes', () => { + const btn = findStopImpersonationButton(); + + expect(btn.attributes('href')).toBe(sidebarData.stop_impersonation_path); + expect(btn.attributes('data-method')).toBe('delete'); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index b6231e03722..995095d0e35 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -14,7 +14,8 @@ describe('UserMenu component', () => { const GlEmoji = { template: '<img/>' }; const toggleNewNavEndpoint = invalidUrl; - const showDropdown = () => wrapper.findComponent(GlDisclosureDropdown).vm.$emit('shown'); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const showDropdown = () => findDropdown().vm.$emit('shown'); const createWrapper = (userDataChanges = {}) => { wrapper = mountExtended(UserMenu, { @@ -36,6 +37,14 @@ describe('UserMenu component', () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }; + it('passes popper options to the dropdown', () => { + createWrapper(); + + expect(findDropdown().props('popperOptions')).toEqual({ + modifiers: [{ name: 'offset', options: { offset: [-211, 4] } }], + }); + }); + describe('Toggle button', () => { let toggle; @@ -93,6 +102,14 @@ describe('UserMenu component', () => { expect(item.find('.js-set-status-modal-trigger').exists()).toBe(true); }); + it('should close the dropdown when status modal opened', () => { + setItem({ can_update: true }); + wrapper.vm.$refs.userDropdown.close = jest.fn(); + expect(wrapper.vm.$refs.userDropdown.close).not.toHaveBeenCalled(); + item.vm.$emit('action'); + expect(wrapper.vm.$refs.userDropdown.close).toHaveBeenCalled(); + }); + describe('renders correct label', () => { it.each` busy | customized | label @@ -117,22 +134,42 @@ describe('UserMenu component', () => { expect(findModalWrapper().exists()).toBe(true); }); - it('sets default data attributes when status is not customized', () => { - setItem({ can_update: true }); - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': '', - 'data-current-message': '', - 'data-default-emoji': 'speech_balloon', + describe('when user cannot update status', () => { + it('sets default data attributes', () => { + setItem({ can_update: true }); + expect(findModalWrapper().attributes()).toMatchObject({ + 'data-current-emoji': '', + 'data-current-message': '', + 'data-default-emoji': 'speech_balloon', + }); }); }); - it('sets user status as data attributes when status is customized', () => { - setItem({ can_update: true, customized: true }); - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': userMenuMockStatus.emoji, - 'data-current-message': userMenuMockStatus.message, - 'data-current-availability': userMenuMockStatus.availability, - 'data-current-clear-status-after': userMenuMockStatus.clear_after, + describe.each` + busy | customized + ${true} | ${true} + ${true} | ${false} + ${false} | ${true} + ${false} | ${false} + `(`when user can update status`, ({ busy, customized }) => { + it(`and ${busy ? 'is busy' : 'is not busy'} and status ${ + customized ? 'is' : 'is not' + } customized sets user status data attributes`, () => { + setItem({ can_update: true, busy, customized }); + if (busy || customized) { + expect(findModalWrapper().attributes()).toMatchObject({ + 'data-current-emoji': userMenuMockStatus.emoji, + 'data-current-message': userMenuMockStatus.message, + 'data-current-availability': userMenuMockStatus.availability, + 'data-current-clear-status-after': userMenuMockStatus.clear_after, + }); + } else { + expect(findModalWrapper().attributes()).toMatchObject({ + 'data-current-emoji': '', + 'data-current-message': '', + 'data-default-emoji': 'speech_balloon', + }); + } }); }); }); @@ -143,7 +180,7 @@ describe('UserMenu component', () => { let item; const setItem = ({ has_start_trial } = {}) => { - createWrapper({ trial: { has_start_trial } }); + createWrapper({ trial: { has_start_trial, url: '' } }); item = wrapper.findByTestId('start-trial-item'); }; @@ -160,6 +197,15 @@ describe('UserMenu component', () => { expect(item.exists()).toBe(true); }); }); + + it('has Snowplow tracking attributes', () => { + setItem({ has_start_trial: true }); + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'start_trial', + }); + }); }); describe('Buy Pipeline Minutes item', () => { @@ -202,17 +248,30 @@ describe('UserMenu component', () => { expect(item.exists()).toBe(true); }); - it('tracks the Sentry event', () => { - setItem({ show_buy_pipeline_minutes: true }); - showDropdown(); - expect(trackingSpy).toHaveBeenCalledWith( - undefined, - userMenuMockPipelineMinutes.tracking_attrs['track-action'], - { - label: userMenuMockPipelineMinutes.tracking_attrs['track-label'], - property: userMenuMockPipelineMinutes.tracking_attrs['track-property'], - }, - ); + describe('Snowplow tracking attributes to track item click', () => { + beforeEach(() => { + setItem({ show_buy_pipeline_minutes: true }); + }); + + it('has attributes to track item click in scope of new nav', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'buy_pipeline_minutes', + }); + }); + + it('tracks the click on the item', () => { + item.vm.$emit('action'); + expect(trackingSpy).toHaveBeenCalledWith( + undefined, + userMenuMockPipelineMinutes.tracking_attrs['track-action'], + { + label: userMenuMockPipelineMinutes.tracking_attrs['track-label'], + property: userMenuMockPipelineMinutes.tracking_attrs['track-property'], + }, + ); + }); }); describe('Callout & notification dot', () => { @@ -292,33 +351,71 @@ describe('UserMenu component', () => { }); describe('Edit profile item', () => { - it('should render a link to the profile page', () => { + let item; + + beforeEach(() => { createWrapper(); - const item = wrapper.findByTestId('edit-profile-item'); + item = wrapper.findByTestId('edit-profile-item'); + }); + + it('should render a link to the profile page', () => { expect(item.text()).toBe(UserMenu.i18n.editProfile); expect(item.find('a').attributes('href')).toBe(userMenuMockData.settings.profile_path); }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'user_edit_profile', + }); + }); }); describe('Preferences item', () => { - it('should render a link to the profile page', () => { + let item; + + beforeEach(() => { createWrapper(); - const item = wrapper.findByTestId('preferences-item'); + item = wrapper.findByTestId('preferences-item'); + }); + + it('should render a link to the profile page', () => { expect(item.text()).toBe(UserMenu.i18n.preferences); expect(item.find('a').attributes('href')).toBe( userMenuMockData.settings.profile_preferences_path, ); }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'user_preferences', + }); + }); }); describe('GitLab Next item', () => { describe('on gitlab.com', () => { - it('should render a link to switch to GitLab Next', () => { + let item; + + beforeEach(() => { createWrapper({ gitlab_com_but_not_canary: true }); - const item = wrapper.findByTestId('gitlab-next-item'); + item = wrapper.findByTestId('gitlab-next-item'); + }); + it('should render a link to switch to GitLab Next', () => { expect(item.text()).toBe(UserMenu.i18n.gitlabNext); expect(item.find('a').attributes('href')).toBe(userMenuMockData.canary_toggle_com_url); }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'switch_to_canary', + }); + }); }); describe('anywhere else', () => { @@ -340,10 +437,23 @@ describe('UserMenu component', () => { }); describe('Feedback item', () => { - it('should render feedback item with a link to a new GitLab issue', () => { + let item; + + beforeEach(() => { createWrapper(); - const feedbackItem = wrapper.findByTestId('feedback-item'); - expect(feedbackItem.find('a').attributes('href')).toBe(UserMenu.feedbackUrl); + item = wrapper.findByTestId('feedback-item'); + }); + + it('should render feedback item with a link to a new GitLab issue', () => { + expect(item.find('a').attributes('href')).toBe(UserMenu.feedbackUrl); + }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'provide_nav_beta_feedback', + }); }); }); @@ -370,6 +480,15 @@ describe('UserMenu component', () => { ); expect(findSignOutGroup().find('a').attributes('data-method')).toBe('post'); }); + + it('should track Snowplow event on sign out', () => { + findSignOutGroup().vm.$emit('action'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { + label: 'user_sign_out', + property: 'nav_user_menu', + }); + }); }); }); }); diff --git a/spec/frontend/super_sidebar/components/user_name_group_spec.js b/spec/frontend/super_sidebar/components/user_name_group_spec.js index c06c8c218d4..6e3b18d3107 100644 --- a/spec/frontend/super_sidebar/components/user_name_group_spec.js +++ b/spec/frontend/super_sidebar/components/user_name_group_spec.js @@ -41,10 +41,12 @@ describe('UserNameGroup component', () => { }); it('passes the item to the disclosure dropdown item', () => { - expect(findGlDisclosureDropdownItem().props('item')).toEqual({ - text: userMenuMockData.name, - href: userMenuMockData.link_to_profile, - }); + expect(findGlDisclosureDropdownItem().props('item')).toEqual( + expect.objectContaining({ + text: userMenuMockData.name, + href: userMenuMockData.link_to_profile, + }), + ); }); it("renders user's name", () => { @@ -97,4 +99,16 @@ describe('UserNameGroup component', () => { }); }); }); + + describe('Tracking', () => { + it('sets the tracking attributes', () => { + expect(findGlDisclosureDropdownItem().find('a').attributes()).toEqual( + expect.objectContaining({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'user_profile', + }), + ); + }); + }); }); diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index b540f85d9fe..0c9449d98a9 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -49,11 +49,23 @@ export const mergeRequestMenuGroup = [ text: 'Assigned', href: '/dashboard/merge_requests?assignee_username=root', count: 4, + extraAttrs: { + 'data-track-action': 'click_link', + 'data-track-label': 'merge_requests_assigned', + 'data-track-property': 'nav_core_menu', + class: 'dashboard-shortcuts-merge_requests', + }, }, { text: 'Review requests', href: '/dashboard/merge_requests?reviewer_username=root', count: 0, + extraAttrs: { + 'data-track-action': 'click_link', + 'data-track-label': 'merge_requests_to_review', + 'data-track-property': 'nav_core_menu', + class: 'dashboard-shortcuts-review_requests', + }, }, ], }, @@ -86,6 +98,21 @@ export const sidebarData = { gitlab_version_check: { severity: 'success' }, gitlab_com_and_canary: false, canary_toggle_com_url: 'https://next.gitlab.com', + context_switcher_links: [], + search: { + search_path: '/search', + }, + pinned_items: [], + panel_type: 'your_work', + update_pins_url: 'path/to/pins', + stop_impersonation_path: '/admin/impersonation', + shortcut_links: [ + { + title: 'Shortcut link', + href: '/shortcut-link', + css_class: 'shortcut-link-class', + }, + ], }; export const userMenuMockStatus = { @@ -122,6 +149,7 @@ export const userMenuMockData = { status: userMenuMockStatus, trial: { has_start_trial: false, + url: invalidUrl, }, settings: { profile_path: invalidUrl, diff --git a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js index 3824965970b..cadcf8c08a3 100644 --- a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js +++ b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js @@ -1,16 +1,14 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { getCookie, setCookie } from '~/lib/utils/common_utils'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { sidebarState } from '~/super_sidebar/constants'; import { SIDEBAR_COLLAPSED_CLASS, SIDEBAR_COLLAPSED_COOKIE, SIDEBAR_COLLAPSED_COOKIE_EXPIRATION, toggleSuperSidebarCollapsed, initSuperSidebarCollapsedState, - bindSuperSidebarCollapsedEvents, findPage, - findSidebar, - findToggles, } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; const { xl, sm } = breakpoints; @@ -33,7 +31,6 @@ describe('Super Sidebar Collapsed State Manager', () => { setHTMLFixture(` <div class="page-with-super-sidebar"> <aside class="super-sidebar"></aside> - <button class="js-super-sidebar-toggle"></button> </div> `); }); @@ -61,8 +58,7 @@ describe('Super Sidebar Collapsed State Manager', () => { toggleSuperSidebarCollapsed(collapsed, saveCookie); pageHasCollapsedClass(hasClass); - expect(findSidebar().ariaHidden).toBe(collapsed); - expect(findSidebar().inert).toBe(collapsed); + expect(sidebarState.isCollapsed).toBe(collapsed); if (saveCookie && windowWidth >= xl) { expect(setCookie).toHaveBeenCalledWith(SIDEBAR_COLLAPSED_COOKIE, collapsed, { @@ -73,29 +69,6 @@ describe('Super Sidebar Collapsed State Manager', () => { } }, ); - - describe('focus', () => { - it.each` - collapsed | isUserAction - ${false} | ${true} - ${false} | ${false} - ${true} | ${true} - ${true} | ${false} - `( - 'when collapsed is $collapsed, isUserAction is $isUserAction', - ({ collapsed, isUserAction }) => { - const sidebar = findSidebar(); - jest.spyOn(sidebar, 'focus'); - toggleSuperSidebarCollapsed(collapsed, false, isUserAction); - - if (!collapsed && isUserAction) { - expect(sidebar.focus).toHaveBeenCalled(); - } else { - expect(sidebar.focus).not.toHaveBeenCalled(); - } - }, - ); - }); }); describe('initSuperSidebarCollapsedState', () => { @@ -118,40 +91,4 @@ describe('Super Sidebar Collapsed State Manager', () => { }, ); }); - - describe('bindSuperSidebarCollapsedEvents', () => { - it.each` - windowWidth | cookie | hasClass - ${xl} | ${undefined} | ${true} - ${sm} | ${undefined} | ${true} - ${xl} | ${'true'} | ${false} - ${sm} | ${'true'} | ${false} - `( - 'toggle click sets page class to `page-with-super-sidebar-collapsed` when windowWidth is $windowWidth and cookie value is $cookie', - ({ windowWidth, cookie, hasClass }) => { - setHTMLFixture(` - <div class="page-with-super-sidebar ${cookie ? SIDEBAR_COLLAPSED_CLASS : ''}"> - <aside class="super-sidebar"></aside> - <button class="js-super-sidebar-toggle"></button> - </div> - `); - jest.spyOn(bp, 'windowWidth').mockReturnValue(windowWidth); - getCookie.mockReturnValue(cookie); - - bindSuperSidebarCollapsedEvents(); - - findToggles()[0].click(); - - pageHasCollapsedClass(hasClass); - - if (windowWidth >= xl) { - expect(setCookie).toHaveBeenCalledWith(SIDEBAR_COLLAPSED_COOKIE, !cookie, { - expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION, - }); - } else { - expect(setCookie).not.toHaveBeenCalled(); - } - }, - ); - }); }); diff --git a/spec/frontend/surveys/merge_request_performance/app_spec.js b/spec/frontend/surveys/merge_request_performance/app_spec.js index af91d8aeb6b..d03451c71a8 100644 --- a/spec/frontend/surveys/merge_request_performance/app_spec.js +++ b/spec/frontend/surveys/merge_request_performance/app_spec.js @@ -56,17 +56,17 @@ describe('MergeRequestExperienceSurveyApp', () => { createWrapper(); }); - it('shows survey', async () => { + it('shows survey', () => { expect(wrapper.html()).toContain('Overall, how satisfied are you with merge requests?'); expect(wrapper.findComponent(SatisfactionRate).exists()).toBe(true); expect(wrapper.emitted().close).toBe(undefined); }); - it('tracks render once', async () => { + it('tracks render once', () => { expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments()); }); - it("doesn't track subsequent renders", async () => { + it("doesn't track subsequent renders", () => { createWrapper(); expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments()); expect(trackingSpy).toHaveBeenCalledTimes(1); @@ -77,15 +77,15 @@ describe('MergeRequestExperienceSurveyApp', () => { findCloseButton().vm.$emit('click'); }); - it('triggers user callout on close', async () => { + it('triggers user callout on close', () => { expect(dismiss).toHaveBeenCalledTimes(1); }); - it('emits close event on close button click', async () => { + it('emits close event on close button click', () => { expect(wrapper.emitted()).toMatchObject({ close: [[]] }); }); - it('tracks dismissal', async () => { + it('tracks dismissal', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', { label: 'dismiss', extra: { @@ -94,7 +94,7 @@ describe('MergeRequestExperienceSurveyApp', () => { }); }); - it('tracks subsequent renders', async () => { + it('tracks subsequent renders', () => { createWrapper(); expect(trackingSpy.mock.calls).toEqual([ createRenderTrackedArguments(), @@ -110,7 +110,7 @@ describe('MergeRequestExperienceSurveyApp', () => { ); }); - it('dismisses user callout on survey rate', async () => { + it('dismisses user callout on survey rate', () => { const rate = wrapper.findComponent(SatisfactionRate); expect(dismiss).not.toHaveBeenCalled(); rate.vm.$emit('rate', 5); @@ -126,7 +126,7 @@ describe('MergeRequestExperienceSurveyApp', () => { ); }); - it('tracks survey rates', async () => { + it('tracks survey rates', () => { const rate = wrapper.findComponent(SatisfactionRate); rate.vm.$emit('rate', 5); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', { @@ -146,7 +146,7 @@ describe('MergeRequestExperienceSurveyApp', () => { }); }); - it('shows legal note', async () => { + it('shows legal note', () => { expect(wrapper.text()).toContain( 'By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the GitLab Privacy Policy.', ); @@ -179,11 +179,11 @@ describe('MergeRequestExperienceSurveyApp', () => { createWrapper({ shouldShowCallout: false }); }); - it('emits close event', async () => { + it('emits close event', () => { expect(wrapper.emitted()).toMatchObject({ close: [[]] }); }); - it("doesn't track anything", async () => { + it("doesn't track anything", () => { expect(trackingSpy).toHaveBeenCalledTimes(0); }); }); @@ -195,12 +195,12 @@ describe('MergeRequestExperienceSurveyApp', () => { document.dispatchEvent(event); }); - it('emits close event', async () => { + it('emits close event', () => { expect(wrapper.emitted()).toMatchObject({ close: [[]] }); expect(dismiss).toHaveBeenCalledTimes(1); }); - it('tracks dismissal', async () => { + it('tracks dismissal', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', { label: 'dismiss', extra: { diff --git a/spec/frontend/tags/components/delete_tag_modal_spec.js b/spec/frontend/tags/components/delete_tag_modal_spec.js index 8438bdb7db0..8ec9925563a 100644 --- a/spec/frontend/tags/components/delete_tag_modal_spec.js +++ b/spec/frontend/tags/components/delete_tag_modal_spec.js @@ -69,7 +69,7 @@ describe('Delete tag modal', () => { expect(submitFormSpy).toHaveBeenCalled(); }); - it('calls show on the modal when a `openModal` event is received through the event hub', async () => { + it('calls show on the modal when a `openModal` event is received through the event hub', () => { const showSpy = jest.spyOn(wrapper.vm.$refs.modal, 'show'); eventHub.$emit('openModal', { diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index 31a644b39b4..ed85825c13a 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -143,7 +143,7 @@ describe('StatesTableActions', () => { return waitForPromises(); }); - it('opens the modal', async () => { + it('opens the modal', () => { expect(findCopyModal().exists()).toBe(true); expect(findCopyModal().isVisible()).toBe(true); }); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 3fb226e5ed3..d3d3e5c8c72 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -1,8 +1,15 @@ /* Setup for unit test environment */ // eslint-disable-next-line no-restricted-syntax import { setImmediate } from 'timers'; +import Dexie from 'dexie'; +import { IDBKeyRange, IDBFactory } from 'fake-indexeddb'; import 'helpers/shared_test_setup'; +const indexedDB = new IDBFactory(); + +Dexie.dependencies.indexedDB = indexedDB; +Dexie.dependencies.IDBKeyRange = IDBKeyRange; + afterEach(() => // give Promises a bit more time so they fail the right test // eslint-disable-next-line no-restricted-syntax @@ -11,3 +18,9 @@ afterEach(() => jest.runOnlyPendingTimers(); }), ); + +afterEach(async () => { + const dbs = await indexedDB.databases(); + + await Promise.all(dbs.map((db) => indexedDB.deleteDatabase(db.name))); +}); diff --git a/spec/frontend/time_tracking/components/timelog_source_cell_spec.js b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js new file mode 100644 index 00000000000..b9be4689c38 --- /dev/null +++ b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js @@ -0,0 +1,136 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue'; +import { + IssuableStatusText, + STATUS_CLOSED, + STATUS_MERGED, + STATUS_OPEN, + STATUS_LOCKED, + STATUS_REOPENED, +} from '~/issues/constants'; + +const createIssuableTimelogMock = ( + type, + { title, state, webUrl, reference } = { + title: 'Issuable title', + state: STATUS_OPEN, + webUrl: 'https://example.com/issuable_url', + reference: '#111', + }, +) => { + return { + timelog: { + project: { + fullPath: 'group/project', + }, + [type]: { + title, + state, + webUrl, + reference, + }, + }, + }; +}; + +describe('TimelogSourceCell component', () => { + Vue.use(VueApollo); + + let wrapper; + + const findTitleContainer = () => wrapper.findByTestId('title-container'); + const findReferenceContainer = () => wrapper.findByTestId('reference-container'); + const findStateContainer = () => wrapper.findByTestId('state-container'); + + const mountComponent = ({ timelog } = {}) => { + wrapper = shallowMountExtended(TimelogSourceCell, { + propsData: { + timelog, + }, + }); + }; + + describe('when the timelog is associated to an issue', () => { + it('shows the issue title as link to the issue', () => { + mountComponent( + createIssuableTimelogMock('issue', { + title: 'Issue title', + webUrl: 'https://example.com/issue_url', + }), + ); + + const titleContainer = findTitleContainer(); + + expect(titleContainer.text()).toBe('Issue title'); + expect(titleContainer.attributes('href')).toBe('https://example.com/issue_url'); + }); + + it('shows the issue full reference as link to the issue', () => { + mountComponent( + createIssuableTimelogMock('issue', { + reference: '#111', + webUrl: 'https://example.com/issue_url', + }), + ); + + const referenceContainer = findReferenceContainer(); + + expect(referenceContainer.text()).toBe('group/project#111'); + expect(referenceContainer.attributes('href')).toBe('https://example.com/issue_url'); + }); + + it.each` + state | stateDescription + ${STATUS_OPEN} | ${IssuableStatusText[STATUS_OPEN]} + ${STATUS_REOPENED} | ${IssuableStatusText[STATUS_REOPENED]} + ${STATUS_LOCKED} | ${IssuableStatusText[STATUS_LOCKED]} + ${STATUS_CLOSED} | ${IssuableStatusText[STATUS_CLOSED]} + `('shows $stateDescription when the state is $state', ({ state, stateDescription }) => { + mountComponent(createIssuableTimelogMock('issue', { state })); + + expect(findStateContainer().text()).toBe(stateDescription); + }); + }); + + describe('when the timelog is associated to a merge request', () => { + it('shows the merge request title as link to the merge request', () => { + mountComponent( + createIssuableTimelogMock('mergeRequest', { + title: 'MR title', + webUrl: 'https://example.com/mr_url', + }), + ); + + const titleContainer = findTitleContainer(); + + expect(titleContainer.text()).toBe('MR title'); + expect(titleContainer.attributes('href')).toBe('https://example.com/mr_url'); + }); + + it('shows the merge request full reference as link to the merge request', () => { + mountComponent( + createIssuableTimelogMock('mergeRequest', { + reference: '!111', + webUrl: 'https://example.com/mr_url', + }), + ); + + const referenceContainer = findReferenceContainer(); + + expect(referenceContainer.text()).toBe('group/project!111'); + expect(referenceContainer.attributes('href')).toBe('https://example.com/mr_url'); + }); + it.each` + state | stateDescription + ${STATUS_OPEN} | ${IssuableStatusText[STATUS_OPEN]} + ${STATUS_CLOSED} | ${IssuableStatusText[STATUS_CLOSED]} + ${STATUS_MERGED} | ${IssuableStatusText[STATUS_MERGED]} + `('shows $stateDescription when the state is $state', ({ state, stateDescription }) => { + mountComponent(createIssuableTimelogMock('mergeRequest', { state })); + + expect(findStateContainer().text()).toBe(stateDescription); + }); + }); +}); diff --git a/spec/frontend/time_tracking/components/timelogs_app_spec.js b/spec/frontend/time_tracking/components/timelogs_app_spec.js new file mode 100644 index 00000000000..ca470ce63ac --- /dev/null +++ b/spec/frontend/time_tracking/components/timelogs_app_spec.js @@ -0,0 +1,238 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '@sentry/browser'; +import { GlDatepicker, GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import getTimelogsEmptyResponse from 'test_fixtures/graphql/get_timelogs_empty_response.json'; +import getPaginatedTimelogsResponse from 'test_fixtures/graphql/get_paginated_timelogs_response.json'; +import getNonPaginatedTimelogsResponse from 'test_fixtures/graphql/get_non_paginated_timelogs_response.json'; +import { createAlert } from '~/alert'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getTimelogsQuery from '~/time_tracking/components/queries/get_timelogs.query.graphql'; +import TimelogsApp from '~/time_tracking/components/timelogs_app.vue'; +import TimelogsTable from '~/time_tracking/components/timelogs_table.vue'; + +jest.mock('~/alert'); +jest.mock('@sentry/browser'); + +describe('Timelogs app', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const findForm = () => wrapper.find('form'); + const findUsernameInput = () => extendedWrapper(findForm()).findByTestId('form-username'); + const findTableContainer = () => wrapper.findByTestId('table-container'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTotalTimeSpentContainer = () => wrapper.findByTestId('total-time-spent-container'); + const findTable = () => wrapper.findComponent(TimelogsTable); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + const findFormDatePicker = (testId) => + findForm() + .findAllComponents(GlDatepicker) + .filter((c) => c.attributes('data-testid') === testId); + const findFromDatepicker = () => findFormDatePicker('form-from-date').at(0); + const findToDatepicker = () => findFormDatePicker('form-to-date').at(0); + + const submitForm = () => findForm().trigger('submit'); + + const resolvedEmptyListMock = jest.fn().mockResolvedValue(getTimelogsEmptyResponse); + const resolvedPaginatedListMock = jest.fn().mockResolvedValue(getPaginatedTimelogsResponse); + const resolvedNonPaginatedListMock = jest.fn().mockResolvedValue(getNonPaginatedTimelogsResponse); + const rejectedMock = jest.fn().mockRejectedValue({}); + + const mountComponent = ({ props, data } = {}, queryResolverMock = resolvedEmptyListMock) => { + fakeApollo = createMockApollo([[getTimelogsQuery, queryResolverMock]]); + + wrapper = mountExtended(TimelogsApp, { + data() { + return { + ...data, + }; + }, + propsData: { + limitToHours: false, + ...props, + }, + apolloProvider: fakeApollo, + }); + }; + + beforeEach(() => { + createAlert.mockClear(); + Sentry.captureException.mockClear(); + }); + + afterEach(() => { + fakeApollo = null; + }); + + describe('the content', () => { + it('shows the form and the loading icon when loading', () => { + mountComponent(); + + expect(findForm().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(true); + expect(findTableContainer().exists()).toBe(false); + }); + + it('shows the form and the table container when finished loading', async () => { + mountComponent(); + + await waitForPromises(); + + expect(findForm().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(false); + expect(findTableContainer().exists()).toBe(true); + }); + }); + + describe('the filter form', () => { + it('runs the query with the correct data', async () => { + mountComponent(); + + const username = 'johnsmith'; + const fromDate = new Date('2023-02-28'); + const toDate = new Date('2023-03-28'); + + findUsernameInput().vm.$emit('input', username); + findFromDatepicker().vm.$emit('input', fromDate); + findToDatepicker().vm.$emit('input', toDate); + + resolvedEmptyListMock.mockClear(); + + submitForm(); + + await waitForPromises(); + + expect(resolvedEmptyListMock).toHaveBeenCalledWith({ + username, + startDate: fromDate, + endDate: toDate, + groupId: null, + projectId: null, + first: 20, + last: null, + after: null, + before: null, + }); + expect(createAlert).not.toHaveBeenCalled(); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('runs the query with the correct data after the date filters are cleared', async () => { + mountComponent(); + + const username = 'johnsmith'; + + findUsernameInput().vm.$emit('input', username); + findFromDatepicker().vm.$emit('clear'); + findToDatepicker().vm.$emit('clear'); + + resolvedEmptyListMock.mockClear(); + + submitForm(); + + await waitForPromises(); + + expect(resolvedEmptyListMock).toHaveBeenCalledWith({ + username, + startDate: null, + endDate: null, + groupId: null, + projectId: null, + first: 20, + last: null, + after: null, + before: null, + }); + expect(createAlert).not.toHaveBeenCalled(); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('shows an alert an logs to sentry when the mutation is rejected', async () => { + mountComponent({}, rejectedMock); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Something went wrong. Please try again.', + }); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('the total time spent container', () => { + it('is not visible when there are no timelogs', async () => { + mountComponent(); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(false); + }); + + it('shows the correct value when `limitToHours` is false', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(true); + expect(findTotalTimeSpentContainer().text()).toBe('3d'); + }); + + it('shows the correct value when `limitToHours` is true', async () => { + mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(true); + expect(findTotalTimeSpentContainer().text()).toBe('24h'); + }); + }); + + describe('the table', () => { + it('gets created with the right props when `limitToHours` is false', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTable().props()).toMatchObject({ + limitToHours: false, + entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes, + }); + }); + + it('gets created with the right props when `limitToHours` is true', async () => { + mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTable().props()).toMatchObject({ + limitToHours: true, + entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes, + }); + }); + }); + + describe('the pagination element', () => { + it('is not visible whene there is no pagination data', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is visible whene there is pagination data', async () => { + mountComponent({}, resolvedPaginatedListMock); + + await waitForPromises(); + await nextTick(); + + expect(findPagination().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/time_tracking/components/timelogs_table_spec.js b/spec/frontend/time_tracking/components/timelogs_table_spec.js new file mode 100644 index 00000000000..980fb79e8fb --- /dev/null +++ b/spec/frontend/time_tracking/components/timelogs_table_spec.js @@ -0,0 +1,223 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlTable } from '@gitlab/ui'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TimelogsTable from '~/time_tracking/components/timelogs_table.vue'; +import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; + +const baseTimelogMock = { + timeSpent: 600, + project: { + fullPath: 'group/project', + }, + user: { + name: 'John Smith', + avatarUrl: 'https://example.gitlab.com/john.jpg', + webPath: 'https://example.gitlab.com/john', + }, + spentAt: '2023-03-27T21:00:00Z', + note: null, + summary: 'Summary from timelog field', + issue: { + title: 'Issue title', + webUrl: 'https://example.gitlab.com/issue_url_a', + state: STATUS_OPEN, + reference: '#111', + }, + mergeRequest: null, +}; + +const timelogsMock = [ + baseTimelogMock, + { + timeSpent: 3600, + project: { + fullPath: 'group/project_b', + }, + user: { + name: 'Paul Reed', + avatarUrl: 'https://example.gitlab.com/paul.jpg', + webPath: 'https://example.gitlab.com/paul', + }, + spentAt: '2023-03-28T16:00:00Z', + note: { + body: 'Summary from the body', + }, + summary: null, + issue: { + title: 'Other issue title', + webUrl: 'https://example.gitlab.com/issue_url_b', + state: STATUS_CLOSED, + reference: '#112', + }, + mergeRequest: null, + }, + { + timeSpent: 27 * 60 * 60, // 27h or 3d 3h (3 days of 8 hours) + project: { + fullPath: 'group/project_b', + }, + user: { + name: 'Les Gibbons', + avatarUrl: 'https://example.gitlab.com/les.jpg', + webPath: 'https://example.gitlab.com/les', + }, + spentAt: '2023-03-28T18:00:00Z', + note: null, + summary: 'Other timelog summary', + issue: null, + mergeRequest: { + title: 'MR title', + webUrl: 'https://example.gitlab.com/mr_url', + state: STATUS_MERGED, + reference: '!99', + }, + }, +]; + +describe('TimelogsTable component', () => { + Vue.use(VueApollo); + + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + const findTableRows = () => findTable().find('tbody').findAll('tr'); + const findRowSpentAt = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('date-container'); + const findRowSource = (rowIndex) => findTableRows().at(rowIndex).findComponent(TimelogSourceCell); + const findRowUser = (rowIndex) => findTableRows().at(rowIndex).findComponent(UserAvatarLink); + const findRowTimeSpent = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('time-spent-container'); + const findRowSummary = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('summary-container'); + + const mountComponent = (props = {}) => { + wrapper = mountExtended(TimelogsTable, { + propsData: { + entries: timelogsMock, + limitToHours: false, + ...props, + }, + stubs: { GlTable }, + }); + }; + + describe('when there are no entries', () => { + it('show the empty table message and no rows', () => { + mountComponent({ entries: [] }); + + expect(findTable().text()).toContain('There are no records to show'); + expect(findTableRows()).toHaveLength(1); + }); + }); + + describe('when there are some entries', () => { + it('does not show the empty table message and has the correct number of rows', () => { + mountComponent(); + + expect(findTable().text()).not.toContain('There are no records to show'); + expect(findTableRows()).toHaveLength(3); + }); + + describe('Spent at column', () => { + it('shows the spent at value with in the correct format', () => { + mountComponent(); + + expect(findRowSpentAt(0).text()).toBe('March 27, 2023, 21:00 (UTC: +0000)'); + }); + }); + + describe('Source column', () => { + it('creates the source cell component passing the right props', () => { + mountComponent(); + + expect(findRowSource(0).props()).toMatchObject({ + timelog: timelogsMock[0], + }); + expect(findRowSource(1).props()).toMatchObject({ + timelog: timelogsMock[1], + }); + expect(findRowSource(2).props()).toMatchObject({ + timelog: timelogsMock[2], + }); + }); + }); + + describe('User column', () => { + it('creates the user avatar component passing the right props', () => { + mountComponent(); + + expect(findRowUser(0).props()).toMatchObject({ + linkHref: timelogsMock[0].user.webPath, + imgSrc: timelogsMock[0].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[0].user.name, + tooltipText: timelogsMock[0].user.name, + username: timelogsMock[0].user.name, + }); + expect(findRowUser(1).props()).toMatchObject({ + linkHref: timelogsMock[1].user.webPath, + imgSrc: timelogsMock[1].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[1].user.name, + tooltipText: timelogsMock[1].user.name, + username: timelogsMock[1].user.name, + }); + expect(findRowUser(2).props()).toMatchObject({ + linkHref: timelogsMock[2].user.webPath, + imgSrc: timelogsMock[2].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[2].user.name, + tooltipText: timelogsMock[2].user.name, + username: timelogsMock[2].user.name, + }); + }); + }); + + describe('Time spent column', () => { + it('shows the time spent value with the correct format when `limitToHours` is false', () => { + mountComponent(); + + expect(findRowTimeSpent(0).text()).toBe('10m'); + expect(findRowTimeSpent(1).text()).toBe('1h'); + expect(findRowTimeSpent(2).text()).toBe('3d 3h'); + }); + + it('shows the time spent value with the correct format when `limitToHours` is true', () => { + mountComponent({ limitToHours: true }); + + expect(findRowTimeSpent(0).text()).toBe('10m'); + expect(findRowTimeSpent(1).text()).toBe('1h'); + expect(findRowTimeSpent(2).text()).toBe('27h'); + }); + }); + + describe('Summary column', () => { + it('shows the summary from the note when note body is present and not empty', () => { + mountComponent({ + entries: [{ ...baseTimelogMock, note: { body: 'Summary from note body' } }], + }); + + expect(findRowSummary(0).text()).toBe('Summary from note body'); + }); + + it('shows the summary from the timelog note body is present but empty', () => { + mountComponent({ + entries: [{ ...baseTimelogMock, note: { body: '' } }], + }); + + expect(findRowSummary(0).text()).toBe('Summary from timelog field'); + }); + + it('shows the summary from the timelog note body is not present', () => { + mountComponent({ + entries: [baseTimelogMock], + }); + + expect(findRowSummary(0).text()).toBe('Summary from timelog field'); + }); + }); + }); +}); diff --git a/spec/frontend/toggles/index_spec.js b/spec/frontend/toggles/index_spec.js index 89e35991914..cccdf17a787 100644 --- a/spec/frontend/toggles/index_spec.js +++ b/spec/frontend/toggles/index_spec.js @@ -52,7 +52,7 @@ describe('toggles/index.js', () => { initToggleWithOptions(); }); - it('attaches a GlToggle to the element', async () => { + it('attaches a GlToggle to the element', () => { expect(toggleWrapper).not.toBe(null); expect(toggleWrapper.querySelector(TOGGLE_LABEL_CLASS).textContent).toBe(toggleLabel); }); diff --git a/spec/frontend/tracking/tracking_initialization_spec.js b/spec/frontend/tracking/tracking_initialization_spec.js index f1628ad9793..0f888e6e1cc 100644 --- a/spec/frontend/tracking/tracking_initialization_spec.js +++ b/spec/frontend/tracking/tracking_initialization_spec.js @@ -52,14 +52,11 @@ describe('Tracking', () => { hostname: 'app.test.com', cookieDomain: '.test.com', appId: '', - userFingerprint: false, respectDoNotTrack: true, - forceSecureTracker: true, eventMethod: 'post', contexts: { webPage: true, performanceTiming: true }, formTracking: false, linkClickTracking: false, - pageUnloadTimer: 10, formTrackingConfig: { fields: { allow: [] }, forms: { allow: [] }, @@ -80,8 +77,14 @@ describe('Tracking', () => { it('should activate features based on what has been enabled', () => { initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', 'GitLab', [standardContext]); + expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', { + minimumVisitLength: 30, + heartbeatDelay: 30, + }); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', { + title: 'GitLab', + context: [standardContext], + }); expect(snowplowSpy).toHaveBeenCalledWith('setDocumentTitle', 'GitLab'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); @@ -131,10 +134,10 @@ describe('Tracking', () => { it('includes those contexts alongside the standard context', () => { initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', 'GitLab', [ - standardContext, - ...experimentContexts, - ]); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', { + title: 'GitLab', + context: [standardContext, ...experimentContexts], + }); }); }); }); diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index 4871644d99f..c23790bb589 100644 --- a/spec/frontend/tracking/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -65,15 +65,14 @@ describe('Tracking', () => { it('tracks to snowplow (our current tracking system)', () => { Tracking.event(TEST_CATEGORY, TEST_ACTION, { label: TEST_LABEL }); - expect(snowplowSpy).toHaveBeenCalledWith( - 'trackStructEvent', - TEST_CATEGORY, - TEST_ACTION, - TEST_LABEL, - undefined, - undefined, - [standardContext], - ); + expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', { + category: TEST_CATEGORY, + action: TEST_ACTION, + label: TEST_LABEL, + property: undefined, + value: undefined, + context: [standardContext], + }); }); it('returns `true` if the Snowplow library was called without issues', () => { @@ -93,14 +92,13 @@ describe('Tracking', () => { Tracking.event(TEST_CATEGORY, TEST_ACTION, { extra }); - expect(snowplowSpy).toHaveBeenCalledWith( - 'trackStructEvent', - TEST_CATEGORY, - TEST_ACTION, - undefined, - undefined, - undefined, - [ + expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', { + category: TEST_CATEGORY, + action: TEST_ACTION, + label: undefined, + property: undefined, + value: undefined, + context: [ { ...standardContext, data: { @@ -109,7 +107,7 @@ describe('Tracking', () => { }, }, ], - ); + }); }); it('skips tracking if snowplow is unavailable', () => { @@ -209,14 +207,16 @@ describe('Tracking', () => { describe('.enableFormTracking', () => { it('tells snowplow to enable form tracking, with only explicit contexts', () => { - const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } }; + const config = { + forms: { allow: ['form-class1'] }, + fields: { allow: ['input-class1'] }, + }; Tracking.enableFormTracking(config, ['_passed_context_', standardContext]); - expect(snowplowSpy).toHaveBeenCalledWith( - 'enableFormTracking', - { forms: { whitelist: ['form-class1'] }, fields: { whitelist: ['input-class1'] } }, - ['_passed_context_'], - ); + expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking', { + options: { forms: { allowlist: ['form-class1'] }, fields: { allowlist: ['input-class1'] } }, + context: ['_passed_context_'], + }); }); it('throws an error if no allow rules are provided', () => { @@ -232,11 +232,10 @@ describe('Tracking', () => { it('does not add empty form allow rules', () => { Tracking.enableFormTracking({ fields: { allow: ['input-class1'] } }); - expect(snowplowSpy).toHaveBeenCalledWith( - 'enableFormTracking', - { fields: { whitelist: ['input-class1'] } }, - [], - ); + expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking', { + options: { fields: { allowlist: ['input-class1'] } }, + context: [], + }); }); describe('when `document.readyState` does not equal `complete`', () => { @@ -285,15 +284,14 @@ describe('Tracking', () => { Tracking.flushPendingEvents(); - expect(snowplowSpy).toHaveBeenCalledWith( - 'trackStructEvent', - TEST_CATEGORY, - TEST_ACTION, - TEST_LABEL, - undefined, - undefined, - [standardContext], - ); + expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', { + category: TEST_CATEGORY, + action: TEST_ACTION, + label: TEST_LABEL, + property: undefined, + value: undefined, + context: [standardContext], + }); }); }); @@ -457,15 +455,14 @@ describe('Tracking', () => { value: '0', }); - expect(snowplowSpy).toHaveBeenCalledWith( - 'trackStructEvent', - TEST_CATEGORY, - 'click_input2', - undefined, - undefined, - 0, - [standardContext], - ); + expect(snowplowSpy).toHaveBeenCalledWith('trackStructEvent', { + category: TEST_CATEGORY, + action: 'click_input2', + label: undefined, + property: undefined, + value: 0, + context: [standardContext], + }); }); it('handles checkbox values correctly', () => { diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js index 6065ec9e4bf..15758c94436 100644 --- a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js +++ b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js @@ -2,12 +2,7 @@ import { GlTableLite, GlPopover } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ProjectStorageDetail from '~/usage_quotas/storage/components/project_storage_detail.vue'; -import { - containerRegistryPopoverId, - containerRegistryId, - uploadsPopoverId, - uploadsId, -} from '~/usage_quotas/storage/constants'; +import { containerRegistryPopoverId, containerRegistryId } from '~/usage_quotas/storage/constants'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { projectData, projectHelpLinks } from '../mock_data'; @@ -47,9 +42,7 @@ describe('ProjectStorageDetail', () => { const findPopoverById = (id) => wrapper.findAllComponents(GlPopover).filter((p) => p.attributes('data-testid') === id); const findContainerRegistryPopover = () => findPopoverById(containerRegistryPopoverId); - const findUploadsPopover = () => findPopoverById(uploadsPopoverId); const findContainerRegistryWarningIcon = () => wrapper.find(`#${containerRegistryPopoverId}`); - const findUploadsWarningIcon = () => wrapper.find(`#${uploadsPopoverId}`); beforeEach(() => { createComponent(); @@ -96,31 +89,19 @@ describe('ProjectStorageDetail', () => { }); describe.each` - description | mockStorageTypes | rendersContainerRegistryPopover | rendersUploadsPopover - ${'without any storage type that has popover'} | ${[generateStorageType()]} | ${false} | ${false} - ${'with container registry storage type'} | ${[generateStorageType(containerRegistryId)]} | ${true} | ${false} - ${'with uploads storage type'} | ${[generateStorageType(uploadsId)]} | ${false} | ${true} - ${'with container registry and uploads storage types'} | ${[generateStorageType(containerRegistryId), generateStorageType(uploadsId)]} | ${true} | ${true} - `( - '$description', - ({ mockStorageTypes, rendersContainerRegistryPopover, rendersUploadsPopover }) => { - beforeEach(() => { - createComponent({ storageTypes: mockStorageTypes }); - }); - - it(`does ${ - rendersContainerRegistryPopover ? '' : ' not' - } render container registry warning icon and popover`, () => { - expect(findContainerRegistryWarningIcon().exists()).toBe(rendersContainerRegistryPopover); - expect(findContainerRegistryPopover().exists()).toBe(rendersContainerRegistryPopover); - }); + description | mockStorageTypes | rendersContainerRegistryPopover + ${'without any storage type that has popover'} | ${[generateStorageType()]} | ${false} + ${'with container registry storage type'} | ${[generateStorageType(containerRegistryId)]} | ${true} + `('$description', ({ mockStorageTypes, rendersContainerRegistryPopover }) => { + beforeEach(() => { + createComponent({ storageTypes: mockStorageTypes }); + }); - it(`does ${ - rendersUploadsPopover ? '' : ' not' - } render container registry warning icon and popover`, () => { - expect(findUploadsWarningIcon().exists()).toBe(rendersUploadsPopover); - expect(findUploadsPopover().exists()).toBe(rendersUploadsPopover); - }); - }, - ); + it(`does ${ + rendersContainerRegistryPopover ? '' : ' not' + } render container registry warning icon and popover`, () => { + expect(findContainerRegistryWarningIcon().exists()).toBe(rendersContainerRegistryPopover); + expect(findContainerRegistryPopover().exists()).toBe(rendersContainerRegistryPopover); + }); + }); }); diff --git a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js index 364cf1e587b..ebe4c4b7f4e 100644 --- a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js +++ b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js @@ -22,7 +22,6 @@ describe('StorageTypeIcon', () => { ${'snippet'} | ${'snippetsSize'} ${'infrastructure-registry'} | ${'repositorySize'} ${'package'} | ${'packagesSize'} - ${'upload'} | ${'uploadsSize'} ${'disk'} | ${'wikiSize'} ${'disk'} | ${'anything-else'} `( diff --git a/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js index 02268e1c9d8..2662711076b 100644 --- a/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js +++ b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js @@ -28,11 +28,10 @@ describe('Storage Counter usage graph component', () => { packagesSize: 3000, containerRegistrySize: 2500, lfsObjectsSize: 2000, - buildArtifactsSize: 500, - pipelineArtifactsSize: 500, + buildArtifactsSize: 700, + pipelineArtifactsSize: 300, snippetsSize: 2000, storageSize: 17000, - uploadsSize: 1000, }, limit: 2000, }; @@ -51,7 +50,6 @@ describe('Storage Counter usage graph component', () => { repositorySize, wikiSize, snippetsSize, - uploadsSize, } = data.rootStorageStatistics; expect(types.at(0).text()).toMatchInterpolatedText(`Wiki ${numberToHumanSize(wikiSize)}`); @@ -64,16 +62,16 @@ describe('Storage Counter usage graph component', () => { expect(types.at(3).text()).toMatchInterpolatedText( `Container Registry ${numberToHumanSize(containerRegistrySize)}`, ); - expect(types.at(4).text()).toMatchInterpolatedText( - `LFS storage ${numberToHumanSize(lfsObjectsSize)}`, - ); + expect(types.at(4).text()).toMatchInterpolatedText(`LFS ${numberToHumanSize(lfsObjectsSize)}`); expect(types.at(5).text()).toMatchInterpolatedText( `Snippets ${numberToHumanSize(snippetsSize)}`, ); expect(types.at(6).text()).toMatchInterpolatedText( - `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`, + `Job artifacts ${numberToHumanSize(buildArtifactsSize)}`, + ); + expect(types.at(7).text()).toMatchInterpolatedText( + `Pipeline artifacts ${numberToHumanSize(pipelineArtifactsSize)}`, ); - expect(types.at(7).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`); }); describe('when storage type is not used', () => { @@ -112,8 +110,8 @@ describe('Storage Counter usage graph component', () => { '0.14705882352941177', '0.11764705882352941', '0.11764705882352941', - '0.058823529411764705', - '0.058823529411764705', + '0.041176470588235294', + '0.01764705882352941', ]); }); }); @@ -132,8 +130,8 @@ describe('Storage Counter usage graph component', () => { '0.14705882352941177', '0.11764705882352941', '0.11764705882352941', - '0.058823529411764705', - '0.058823529411764705', + '0.041176470588235294', + '0.01764705882352941', ]); }); }); diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js index b1c6be10d80..b4b02f77b52 100644 --- a/spec/frontend/usage_quotas/storage/mock_data.js +++ b/spec/frontend/usage_quotas/storage/mock_data.js @@ -19,16 +19,25 @@ export const projectData = { { storageType: { id: 'buildArtifactsSize', - name: 'Artifacts', - description: 'Pipeline artifacts and job artifacts, created with CI/CD.', + name: 'Job artifacts', + description: 'Job artifacts created by CI/CD.', helpPath: '/build-artifacts', }, value: 400000, }, { storageType: { + id: 'pipelineArtifactsSize', + name: 'Pipeline artifacts', + description: 'Pipeline artifacts created by CI/CD.', + helpPath: '/pipeline-artifacts', + }, + value: 400000, + }, + { + storageType: { id: 'lfsObjectsSize', - name: 'LFS storage', + name: 'LFS', description: 'Audio samples, videos, datasets, and graphics.', helpPath: '/lsf-objects', }, @@ -63,15 +72,6 @@ export const projectData = { }, { storageType: { - id: 'uploadsSize', - name: 'Uploads', - description: 'File attachments and smaller design graphics.', - helpPath: '/uploads', - }, - value: 900000, - }, - { - storageType: { id: 'wikiSize', name: 'Wiki', description: 'Wiki content.', @@ -87,11 +87,11 @@ export const projectHelpLinks = { containerRegistry: '/container_registry', usageQuotas: '/usage-quotas', buildArtifacts: '/build-artifacts', + pipelineArtifacts: '/pipeline-artifacts', lfsObjects: '/lsf-objects', packages: '/packages', repository: '/repository', snippets: '/snippets', - uploads: '/uploads', wiki: '/wiki', }; diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js index 5f067d9de3c..b5eb6313bed 100644 --- a/spec/frontend/user_lists/components/edit_user_list_spec.js +++ b/spec/frontend/user_lists/components/edit_user_list_spec.js @@ -67,7 +67,7 @@ describe('user_lists/components/edit_user_list', () => { expect(alert.text()).toContain(message); }); - it('should not be dismissible', async () => { + it('should not be dismissible', () => { expect(alert.props('dismissible')).toBe(false); }); diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js index 603289ac11e..2da2eb0dd5f 100644 --- a/spec/frontend/user_lists/components/user_lists_spec.js +++ b/spec/frontend/user_lists/components/user_lists_spec.js @@ -82,7 +82,7 @@ describe('~/user_lists/components/user_lists.vue', () => { emptyState = wrapper.findComponent(GlEmptyState); }); - it('should render the empty state', async () => { + it('should render the empty state', () => { expect(emptyState.exists()).toBe(true); }); diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index 3808cc8b0fc..7fc1bcbc936 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -97,7 +97,7 @@ describe('User Popovers', () => { expect(findPopovers().length).toBe(linksWithUsers.length); }); - it('for elements added after initial load', async () => { + it('for elements added after initial load', () => { const addedLinks = [createUserLink(), createUserLink()]; addedLinks.forEach((link) => { document.body.appendChild(link); @@ -113,7 +113,7 @@ describe('User Popovers', () => { }); }); - it('does not initialize the popovers for group references', async () => { + it('does not initialize the popovers for group references', () => { const [groupLink] = Array.from(document.querySelectorAll('.js-user-link[data-group]')); triggerEvent('mouseover', groupLink); @@ -122,7 +122,7 @@ describe('User Popovers', () => { expect(findPopovers().length).toBe(0); }); - it('does not initialize the popovers for @all references', async () => { + it('does not initialize the popovers for @all references', () => { const [projectLink] = Array.from(document.querySelectorAll('.js-user-link[data-project]')); triggerEvent('mouseover', projectLink); @@ -131,7 +131,7 @@ describe('User Popovers', () => { expect(findPopovers().length).toBe(0); }); - it('does not initialize the user popovers twice for the same element', async () => { + it('does not initialize the user popovers twice for the same element', () => { const [firstUserLink] = findFixtureLinks(); triggerEvent('mouseover', firstUserLink); jest.runOnlyPendingTimers(); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js index 4775a0673b5..33647671853 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js @@ -1,10 +1,12 @@ import axios from 'axios'; +import { GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import MemoryUsage from '~/vue_merge_request_widget/components/deployment/memory_usage.vue'; import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import MemoryGraph from '~/vue_shared/components/memory_graph.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; const url = '/root/acets-review-apps/environments/15/deployments/1/metrics'; const monitoringUrl = '/root/acets-review-apps/environments/15/metrics'; @@ -35,50 +37,49 @@ const metricsMockData = { deployment_time: 1493718485, }; -const createComponent = () => { - const Component = Vue.extend(MemoryUsage); - - return new Component({ - el: document.createElement('div'), - propsData: { - metricsUrl: url, - metricsMonitoringUrl: monitoringUrl, - memoryMetrics: [], - deploymentTime: 0, - hasMetrics: false, - loadFailed: false, - loadingMetrics: true, - backOffRequestCounter: 0, - }, - }); -}; - const messages = { loadingMetrics: 'Loading deployment statistics', - hasMetrics: 'Memory usage is unchanged at 0MB', + hasMetrics: 'Memory usage is unchanged at 0.00MB', loadFailed: 'Failed to load deployment statistics', metricsUnavailable: 'Deployment statistics are not available currently', }; describe('MemoryUsage', () => { - let vm; - let el; + let wrapper; let mock; + const createComponent = () => { + wrapper = shallowMountExtended(MemoryUsage, { + propsData: { + metricsUrl: url, + metricsMonitoringUrl: monitoringUrl, + memoryMetrics: [], + deploymentTime: 0, + hasMetrics: false, + loadFailed: false, + loadingMetrics: true, + backOffRequestCounter: 0, + }, + stubs: { + GlSprintf, + }, + }); + }; + + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findUsageInfo = () => wrapper.find('.js-usage-info'); + const findUsageInfoFailed = () => wrapper.find('.usage-info-failed'); + const findUsageInfoUnavailable = () => wrapper.find('.usage-info-unavailable'); + const findMemoryGraph = () => wrapper.findComponent(MemoryGraph); + beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(`${url}.json`).reply(HTTP_STATUS_OK); - - vm = createComponent(); - el = vm.$el; - }); - - afterEach(() => { - mock.restore(); }); describe('data', () => { it('should have default data', () => { + createComponent(); const data = MemoryUsage.data(); expect(Array.isArray(data.memoryMetrics)).toBe(true); @@ -103,126 +104,182 @@ describe('MemoryUsage', () => { describe('computed', () => { describe('memoryChangeMessage', () => { - it('should contain "increased" if memoryFrom value is less than memoryTo value', () => { - vm.memoryFrom = 4.28; - vm.memoryTo = 9.13; + it('should contain "increased" if memoryFrom value is less than memoryTo value', async () => { + jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ + data: { + ...metricsMockData, + metrics: { + ...metricsMockData.metrics, + memory_after: [ + { + metric: {}, + value: [1495787020.607, '54858853.130206379'], + }, + ], + }, + }, + }); - expect(vm.memoryChangeMessage.indexOf('increased')).not.toEqual('-1'); + createComponent(); + await waitForPromises(); + + expect(findUsageInfo().text().indexOf('increased')).not.toEqual(-1); }); - it('should contain "decreased" if memoryFrom value is less than memoryTo value', () => { - vm.memoryFrom = 9.13; - vm.memoryTo = 4.28; + it('should contain "decreased" if memoryFrom value is less than memoryTo value', async () => { + jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ + data: metricsMockData, + }); + + createComponent(); + await waitForPromises(); - expect(vm.memoryChangeMessage.indexOf('decreased')).not.toEqual('-1'); + expect(findUsageInfo().text().indexOf('decreased')).not.toEqual(-1); }); - it('should contain "unchanged" if memoryFrom value equal to memoryTo value', () => { - vm.memoryFrom = 1; - vm.memoryTo = 1; + it('should contain "unchanged" if memoryFrom value equal to memoryTo value', async () => { + jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ + data: { + ...metricsMockData, + metrics: { + ...metricsMockData.metrics, + memory_after: [ + { + metric: {}, + value: [1495785220.607, '9572875.906976745'], + }, + ], + }, + }, + }); + + createComponent(); + await waitForPromises(); - expect(vm.memoryChangeMessage.indexOf('unchanged')).not.toEqual('-1'); + expect(findUsageInfo().text().indexOf('unchanged')).not.toEqual(-1); }); }); }); describe('methods', () => { - const { metrics, deployment_time } = metricsMockData; + beforeEach(async () => { + jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ + data: metricsMockData, + }); + + createComponent(); + await waitForPromises(); + }); describe('getMegabytes', () => { it('should return Megabytes from provided Bytes value', () => { - const memoryInBytes = '9572875.906976745'; - - expect(vm.getMegabytes(memoryInBytes)).toEqual('9.13'); + expect(findUsageInfo().text()).toContain('9.13MB'); }); }); describe('computeGraphData', () => { it('should populate sparkline graph', () => { - // ignore BoostrapVue warnings - jest.spyOn(console, 'warn').mockImplementation(); - - vm.computeGraphData(metrics, deployment_time); - const { hasMetrics, memoryMetrics, deploymentTime, memoryFrom, memoryTo } = vm; - - expect(hasMetrics).toBe(true); - expect(memoryMetrics.length).toBeGreaterThan(0); - expect(deploymentTime).toEqual(deployment_time); - expect(memoryFrom).toEqual('9.13'); - expect(memoryTo).toEqual('4.28'); + expect(findMemoryGraph().exists()).toBe(true); + expect(findMemoryGraph().props('metrics')).toHaveLength(1); + expect(findUsageInfo().text()).toContain('9.13MB'); + expect(findUsageInfo().text()).toContain('4.28MB'); }); }); describe('loadMetrics', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + it('should load metrics data using MRWidgetService', async () => { jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ data: metricsMockData, }); - jest.spyOn(vm, 'computeGraphData').mockImplementation(() => {}); - - vm.loadMetrics(); await waitForPromises(); expect(MRWidgetService.fetchMetrics).toHaveBeenCalledWith(url); - expect(vm.computeGraphData).toHaveBeenCalledWith(metrics, deployment_time); }); }); }); describe('template', () => { - it('should render template elements correctly', () => { - expect(el.classList.contains('mr-memory-usage')).toBe(true); - expect(el.querySelector('.js-usage-info')).toBeDefined(); - }); + it('should render template elements correctly', async () => { + jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ + data: metricsMockData, + }); - it('should show loading metrics message while metrics are being loaded', async () => { - vm.loadingMetrics = true; - vm.hasMetrics = false; - vm.loadFailed = false; + createComponent(); + await waitForPromises(); - await nextTick(); + expect(wrapper.classes()).toContain('mr-memory-usage'); + expect(findUsageInfo().exists()).toBe(true); + }); + + it('should show loading metrics message while metrics are being loaded', () => { + createComponent(); - expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); - expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); + expect(findGlLoadingIcon().exists()).toBe(true); + expect(findUsageInfo().exists()).toBe(true); + expect(findUsageInfo().text()).toBe(messages.loadingMetrics); }); it('should show deployment memory usage when metrics are loaded', async () => { - // ignore BoostrapVue warnings - jest.spyOn(console, 'warn').mockImplementation(); - - vm.loadingMetrics = false; - vm.hasMetrics = true; - vm.loadFailed = false; - vm.memoryMetrics = metricsMockData.metrics.memory_values[0].values; + jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ + data: { + ...metricsMockData, + metrics: { + ...metricsMockData.metrics, + memory_after: [ + { + metric: {}, + value: [0, '0'], + }, + ], + memory_before: [ + { + metric: {}, + value: [0, '0'], + }, + ], + }, + }, + }); - await nextTick(); + createComponent(); + await waitForPromises(); - expect(el.querySelector('.memory-graph-container')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); + expect(findMemoryGraph().exists()).toBe(true); + expect(findUsageInfo().text()).toBe(messages.hasMetrics); }); it('should show failure message when metrics loading failed', async () => { - vm.loadingMetrics = false; - vm.hasMetrics = false; - vm.loadFailed = true; + jest.spyOn(MRWidgetService, 'fetchMetrics').mockRejectedValue({}); - await nextTick(); + createComponent(); + await waitForPromises(); - expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); + expect(findUsageInfoFailed().exists()).toBe(true); + expect(findUsageInfo().text()).toBe(messages.loadFailed); }); it('should show metrics unavailable message when metrics loading failed', async () => { - vm.loadingMetrics = false; - vm.hasMetrics = false; - vm.loadFailed = false; + jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ + data: { + ...metricsMockData, + metrics: { + ...metricsMockData.metrics, + memory_values: [], + }, + }, + }); - await nextTick(); + createComponent(); + await waitForPromises(); - expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); + expect(findUsageInfoUnavailable().exists()).toBe(true); + expect(findUsageInfo().text()).toBe(messages.metricsUnavailable); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js index f284ec98a73..9bd46267daa 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js @@ -1,56 +1,101 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { GlModal } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue'; +import rebaseQuery from '~/vue_merge_request_widget/queries/states/rebase.query.graphql'; import eventHub from '~/vue_merge_request_widget/event_hub'; +import StateContainer from '~/vue_merge_request_widget/components/state_container.vue'; import toast from '~/vue_shared/plugins/global_toast'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; jest.mock('~/vue_shared/plugins/global_toast'); let wrapper; - -function createWrapper(propsData, provideData) { - wrapper = mount(WidgetRebase, { - provide: { - ...provideData, +const showMock = jest.fn(); + +const mockPipelineNodes = [ + { + id: '1', + project: { + id: '2', + fullPath: 'user/forked', }, - propsData, - data() { - return { - state: { - rebaseInProgress: propsData.mr.rebaseInProgress, - targetBranch: propsData.mr.targetBranch, + }, +]; + +const mockQueryHandler = ({ + rebaseInProgress = false, + targetBranch = '', + pushToSourceBranch = false, + nodes = mockPipelineNodes, +} = {}) => + jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + mergeRequest: { + id: '2', + rebaseInProgress, + targetBranch, userPermissions: { - pushToSourceBranch: propsData.mr.canPushToSourceBranch, + pushToSourceBranch, + }, + pipelines: { + nodes, }, - pipelines: propsData.mr.pipelines, }, - }; + }, + }, + }); + +const createMockApolloProvider = (handler) => { + Vue.use(VueApollo); + + return createMockApollo([[rebaseQuery, handler]]); +}; + +function createWrapper({ propsData = {}, provideData = {}, handler = mockQueryHandler() } = {}) { + wrapper = shallowMountExtended(WidgetRebase, { + apolloProvider: createMockApolloProvider(handler), + provide: { + ...provideData, }, - mocks: { - $apollo: { - queries: { - state: { loading: false }, + propsData: { + mr: {}, + service: {}, + ...propsData, + }, + stubs: { + StateContainer, + GlModal: stubComponent(GlModal, { + methods: { + show: showMock, }, - }, + }), }, }); } describe('Merge request widget rebase component', () => { - const findRebaseMessage = () => wrapper.find('[data-testid="rebase-message"]'); + const findRebaseMessage = () => wrapper.findByTestId('rebase-message'); + const findBoldText = () => wrapper.findComponent(BoldText); const findRebaseMessageText = () => findRebaseMessage().text(); - const findStandardRebaseButton = () => wrapper.find('[data-testid="standard-rebase-button"]'); - const findRebaseWithoutCiButton = () => wrapper.find('[data-testid="rebase-without-ci-button"]'); + const findStandardRebaseButton = () => wrapper.findByTestId('standard-rebase-button'); + const findRebaseWithoutCiButton = () => wrapper.findByTestId('rebase-without-ci-button'); const findModal = () => wrapper.findComponent(GlModal); describe('while rebasing', () => { - it('should show progress message', () => { + it('should show progress message', async () => { createWrapper({ - mr: { rebaseInProgress: true }, - service: {}, + handler: mockQueryHandler({ rebaseInProgress: true }), }); + await waitForPromises(); + expect(findRebaseMessageText()).toContain('Rebase in progress'); }); }); @@ -59,95 +104,110 @@ describe('Merge request widget rebase component', () => { const rebaseMock = jest.fn().mockResolvedValue(); const pollMock = jest.fn().mockResolvedValue({}); - it('renders the warning message', () => { + it('renders the warning message', async () => { createWrapper({ - mr: { + handler: mockQueryHandler({ rebaseInProgress: false, - canPushToSourceBranch: true, - }, - service: { - rebase: rebaseMock, - poll: pollMock, - }, + pushToSourceBranch: false, + }), }); - const text = findRebaseMessageText(); + await waitForPromises(); - expect(text).toContain('Merge blocked'); - expect(text.replace(/\s\s+/g, ' ')).toContain( + expect(findBoldText().props('message')).toContain('Merge blocked'); + expect(findBoldText().props('message').replace(/\s\s+/g, ' ')).toContain( 'the source branch must be rebased onto the target branch', ); }); it('renders an error message when rebasing has failed', async () => { createWrapper({ - mr: { - rebaseInProgress: false, - canPushToSourceBranch: true, - }, - service: { - rebase: rebaseMock, - poll: pollMock, + propsData: { + service: { + rebase: jest.fn().mockRejectedValue({ + response: { + data: { + merge_error: 'Something went wrong!', + }, + }, + }), + }, }, + handler: mockQueryHandler({ pushToSourceBranch: true }), }); + await waitForPromises(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ rebasingError: 'Something went wrong!' }); + findStandardRebaseButton().vm.$emit('click'); - await nextTick(); + await waitForPromises(); expect(findRebaseMessageText()).toContain('Something went wrong!'); }); describe('Rebase buttons', () => { - beforeEach(() => { + it('renders both buttons', async () => { createWrapper({ - mr: { - rebaseInProgress: false, - canPushToSourceBranch: true, - }, - service: { - rebase: rebaseMock, - poll: pollMock, - }, + handler: mockQueryHandler({ pushToSourceBranch: true }), }); - }); - it('renders both buttons', () => { + await waitForPromises(); + expect(findRebaseWithoutCiButton().exists()).toBe(true); expect(findStandardRebaseButton().exists()).toBe(true); }); it('starts the rebase when clicking', async () => { - findStandardRebaseButton().vm.$emit('click'); + createWrapper({ + propsData: { + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + handler: mockQueryHandler({ pushToSourceBranch: true }), + }); - await nextTick(); + await waitForPromises(); + + findStandardRebaseButton().vm.$emit('click'); expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false }); }); it('starts the CI-skipping rebase when clicking on "Rebase without CI"', async () => { - findRebaseWithoutCiButton().vm.$emit('click'); + createWrapper({ + propsData: { + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + handler: mockQueryHandler({ pushToSourceBranch: true }), + }); - await nextTick(); + await waitForPromises(); + + findRebaseWithoutCiButton().vm.$emit('click'); expect(rebaseMock).toHaveBeenCalledWith({ skipCi: true }); }); }); describe('Rebase when pipelines must succeed is enabled', () => { - beforeEach(() => { + beforeEach(async () => { createWrapper({ - mr: { - rebaseInProgress: false, - canPushToSourceBranch: true, - onlyAllowMergeIfPipelineSucceeds: true, - }, - service: { - rebase: rebaseMock, - poll: pollMock, + propsData: { + mr: { + onlyAllowMergeIfPipelineSucceeds: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, }, + handler: mockQueryHandler({ pushToSourceBranch: true }), }); + + await waitForPromises(); }); it('renders only the rebase button', () => { @@ -165,19 +225,22 @@ describe('Merge request widget rebase component', () => { }); describe('Rebase when pipelines must succeed and skipped pipelines are considered successful are enabled', () => { - beforeEach(() => { + beforeEach(async () => { createWrapper({ - mr: { - rebaseInProgress: false, - canPushToSourceBranch: true, - onlyAllowMergeIfPipelineSucceeds: true, - allowMergeOnSkippedPipeline: true, - }, - service: { - rebase: rebaseMock, - poll: pollMock, + propsData: { + mr: { + onlyAllowMergeIfPipelineSucceeds: true, + allowMergeOnSkippedPipeline: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, }, + handler: mockQueryHandler({ pushToSourceBranch: true }), }); + + await waitForPromises(); }); it('renders both rebase buttons', () => { @@ -203,51 +266,36 @@ describe('Merge request widget rebase component', () => { }); describe('security modal', () => { - it('displays modal and rebases after confirming', () => { - createWrapper( - { + it('displays modal and rebases after confirming', async () => { + createWrapper({ + propsData: { mr: { - rebaseInProgress: false, - canPushToSourceBranch: true, sourceProjectFullPath: 'user/forked', targetProjectFullPath: 'root/original', - pipelines: { - nodes: [ - { - id: '1', - project: { - id: '2', - fullPath: 'user/forked', - }, - }, - ], - }, }, service: { rebase: rebaseMock, poll: pollMock, }, }, - { canCreatePipelineInTargetProject: true }, - ); + provideData: { canCreatePipelineInTargetProject: true }, + handler: mockQueryHandler({ pushToSourceBranch: true }), + }); - findModal().vm.show = jest.fn(); + await waitForPromises(); findStandardRebaseButton().vm.$emit('click'); - - expect(findModal().vm.show).toHaveBeenCalled(); + expect(showMock).toHaveBeenCalled(); findModal().vm.$emit('primary'); expect(rebaseMock).toHaveBeenCalled(); }); - it('does not display modal', () => { - createWrapper( - { + it('does not display modal', async () => { + createWrapper({ + propsData: { mr: { - rebaseInProgress: false, - canPushToSourceBranch: true, sourceProjectFullPath: 'user/forked', targetProjectFullPath: 'root/original', }, @@ -256,14 +304,15 @@ describe('Merge request widget rebase component', () => { poll: pollMock, }, }, - { canCreatePipelineInTargetProject: false }, - ); + provideData: { canCreatePipelineInTargetProject: false }, + handler: mockQueryHandler({ pushToSourceBranch: true }), + }); - findModal().vm.show = jest.fn(); + await waitForPromises(); findStandardRebaseButton().vm.$emit('click'); - expect(findModal().vm.show).not.toHaveBeenCalled(); + expect(showMock).not.toHaveBeenCalled(); expect(rebaseMock).toHaveBeenCalled(); }); }); @@ -273,42 +322,41 @@ describe('Merge request widget rebase component', () => { const exampleTargetBranch = 'fake-branch-to-test-with'; describe('UI text', () => { - beforeEach(() => { + beforeEach(async () => { createWrapper({ - mr: { - rebaseInProgress: false, - canPushToSourceBranch: false, + handler: mockQueryHandler({ + pushToSourceBranch: false, targetBranch: exampleTargetBranch, - }, - service: {}, + }), }); + + await waitForPromises(); }); it('renders a message explaining user does not have permissions', () => { - const text = findRebaseMessageText(); - - expect(text).toContain('Merge blocked:'); - expect(text).toContain('the source branch must be rebased'); + expect(findBoldText().props('message')).toContain('Merge blocked'); + expect(findBoldText().props('message')).toContain('the source branch must be rebased'); }); it('renders the correct target branch name', () => { - const text = findRebaseMessageText(); - - expect(text).toContain('Merge blocked:'); - expect(text).toContain('the source branch must be rebased onto the target branch.'); + expect(findBoldText().props('message')).toContain('Merge blocked:'); + expect(findBoldText().props('message')).toContain( + 'the source branch must be rebased onto the target branch.', + ); }); }); - it('does render the "Rebase without pipeline" button', () => { + it('does render the "Rebase without pipeline" button', async () => { createWrapper({ - mr: { + handler: mockQueryHandler({ rebaseInProgress: false, - canPushToSourceBranch: false, + pushToSourceBranch: false, targetBranch: exampleTargetBranch, - }, - service: {}, + }), }); + await waitForPromises(); + expect(findRebaseWithoutCiButton().exists()).toBe(true); }); }); @@ -317,24 +365,27 @@ describe('Merge request widget rebase component', () => { it('checkRebaseStatus', async () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); createWrapper({ - mr: {}, - service: { - rebase() { - return Promise.resolve(); - }, - poll() { - return Promise.resolve({ - data: { - rebase_in_progress: false, - should_be_rebased: false, - merge_error: null, - }, - }); + propsData: { + service: { + rebase() { + return Promise.resolve(); + }, + poll() { + return Promise.resolve({ + data: { + rebase_in_progress: false, + should_be_rebased: false, + merge_error: null, + }, + }); + }, }, }, }); - wrapper.vm.rebase(); + await waitForPromises(); + + findRebaseWithoutCiButton().vm.$emit('click'); // Wait for the rebase request await nextTick(); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js index ca75ca11e5b..85acd5f9a9e 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js @@ -10,6 +10,8 @@ jest.mock('~/lib/utils/simple_poll', () => describe('MRWidgetMerging', () => { let wrapper; + const pollMock = jest.fn().mockResolvedValue(); + const GlEmoji = { template: '<img />' }; beforeEach(() => { wrapper = shallowMount(MrWidgetMerging, { @@ -20,7 +22,7 @@ describe('MRWidgetMerging', () => { transitionStateMachine() {}, }, service: { - poll: jest.fn().mockResolvedValue(), + poll: pollMock, }, }, stubs: { @@ -36,17 +38,11 @@ describe('MRWidgetMerging', () => { describe('initiateMergePolling', () => { it('should call simplePoll', () => { - wrapper.vm.initiateMergePolling(); - expect(simplePoll).toHaveBeenCalledWith(expect.any(Function), { timeout: 0 }); }); it('should call handleMergePolling', () => { - jest.spyOn(wrapper.vm, 'handleMergePolling').mockImplementation(() => {}); - - wrapper.vm.initiateMergePolling(); - - expect(wrapper.vm.handleMergePolling).toHaveBeenCalled(); + expect(pollMock).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js index 1e4e089e7c1..07fc0be9e51 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -113,6 +113,11 @@ const createComponent = (customConfig = {}, createState = true) => { GlSprintf, }, apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]), + provide: { + glFeatures: { + autoMergeLabelsMrWidget: false, + }, + }, }); }; @@ -596,7 +601,7 @@ describe('ReadyToMerge', () => { describe('commits edit components', () => { describe('when fast-forward merge is enabled', () => { - it('should not be rendered if squash is disabled', async () => { + it('should not be rendered if squash is disabled', () => { createComponent({ mr: { ffOnlyEnabled: true, diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js index 58b9f162815..19825318a4f 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -4,12 +4,19 @@ import { removeBreakLine } from 'helpers/text_helper'; import notesEventHub from '~/notes/event_hub'; import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue'; -function createComponent({ path = '' } = {}) { +function createComponent({ path = '', propsData = {}, provide = {} } = {}) { return mount(UnresolvedDiscussions, { propsData: { mr: { createIssueToResolveDiscussionsPath: path, }, + ...propsData, + }, + provide: { + glFeatures: { + hideCreateIssueResolveAll: false, + }, + ...provide, }, }); } @@ -21,7 +28,7 @@ describe('UnresolvedDiscussions', () => { wrapper = createComponent(); }); - it('triggers the correct notes event when the jump to first unresolved discussion button is clicked', () => { + it('triggers the correct notes event when the go to first unresolved discussion button is clicked', () => { jest.spyOn(notesEventHub, '$emit'); wrapper.find('[data-testid="jump-to-first"]').trigger('click'); @@ -39,8 +46,8 @@ describe('UnresolvedDiscussions', () => { expect(text).toContain('Merge blocked:'); expect(text).toContain('all threads must be resolved.'); - expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); - expect(wrapper.element.innerText).toContain('Create issue to resolve all threads'); + expect(wrapper.element.innerText).toContain('Resolve all with new issue'); + expect(wrapper.element.innerText).toContain('Go to first unresolved thread'); expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual( TEST_HOST, ); @@ -53,9 +60,26 @@ describe('UnresolvedDiscussions', () => { expect(text).toContain('Merge blocked:'); expect(text).toContain('all threads must be resolved.'); - expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); - expect(wrapper.element.innerText).not.toContain('Create issue to resolve all threads'); + expect(wrapper.element.innerText).not.toContain('Resolve all with new issue'); + expect(wrapper.element.innerText).toContain('Go to first unresolved thread'); expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null); }); }); + + describe('when `hideCreateIssueResolveAll` is enabled', () => { + beforeEach(() => { + wrapper = createComponent({ + path: TEST_HOST, + provide: { + glFeatures: { + hideCreateIssueResolveAll: true, + }, + }, + }); + }); + + it('do not show jump to first button', () => { + expect(wrapper.text()).not.toContain('Create issue to resolve all threads'); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap index e9a34453930..3947c50fe4e 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap +++ b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap @@ -1,35 +1,63 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue renders given data 1`] = ` -"<content-row-stub level=\\"2\\" statusiconname=\\"success\\" widgetname=\\"MyWidget\\" header=\\"This is a header,This is a subheader\\" helppopover=\\"[object Object]\\" actionbuttons=\\"\\"> - <div class=\\"gl-display-flex gl-flex-direction-column\\"> - <div> - <p class=\\"gl-mb-0\\">Main text for the row</p> - <gl-link-stub href=\\"https://gitlab.com\\">Optional link to display after text</gl-link-stub> - <!----> - <gl-badge-stub size=\\"md\\" variant=\\"info\\" iconsize=\\"md\\"> - Badge is optional. Text to be displayed inside badge - </gl-badge-stub> - <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub> - <p class=\\"gl-m-0 gl-font-sm\\">Optional: Smaller sub-text to be displayed below the main text</p> +"<div class=\\"gl-w-full gl-display-flex gl-border-t gl-py-3 gl-pl-7 gl-align-items-baseline\\"> + <!----> + <div class=\\"gl-w-full\\"> + <div class=\\"gl-display-flex\\"> + <div class=\\"gl-mb-2\\"><strong class=\\"gl-display-block\\">This is a header</strong><span class=\\"gl-display-block\\">This is a subheader</span></div> + <div class=\\"gl-ml-auto gl-display-flex gl-align-items-baseline\\"> + <help-popover-stub options=\\"[object Object]\\" icon=\\"information-o\\" class=\\"\\"> + <p class=\\"gl-mb-0\\">Widget help popover content</p> + <!----> + </help-popover-stub> + <!----> + </div> </div> - <ul class=\\"gl-m-0 gl-p-0 gl-list-style-none\\"> - <li> - <content-row-stub level=\\"3\\" statusiconname=\\"\\" widgetname=\\"MyWidget\\" header=\\"Child row header\\" actionbuttons=\\"\\" data-qa-selector=\\"child_content\\"> - <div class=\\"gl-display-flex gl-flex-direction-column\\"> - <div> - <p class=\\"gl-mb-0\\">This is recursive. It will be listed in level 3.</p> - <!----> - <!----> - <!----> - <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub> + <div class=\\"gl-display-flex gl-align-items-baseline gl-w-full\\"> + <status-icon-stub level=\\"2\\" name=\\"MyWidget\\" iconname=\\"success\\"></status-icon-stub> + <div class=\\"gl-display-flex gl-flex-direction-column\\"> + <div> + <p class=\\"gl-mb-0\\">Main text for the row</p> + <gl-link-stub href=\\"https://gitlab.com\\">Optional link to display after text</gl-link-stub> + <!----> + <gl-badge-stub size=\\"md\\" variant=\\"info\\" iconsize=\\"md\\"> + Badge is optional. Text to be displayed inside badge + </gl-badge-stub> + <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub> + <p class=\\"gl-m-0 gl-font-sm\\">Optional: Smaller sub-text to be displayed below the main text</p> + </div> + <ul class=\\"gl-m-0 gl-p-0 gl-list-style-none\\"> + <li> + <div class=\\"gl-w-full gl-display-flex gl-align-items-center\\" data-qa-selector=\\"child_content\\"> <!----> + <div class=\\"gl-w-full\\"> + <div class=\\"gl-display-flex\\"> + <div class=\\"gl-mb-2\\"><strong class=\\"gl-display-block\\">Child row header</strong> + <!----> + </div> + <!----> + </div> + <div class=\\"gl-display-flex gl-align-items-baseline gl-w-full\\"> + <!----> + <div class=\\"gl-display-flex gl-flex-direction-column\\"> + <div> + <p class=\\"gl-mb-0\\">This is recursive. It will be listed in level 3.</p> + <!----> + <!----> + <!----> + <actions-stub widget=\\"MyWidget\\" tertiarybuttons=\\"\\" class=\\"gl-ml-auto gl-pl-3\\"></actions-stub> + <!----> + </div> + <!----> + </div> + </div> + </div> </div> - <!----> - </div> - </content-row-stub> - </li> - </ul> + </li> + </ul> + </div> + </div> </div> -</content-row-stub>" +</div>" `; diff --git a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js index 527e800ddcf..16751bcc0f0 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js @@ -1,6 +1,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; import DynamicContent from '~/vue_merge_request_widget/components/widget/dynamic_content.vue'; +import ContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue'; describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', () => { let wrapper; @@ -13,6 +14,7 @@ describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', () }, stubs: { DynamicContent, + ContentRow, }, }); }; diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js index 5887670a58d..4972c522733 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/browser'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import waitForPromises from 'helpers/wait_for_promises'; +import { assertProps } from 'helpers/assert_props'; import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; import ActionButtons from '~/vue_merge_request_widget/components/widget/action_buttons.vue'; import Widget from '~/vue_merge_request_widget/components/widget/widget.vue'; @@ -111,9 +112,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { it('validates widget name', () => { expect(() => { - createComponent({ - propsData: { widgetName: 'InvalidWidgetName' }, - }); + assertProps(Widget, { widgetName: 'InvalidWidgetName' }); }).toThrow(); }); }); @@ -121,7 +120,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { describe('fetch', () => { it('sets the data.collapsed property after a successfull call - multiPolling: false', async () => { const mockData = { headers: {}, status: HTTP_STATUS_OK, data: { vulnerabilities: [] } }; - createComponent({ propsData: { fetchCollapsedData: async () => mockData } }); + createComponent({ propsData: { fetchCollapsedData: () => Promise.resolve(mockData) } }); await waitForPromises(); expect(wrapper.emitted('input')[0][0]).toEqual({ collapsed: mockData.data, expanded: null }); }); @@ -287,6 +286,21 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { expect(findExpandedSection().text()).toBe('More complex content'); }); + it('emits a toggle even when button is toggled', () => { + createComponent({ + propsData: { + isCollapsible: true, + }, + slots: { + content: '<b>More complex content</b>', + }, + }); + + expect(findExpandedSection().exists()).toBe(false); + findToggleButton().vm.$emit('click'); + expect(wrapper.emitted('toggle')).toEqual([[{ expanded: true }]]); + }); + it('does not display the toggle button if isCollapsible is false', () => { createComponent({ propsData: { diff --git a/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js index 40158917f52..9b1e694d9c4 100644 --- a/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js @@ -101,7 +101,7 @@ describe('Accessibility extension', () => { await waitForPromises(); }); - it('displays all report list items in viewport', async () => { + it('displays all report list items in viewport', () => { expect(findAllExtensionListItems()).toHaveLength(7); }); diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js index 4b7870842bd..1478b3ac098 100644 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js @@ -184,7 +184,7 @@ describe('Code Quality extension', () => { await waitForPromises(); }); - it('displays all report list items in viewport', async () => { + it('displays all report list items in viewport', () => { expect(findAllExtensionListItems()).toHaveLength(2); }); diff --git a/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js index 52a244107bd..5baed8ff211 100644 --- a/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js @@ -82,7 +82,7 @@ describe('Terraform extension', () => { ${'2 valid reports'} | ${{ 0: validPlanWithName, 1: validPlanWithName }} | ${'2 Terraform reports were generated in your pipelines'} | ${''} ${'1 valid and 2 invalid reports'} | ${{ 0: validPlanWithName, 1: invalidPlanWithName, 2: invalidPlanWithName }} | ${'Terraform report was generated in your pipelines'} | ${'2 Terraform reports failed to generate'} `('and received $responseType', ({ response, summaryTitle, summarySubtitle }) => { - beforeEach(async () => { + beforeEach(() => { mockPollingApi(HTTP_STATUS_OK, response, {}); return createComponent(); }); diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index fad501ee7f5..132d64226e1 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -470,15 +470,15 @@ describe('MrWidgetOptions', () => { }); it('should call setFavicon method', async () => { - wrapper.vm.mr.ciStatusFaviconPath = overlayDataUrl; + wrapper.vm.mr.faviconOverlayPath = overlayDataUrl; await wrapper.vm.setFaviconHelper(); expect(setFaviconOverlay).toHaveBeenCalledWith(overlayDataUrl); }); - it('should not call setFavicon when there is no ciStatusFaviconPath', async () => { - wrapper.vm.mr.ciStatusFaviconPath = null; + it('should not call setFavicon when there is no faviconOverlayPath', async () => { + wrapper.vm.mr.faviconOverlayPath = null; await wrapper.vm.setFaviconHelper(); expect(faviconElement.getAttribute('href')).toEqual(null); }); @@ -838,7 +838,7 @@ describe('MrWidgetOptions', () => { }); describe('security widget', () => { - const setup = async (hasPipeline) => { + const setup = (hasPipeline) => { const mrData = { ...mockData, ...(hasPipeline ? {} : { pipeline: null }), @@ -853,7 +853,9 @@ describe('MrWidgetOptions', () => { apolloMock: [ [ securityReportMergeRequestDownloadPathsQuery, - async () => ({ data: securityReportMergeRequestDownloadPathsQueryResponse }), + jest + .fn() + .mockResolvedValue({ data: securityReportMergeRequestDownloadPathsQueryResponse }), ], ], }); @@ -1197,33 +1199,6 @@ describe('MrWidgetOptions', () => { 'i_code_review_merge_request_widget_test_extension_count_expand_warning', ); }); - - it.each` - widgetName | nonStandardEvent - ${'WidgetCodeQuality'} | ${'i_testing_code_quality_widget_total'} - ${'WidgetTerraform'} | ${'i_testing_terraform_widget_total'} - ${'WidgetIssues'} | ${'i_testing_issues_widget_total'} - ${'WidgetTestSummary'} | ${'i_testing_summary_widget_total'} - `( - "sends non-standard events for the '$widgetName' widget", - async ({ widgetName, nonStandardEvent }) => { - const definition = { - ...workingExtension(), - name: widgetName, - }; - - registerExtension(definition); - createComponent(); - - await waitForPromises(); - - api.trackRedisHllUserEvent.mockClear(); - - findExtensionToggleButton().trigger('click'); - - expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(nonStandardEvent); - }, - ); }); it('triggers the "full report clicked" events when the appropriate button is clicked', () => { diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index 3bc191d988f..6c2b21053f0 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -86,12 +86,10 @@ describe('AlertDetails', () => { }); afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } mock.restore(); }); + const findTabs = () => wrapper.findByTestId('alertDetailsTabs'); const findCreateIncidentBtn = () => wrapper.findByTestId('createIncidentBtn'); const findViewIncidentBtn = () => wrapper.findByTestId('viewIncidentBtn'); const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError'); @@ -107,7 +105,7 @@ describe('AlertDetails', () => { }); it('shows an empty state', () => { - expect(wrapper.findByTestId('alertDetailsTabs').exists()).toBe(false); + expect(findTabs().exists()).toBe(false); }); }); @@ -349,9 +347,7 @@ describe('AlertDetails', () => { ${1} | ${'metrics'} ${2} | ${'activity'} `('will navigate to the correct tab via $tabId', ({ index, tabId }) => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ currentTabIndex: index }); + findTabs().vm.$emit('input', index); expect($router.push).toHaveBeenCalledWith({ name: 'tab', params: { tabId } }); }); }); diff --git a/spec/frontend/vue_shared/alert_details/alert_metrics_spec.js b/spec/frontend/vue_shared/alert_details/alert_metrics_spec.js deleted file mode 100644 index 9d84a535d67..00000000000 --- a/spec/frontend/vue_shared/alert_details/alert_metrics_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; -import AlertMetrics from '~/vue_shared/alert_details/components/alert_metrics.vue'; - -jest.mock('~/monitoring/stores', () => ({ - monitoringDashboard: {}, -})); - -jest.mock('~/monitoring/components/embeds/metric_embed.vue', () => ({ - render(h) { - return h('div'); - }, -})); - -describe('Alert Metrics', () => { - let wrapper; - const mock = new MockAdapter(axios); - - function mountComponent({ props } = {}) { - wrapper = shallowMount(AlertMetrics, { - propsData: { - ...props, - }, - }); - } - - const findChart = () => wrapper.findComponent(MetricEmbed); - const findEmptyState = () => wrapper.findComponent({ ref: 'emptyState' }); - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - }); - - afterAll(() => { - mock.restore(); - }); - - describe('Empty state', () => { - it('should display a message when metrics dashboard url is not provided', () => { - mountComponent(); - expect(findChart().exists()).toBe(false); - expect(findEmptyState().text()).toBe("Metrics weren't available in the alerts payload."); - }); - }); - - describe('Chart', () => { - it('should be rendered when dashboard url is provided', async () => { - mountComponent({ props: { dashboardUrl: 'metrics.url' } }); - - await waitForPromises(); - await nextTick(); - - expect(findEmptyState().exists()).toBe(false); - expect(findChart().exists()).toBe(true); - }); - }); -}); 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 98a357bac2b..bf4435fae45 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 @@ -1,21 +1,28 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { GlDropdownItem } from '@gitlab/ui'; 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'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import mockAlerts from '../mocks/alerts.json'; const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar Assignees', () => { let wrapper; + let requestHandlers; let mock; const mockPath = '/-/autocomplete/users.json'; + const mockUrlRoot = '/gitlab'; + const expectedUrl = `${mockUrlRoot}${mockPath}`; + const mockUsers = [ { avatar_url: @@ -40,81 +47,64 @@ describe('Alert Details Sidebar Assignees', () => { const findSidebarIcon = () => wrapper.findByTestId('assignees-icon'); const findUnassigned = () => wrapper.findByTestId('unassigned-users'); + const mockDefaultHandler = (errors = []) => + jest.fn().mockResolvedValue({ + data: { + issuableSetAssignees: { + errors, + issuable: { + id: 'id', + iid: 'iid', + assignees: { + nodes: [], + }, + notes: { + nodes: [], + }, + }, + }, + }, + }); + const createMockApolloProvider = (handlers) => { + Vue.use(VueApollo); + requestHandlers = handlers; + + return createMockApollo([[AlertSetAssignees, handlers]]); + }; + function mountComponent({ - data, - users = [], - isDropdownSearching = false, + props, sidebarCollapsed = true, - loading = false, - stubs = {}, + handlers = mockDefaultHandler(), } = {}) { wrapper = shallowMountExtended(SidebarAssignees, { - data() { - return { - users, - isDropdownSearching, - }; - }, + apolloProvider: createMockApolloProvider(handlers), propsData: { alert: { ...mockAlert }, - ...data, + ...props, sidebarCollapsed, projectPath: 'projectPath', projectId: '1', }, - mocks: { - $apollo: { - mutate: jest.fn(), - queries: { - alert: { - loading, - }, - }, - }, - }, - stubs, }); } - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - mock.restore(); - }); - describe('sidebar expanded', () => { - const mockUpdatedMutationResult = { - data: { - alertSetAssignees: { - errors: [], - alert: { - assigneeUsernames: ['root'], - }, - }, - }, - }; - beforeEach(() => { mock = new MockAdapter(axios); + window.gon = { + relative_url_root: mockUrlRoot, + }; - mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, mockUsers); mountComponent({ - data: { alert: mockAlert }, + props: { alert: mockAlert }, sidebarCollapsed: false, - loading: false, - users: mockUsers, - stubs: { - SidebarAssignee, - }, }); }); it('renders a unassigned option', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isDropdownSearching: false }); - await nextTick(); + await waitForPromises(); expect(findDropdown().text()).toBe('Unassigned'); }); @@ -122,60 +112,38 @@ describe('Alert Details Sidebar Assignees', () => { expect(findSidebarIcon().exists()).toBe(false); }); - it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isDropdownSearching: false }); - - await nextTick(); + it('calls `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { + await waitForPromises(); wrapper.findComponent(SidebarAssignee).vm.$emit('update-alert-assignees', 'root'); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: AlertSetAssignees, - variables: { - iid: '1527542', - assigneeUsernames: ['root'], - fullPath: 'projectPath', - }, + expect(requestHandlers).toHaveBeenCalledWith({ + iid: '1527542', + assigneeUsernames: ['root'], + fullPath: 'projectPath', }); }); it('emits an error when request contains error messages', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isDropdownSearching: false }); - const errorMutationResult = { - data: { - issuableSetAssignees: { - errors: ['There was a problem for sure.'], - alert: {}, - }, - }, - }; - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(errorMutationResult); + mountComponent({ + sidebarCollapsed: false, + handlers: mockDefaultHandler(['There was a problem for sure.']), + }); + await waitForPromises(); - await nextTick(); const SideBarAssigneeItem = wrapper.findAllComponents(SidebarAssignee).at(0); await SideBarAssigneeItem.vm.$emit('update-alert-assignees'); - expect(wrapper.emitted('alert-error')).toBeDefined(); + + await waitForPromises(); + expect(wrapper.emitted('alert-error')).toHaveLength(1); }); it('stops updating and cancels loading when the request fails', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - wrapper.vm.updateAlertAssignees('root'); expect(findUnassigned().text()).toBe('assign yourself'); }); it('shows a user avatar, username and full name when a user is set', () => { mountComponent({ - data: { alert: mockAlerts[1] }, - sidebarCollapsed: false, - loading: false, - stubs: { - SidebarAssignee, - }, + props: { alert: mockAlerts[1] }, }); expect(findAssigned().find('img').attributes('src')).toBe('/url'); @@ -188,15 +156,10 @@ describe('Alert Details Sidebar Assignees', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, mockUsers); mountComponent({ - data: { alert: mockAlert }, - loading: false, - users: mockUsers, - stubs: { - SidebarAssignee, - }, + props: { alert: mockAlert }, }); }); it('does not display the status dropdown', () => { diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js index a839af3b709..174e27af948 100644 --- a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js +++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js @@ -96,7 +96,7 @@ describe('ColorPicker', () => { expect(colorTextInput().attributes('class')).not.toContain('is-invalid'); }); - it('shows invalid feedback when the state is marked as invalid', async () => { + it('shows invalid feedback when the state is marked as invalid', () => { createComponent(mount, { invalidFeedback: invalidText, state: false }); expect(invalidFeedback().text()).toBe(invalidText); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js index 61a9ab3225e..f262b03414c 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js @@ -69,7 +69,7 @@ describe('LabelsSelectRoot', () => { ${'embedded'} | ${[...defaultClasses, 'is-embedded']} `( 'renders component root element with CSS class `$cssClass` when variant is "$variant"', - async ({ variant, cssClass }) => { + ({ variant, cssClass }) => { createComponent({ propsData: { variant }, }); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js index 914dfdbaab0..bdb9e8763e2 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js @@ -25,7 +25,7 @@ describe('DropdownContentsColorView', () => { const findColors = () => wrapper.findAllComponents(ColorItem); const findColorList = () => wrapper.findComponent(GlDropdownForm); - it('renders color list', async () => { + it('renders color list', () => { expect(findColorList().exists()).toBe(true); expect(findColors()).toHaveLength(ISSUABLE_COLORS.length); }); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js index c07faab20d0..2e3a8550e97 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js @@ -2,6 +2,7 @@ import { nextTick } from 'vue'; import { GlDropdown } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants'; +import { stubComponent } from 'helpers/stub_component'; import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue'; import DropdownHeader from '~/vue_shared/components/color_select_dropdown/dropdown_header.vue'; @@ -19,12 +20,13 @@ const defaultProps = { describe('DropdownContent', () => { let wrapper; - const createComponent = ({ propsData = {} } = {}) => { + const createComponent = ({ propsData = {}, stubs = {} } = {}) => { wrapper = mountExtended(DropdownContents, { propsData: { ...defaultProps, ...propsData, }, + stubs, }); }; @@ -33,13 +35,22 @@ describe('DropdownContent', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); it('calls dropdown `show` method on `isVisible` prop change', async () => { - createComponent(); - const spy = jest.spyOn(wrapper.vm.$refs.dropdown, 'show'); + const showDropdown = jest.fn(); + const hideDropdown = jest.fn(); + const dropdownStub = { + GlDropdown: stubComponent(GlDropdown, { + methods: { + show: showDropdown, + hide: hideDropdown, + }, + }), + }; + createComponent({ stubs: dropdownStub }); await wrapper.setProps({ isVisible: true, }); - expect(spy).toHaveBeenCalledTimes(1); + expect(showDropdown).toHaveBeenCalledTimes(1); }); it('does not emit `setColor` event on dropdown hide if color did not change', () => { diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js index 825f37c97e0..01d3fde279b 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js @@ -31,12 +31,9 @@ describe('DropdownValue', () => { index | cssClass ${0} | ${[]} ${1} | ${['hide-collapsed']} - `( - 'passes correct props to the ColorItem with CSS class `$cssClass`', - async ({ index, cssClass }) => { - expect(findColorItems().at(index).props()).toMatchObject(propsData.selectedColor); - expect(findColorItems().at(index).classes()).toEqual(cssClass); - }, - ); + `('passes correct props to the ColorItem with CSS class `$cssClass`', ({ index, cssClass }) => { + expect(findColorItems().at(index).props()).toMatchObject(propsData.selectedColor); + expect(findColorItems().at(index).classes()).toEqual(cssClass); + }); }); }); diff --git a/spec/frontend/vue_shared/components/diff_viewer/utils_spec.js b/spec/frontend/vue_shared/components/diff_viewer/utils_spec.js new file mode 100644 index 00000000000..b95e1ee283e --- /dev/null +++ b/spec/frontend/vue_shared/components/diff_viewer/utils_spec.js @@ -0,0 +1,33 @@ +import { transition } from '~/vue_shared/components/diff_viewer/utils'; +import { + TRANSITION_LOAD_START, + TRANSITION_LOAD_ERROR, + TRANSITION_LOAD_SUCCEED, + TRANSITION_ACKNOWLEDGE_ERROR, + STATE_IDLING, + STATE_LOADING, + STATE_ERRORED, +} from '~/diffs/constants'; + +describe('transition', () => { + it.each` + state | transitionEvent | result + ${'idle'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} + ${'idle'} | ${TRANSITION_LOAD_ERROR} | ${STATE_IDLING} + ${'idle'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING} + ${'idle'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING} + ${'loading'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} + ${'loading'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} + ${'loading'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING} + ${'loading'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_LOADING} + ${'errored'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} + ${'errored'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} + ${'errored'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_ERRORED} + ${'errored'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING} + `( + 'correctly updates the state to "$result" when it starts as "$state" and the transition is "$transitionEvent"', + ({ state, transitionEvent, result }) => { + expect(transition(state, transitionEvent)).toBe(result); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js index 549388c1a5c..0d536b23c45 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js @@ -1,173 +1,119 @@ import { shallowMount, mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Vuex from 'vuex'; +import { GlAlert, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as transitionModule from '~/vue_shared/components/diff_viewer/utils'; import { + TRANSITION_ACKNOWLEDGE_ERROR, TRANSITION_LOAD_START, TRANSITION_LOAD_ERROR, TRANSITION_LOAD_SUCCEED, - TRANSITION_ACKNOWLEDGE_ERROR, STATE_IDLING, STATE_LOADING, - STATE_ERRORED, } from '~/diffs/constants'; import Renamed from '~/vue_shared/components/diff_viewer/viewers/renamed.vue'; Vue.use(Vuex); -function createRenamedComponent({ props = {}, store = new Vuex.Store({}), deep = false }) { +let wrapper; +let store; +let event; + +const DIFF_FILE_COMMIT_SHA = 'commitsha'; +const DIFF_FILE_SHORT_SHA = 'commitsh'; +const DIFF_FILE_VIEW_PATH = `blob/${DIFF_FILE_COMMIT_SHA}/filename.ext`; + +const defaultStore = { + modules: { + diffs: { + namespaced: true, + actions: { switchToFullDiffFromRenamedFile: jest.fn().mockResolvedValue() }, + }, + }, +}; +const diffFile = { + content_sha: DIFF_FILE_COMMIT_SHA, + view_path: DIFF_FILE_VIEW_PATH, + alternate_viewer: { + name: 'text', + }, +}; +const defaultProps = { diffFile }; + +function createRenamedComponent({ props = {}, storeArg = defaultStore, deep = false } = {}) { + store = new Vuex.Store(storeArg); const mnt = deep ? mount : shallowMount; - return mnt(Renamed, { - propsData: { ...props }, + wrapper = mnt(Renamed, { + propsData: { ...defaultProps, ...props }, store, }); } -describe('Renamed Diff Viewer', () => { - const DIFF_FILE_COMMIT_SHA = 'commitsha'; - const DIFF_FILE_SHORT_SHA = 'commitsh'; - const DIFF_FILE_VIEW_PATH = `blob/${DIFF_FILE_COMMIT_SHA}/filename.ext`; - let diffFile; - let wrapper; +const findErrorAlert = () => wrapper.findComponent(GlAlert); +const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); +const findShowFullDiffBtn = () => wrapper.findComponent(GlLink); +const findPlainText = () => wrapper.find('[test-id="plaintext"]'); +describe('Renamed Diff Viewer', () => { beforeEach(() => { - diffFile = { - content_sha: DIFF_FILE_COMMIT_SHA, - view_path: DIFF_FILE_VIEW_PATH, - alternate_viewer: { - name: 'text', - }, + event = { + preventDefault: jest.fn(), }; }); - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } - }); - - describe('is', () => { + describe('when clicking to load full diff', () => { beforeEach(() => { - wrapper = createRenamedComponent({ props: { diffFile } }); + createRenamedComponent(); }); - it.each` - state | request | result - ${'idle'} | ${'idle'} | ${true} - ${'idle'} | ${'loading'} | ${false} - ${'idle'} | ${'errored'} | ${false} - ${'loading'} | ${'loading'} | ${true} - ${'loading'} | ${'idle'} | ${false} - ${'loading'} | ${'errored'} | ${false} - ${'errored'} | ${'errored'} | ${true} - ${'errored'} | ${'idle'} | ${false} - ${'errored'} | ${'loading'} | ${false} - `( - 'returns the $result for "$request" when the state is "$state"', - ({ request, result, state }) => { - wrapper.vm.state = state; + it('shows a loading state', async () => { + expect(findLoadingIcon().exists()).toBe(false); - expect(wrapper.vm.is(request)).toEqual(result); - }, - ); - }); + await findShowFullDiffBtn().vm.$emit('click', event); - describe('transition', () => { - beforeEach(() => { - wrapper = createRenamedComponent({ props: { diffFile } }); + expect(findLoadingIcon().exists()).toBe(true); }); - it.each` - state | transition | result - ${'idle'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} - ${'idle'} | ${TRANSITION_LOAD_ERROR} | ${STATE_IDLING} - ${'idle'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING} - ${'idle'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING} - ${'loading'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} - ${'loading'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} - ${'loading'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_IDLING} - ${'loading'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_LOADING} - ${'errored'} | ${TRANSITION_LOAD_START} | ${STATE_LOADING} - ${'errored'} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} - ${'errored'} | ${TRANSITION_LOAD_SUCCEED} | ${STATE_ERRORED} - ${'errored'} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLING} - `( - 'correctly updates the state to "$result" when it starts as "$state" and the transition is "$transition"', - ({ state, transition, result }) => { - wrapper.vm.state = state; - - wrapper.vm.transition(transition); - - expect(wrapper.vm.state).toEqual(result); - }, - ); - }); - - describe('switchToFull', () => { - let store; - - beforeEach(() => { - store = new Vuex.Store({ - modules: { - diffs: { - namespaced: true, - actions: { switchToFullDiffFromRenamedFile: () => {} }, - }, - }, - }); - + it('calls the switchToFullDiffFromRenamedFile action when the method is triggered', () => { jest.spyOn(store, 'dispatch'); - wrapper = createRenamedComponent({ props: { diffFile }, store }); - }); - - afterEach(() => { - store = null; - }); - - it('calls the switchToFullDiffFromRenamedFile action when the method is triggered', async () => { - store.dispatch.mockResolvedValue(); - - wrapper.vm.switchToFull(); + findShowFullDiffBtn().vm.$emit('click', event); - await nextTick(); expect(store.dispatch).toHaveBeenCalledWith('diffs/switchToFullDiffFromRenamedFile', { diffFile, }); }); it.each` - after | resolvePromise | resolution - ${STATE_IDLING} | ${'mockResolvedValue'} | ${'successful'} - ${STATE_ERRORED} | ${'mockRejectedValue'} | ${'rejected'} + after | resolvePromise | resolution + ${TRANSITION_LOAD_SUCCEED} | ${'mockResolvedValue'} | ${'successful'} + ${TRANSITION_LOAD_ERROR} | ${'mockRejectedValue'} | ${'rejected'} `( 'moves through the correct states during a $resolution request', async ({ after, resolvePromise }) => { - store.dispatch[resolvePromise](); + jest.spyOn(transitionModule, 'transition'); + store.dispatch = jest.fn()[resolvePromise](); - expect(wrapper.vm.state).toEqual(STATE_IDLING); + expect(transitionModule.transition).not.toHaveBeenCalled(); - wrapper.vm.switchToFull(); + findShowFullDiffBtn().vm.$emit('click', event); - expect(wrapper.vm.state).toEqual(STATE_LOADING); + expect(transitionModule.transition).toHaveBeenCalledWith( + STATE_IDLING, + TRANSITION_LOAD_START, + ); + + await waitForPromises(); - await nextTick(); // This tick is needed for when the action (promise) finishes - await nextTick(); // This tick waits for the state change in the promise .then/.catch to bubble into the component - expect(wrapper.vm.state).toEqual(after); + expect(transitionModule.transition).toHaveBeenCalledTimes(2); + expect(transitionModule.transition.mock.calls[1]).toEqual([STATE_LOADING, after]); }, ); }); describe('clickLink', () => { - let event; - - beforeEach(() => { - event = { - preventDefault: jest.fn(), - }; - }); - it.each` alternateViewer | stops | handled ${'text'} | ${true} | ${'should'} @@ -175,42 +121,51 @@ describe('Renamed Diff Viewer', () => { `( 'given { alternate_viewer: { name: "$alternateViewer" } }, the click event $handled be handled in the component', ({ alternateViewer, stops }) => { - wrapper = createRenamedComponent({ - props: { - diffFile: { - ...diffFile, - alternate_viewer: { name: alternateViewer }, - }, + const props = { + diffFile: { + ...diffFile, + alternate_viewer: { name: alternateViewer }, }, + }; + + createRenamedComponent({ + props, }); - jest.spyOn(wrapper.vm, 'switchToFull').mockImplementation(() => {}); + store.dispatch = jest.fn().mockResolvedValue(); - wrapper.vm.clickLink(event); + findShowFullDiffBtn().vm.$emit('click', event); if (stops) { expect(event.preventDefault).toHaveBeenCalled(); - expect(wrapper.vm.switchToFull).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/switchToFullDiffFromRenamedFile', + props, + ); } else { expect(event.preventDefault).not.toHaveBeenCalled(); - expect(wrapper.vm.switchToFull).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); } }, ); }); describe('dismissError', () => { - let transitionSpy; - beforeEach(() => { - wrapper = createRenamedComponent({ props: { diffFile } }); - transitionSpy = jest.spyOn(wrapper.vm, 'transition'); + createRenamedComponent({ props: { diffFile } }); }); it(`transitions the component with "${TRANSITION_ACKNOWLEDGE_ERROR}"`, () => { - wrapper.vm.dismissError(); + jest.spyOn(transitionModule, 'transition'); + + expect(transitionModule.transition).not.toHaveBeenCalled(); + + findErrorAlert().vm.$emit('dismiss'); - expect(transitionSpy).toHaveBeenCalledWith(TRANSITION_ACKNOWLEDGE_ERROR); + expect(transitionModule.transition).toHaveBeenCalledWith( + expect.stringContaining(''), + TRANSITION_ACKNOWLEDGE_ERROR, + ); }); }); @@ -224,14 +179,19 @@ describe('Renamed Diff Viewer', () => { `( 'with { alternate_viewer: { name: $nameDisplay } }, renders the component', ({ altViewer }) => { - const file = { ...diffFile }; - - file.alternate_viewer.name = altViewer; - wrapper = createRenamedComponent({ props: { diffFile: file } }); + createRenamedComponent({ + props: { + diffFile: { + ...diffFile, + alternate_viewer: { + ...diffFile.alternate_viewer, + name: altViewer, + }, + }, + }, + }); - expect(wrapper.find('[test-id="plaintext"]').text()).toEqual( - 'File renamed with no changes.', - ); + expect(findPlainText().text()).toBe('File renamed with no changes.'); }, ); @@ -245,15 +205,15 @@ describe('Renamed Diff Viewer', () => { const file = { ...diffFile }; file.alternate_viewer.name = altType; - wrapper = createRenamedComponent({ + createRenamedComponent({ deep: true, props: { diffFile: file }, }); - const link = wrapper.find('a'); + const link = findShowFullDiffBtn(); - expect(link.text()).toEqual(linkText); - expect(link.attributes('href')).toEqual(DIFF_FILE_VIEW_PATH); + expect(link.text()).toBe(linkText); + expect(link.attributes('href')).toBe(DIFF_FILE_VIEW_PATH); }, ); }); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js index 4dfee20764c..dd5a05a40c6 100644 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js @@ -55,7 +55,7 @@ describe('DropdownWidget component', () => { expect(wrapper.emitted('set-search')).toEqual([[searchTerm]]); }); - it('renders one selectable item per passed option', async () => { + it('renders one selectable item per passed option', () => { expect(findDropdownItems()).toHaveLength(2); }); 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 ef42c17984a..4708a5555f8 100644 --- a/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js +++ b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js @@ -44,7 +44,7 @@ describe('DropdownKeyboardNavigation', () => { createComponent(); }); - it('should $emit @change with the default index', async () => { + it('should $emit @change with the default index', () => { expect(wrapper.emitted('change')[0]).toStrictEqual([MOCK_DEFAULT_INDEX]); }); @@ -100,6 +100,25 @@ describe('DropdownKeyboardNavigation', () => { describe.each` keyboardAction | direction | index | max | min + ${helpers.arrowDown} | ${1} | ${10} | ${10} | ${0} + ${helpers.arrowUp} | ${-1} | ${0} | ${10} | ${0} + `( + 'moving out of bounds with cycle enabled', + ({ keyboardAction, direction, index, max, min }) => { + beforeEach(() => { + createComponent({ index, max, min, enableCycle: true }); + keyboardAction(); + }); + + it(`in ${direction} direction does $emit correct @change event`, () => { + // The first @change`call happens on created() so we test that we only have 1 call + expect(wrapper.emitted('change')[1]).toStrictEqual([direction === 1 ? min : max]); + }); + }, + ); + + describe.each` + keyboardAction | direction | index | max | min ${helpers.arrowDown} | ${1} | ${0} | ${10} | ${0} ${helpers.arrowUp} | ${-1} | ${10} | ${10} | ${0} `('moving in bounds', ({ keyboardAction, direction, index, max, min }) => { 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 index 6b98f6c5e89..fa2e09b6b9f 100644 --- a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js @@ -201,7 +201,7 @@ describe('EntitySelect', () => { describe('pagination', () => { const searchString = 'searchString'; - beforeEach(async () => { + beforeEach(() => { let requestCount = 0; fetchItemsMock.mockImplementation((searchQuery, page) => { requestCount += 1; 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 index 32ce2155494..0a174c98efb 100644 --- a/spec/frontend/vue_shared/components/entity_select/project_select_spec.js +++ b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js @@ -97,6 +97,7 @@ describe('ProjectSelect', () => { ${'defaultToggleText'} | ${PROJECT_TOGGLE_TEXT} ${'headerText'} | ${PROJECT_HEADER_TEXT} ${'clearable'} | ${true} + ${'block'} | ${false} `('passes the $prop prop to entity-select', ({ prop, expectedValue }) => { expect(findEntitySelect().props(prop)).toBe(expectedValue); }); @@ -136,6 +137,18 @@ describe('ProjectSelect', () => { expect(mock.history.get[0].params.include_subgroups).toBe(true); }); + it('does not include shared projects if withShared prop is false', async () => { + createComponent({ + props: { + withShared: false, + }, + }); + openListbox(); + await waitForPromises(); + + expect(mock.history.get[0].params.with_shared).toBe(false); + }); + it('fetches projects globally if no group ID is provided', async () => { createComponent({ props: { diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js index 5cf891a2e52..d7569ed7b76 100644 --- a/spec/frontend/vue_shared/components/file_finder/index_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js @@ -79,7 +79,7 @@ describe('File finder item spec', () => { expect(vm.$el.querySelector('.dropdown-input-search').classList).toContain('hidden'); }); - it('clear button resets searchText', async () => { + it('clear button resets searchText', () => { vm.searchText = 'index'; vm.clearSearchInput(); diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js index dce6c85b5b3..c73f14e9c6e 100644 --- a/spec/frontend/vue_shared/components/file_finder/item_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js @@ -36,7 +36,7 @@ describe('File finder item spec', () => { expect(wrapper.classes()).toContain('is-focused'); }); - it('does not have is-focused class when not focused', async () => { + it('does not have is-focused class when not focused', () => { createComponent({ focused: false }); expect(wrapper.classes()).not.toContain('is-focused'); @@ -50,13 +50,13 @@ describe('File finder item spec', () => { expect(wrapper.find('.diff-changed-stats').exists()).toBe(false); }); - it('renders when a changed file', async () => { + it('renders when a changed file', () => { createComponent({ file: { changed: true } }); expect(wrapper.find('.diff-changed-stats').exists()).toBe(true); }); - it('renders when a temp file', async () => { + it('renders when a temp file', () => { createComponent({ file: { tempFile: true } }); expect(wrapper.find('.diff-changed-stats').exists()).toBe(true); @@ -80,7 +80,7 @@ describe('File finder item spec', () => { expect(findChangedFilePath().findAll('.highlighted')).toHaveLength(4); }); - it('adds ellipsis to long text', async () => { + it('adds ellipsis to long text', () => { const path = new Array(70) .fill() .map((_, i) => `${i}-`) @@ -101,7 +101,7 @@ describe('File finder item spec', () => { expect(findChangedFileName().findAll('.highlighted')).toHaveLength(4); }); - it('does not add ellipsis to long text', async () => { + it('does not add ellipsis to long text', () => { const name = new Array(70) .fill() .map((_, i) => `${i}-`) diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 123714353e2..f576121fc18 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -398,7 +398,7 @@ describe('FilteredSearchBarRoot', () => { expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems); }); - it('renders checkbox when `showCheckbox` prop is true', async () => { + it('renders checkbox when `showCheckbox` prop is true', () => { let wrapperWithCheckbox = createComponent({ showCheckbox: true, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index 9941abbfaea..f115ec2d6ca 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -18,6 +18,7 @@ import { OPTIONS_NONE_ANY, OPERATOR_IS, OPERATOR_NOT, + OPERATOR_OR, } from '~/vue_shared/components/filtered_search_bar/constants'; import { getRecentlyUsedSuggestions, @@ -300,6 +301,7 @@ describe('BaseToken', () => { operator | shouldRenderFilteredSearchSuggestion ${OPERATOR_IS} | ${true} ${OPERATOR_NOT} | ${false} + ${OPERATOR_OR} | ${false} `('when operator is $operator', ({ shouldRenderFilteredSearchSuggestion, operator }) => { beforeEach(() => { const props = { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js index 8526631c63d..f41c5b5d432 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -157,7 +157,7 @@ describe('CrmOrganizationToken', () => { }); }); - it('calls `createAlert` with alert error message when request fails', async () => { + it('calls `createAlert` when request fails', async () => { mountComponent(); jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index 4e00b6837a3..0dddae50c4e 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -91,7 +91,7 @@ describe('EmojiToken', () => { describe('when request is successful', () => { const searchTerm = 'foo'; - beforeEach(async () => { + beforeEach(() => { wrapper = createComponent({ config: { fetchEmojis: jest.fn().mockResolvedValue({ data: mockEmojis }), @@ -119,7 +119,7 @@ describe('EmojiToken', () => { return triggerFetchEmojis(); }); - it('calls `createAlert` with alert error message', () => { + it('calls `createAlert`', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching emojis.', }); @@ -135,7 +135,7 @@ describe('EmojiToken', () => { describe('template', () => { const defaultEmojis = OPTIONS_NONE_ANY; - beforeEach(async () => { + beforeEach(() => { wrapper = createComponent({ value: { data: `"${mockEmojis[0].name}"` }, config: { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index b9275409125..696483df8ef 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -152,7 +152,7 @@ describe('LabelToken', () => { await triggerFetchLabels(); }); - it('calls `createAlert` with alert error message', () => { + it('calls `createAlert`', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching labels.', }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index fea1496a80b..c758e550ba2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -134,7 +134,7 @@ describe('MilestoneToken', () => { return triggerFetchMilestones(); }); - it('calls `createAlert` with alert error message', () => { + it('calls `createAlert`', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching milestones.', }); @@ -153,7 +153,7 @@ describe('MilestoneToken', () => { { text: 'bar', value: 'baz' }, ]; - beforeEach(async () => { + beforeEach(() => { wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` }, config: { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js index 89003296854..d0a6519f16d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js @@ -134,13 +134,13 @@ describe('UserToken', () => { return triggerFetchUsers(); }); - it('calls `createAlert` with alert error message', () => { + it('calls `createAlert`', () => { expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching users.', }); }); - it('sets `loading` to false when request completes', async () => { + it('sets `loading` to false when request completes', () => { expect(findBaseToken().props('suggestionsLoading')).toBe(false); }); }); @@ -187,7 +187,7 @@ describe('UserToken', () => { expect(tokenValue.text()).toBe(mockUsers[0].name); // "Administrator" }); - it('renders token value with correct avatarUrl from user object', async () => { + it('renders token value with correct avatarUrl from user object', () => { const getAvatarEl = () => wrapper.findAllComponents(GlFilteredSearchTokenSegment).at(2).findComponent(GlAvatar); diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js index 1de206123fe..38d54eff872 100644 --- a/spec/frontend/vue_shared/components/gl_countdown_spec.js +++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js @@ -11,7 +11,7 @@ describe('GlCountdown', () => { }); describe('when there is time remaining', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mount(GlCountdown, { propsData: { endDateString: '2000-01-01T01:02:03Z', @@ -33,7 +33,7 @@ describe('GlCountdown', () => { }); describe('when there is no time remaining', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mount(GlCountdown, { propsData: { endDateString: '1900-01-01T00:00:00Z', diff --git a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js index 4e83f3e1c06..397fd270344 100644 --- a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js +++ b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js @@ -165,7 +165,7 @@ describe('ListboxInput', () => { findGlListbox().vm.$emit('search', '1'); }); - it('passes only the items that match the search string', async () => { + it('passes only the items that match the search string', () => { expect(findGlListbox().props('items')).toStrictEqual([ { text: 'Group 1', @@ -183,7 +183,7 @@ describe('ListboxInput', () => { findGlListbox().vm.$emit('search', '1'); }); - it('passes only the items that match the search string', async () => { + it('passes only the items that match the search string', () => { expect(findGlListbox().props('items')).toStrictEqual([{ text: 'Item 1', value: '1' }]); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js new file mode 100644 index 00000000000..bfa9b7dd706 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js @@ -0,0 +1,76 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import savedRepliesResponse from 'test_fixtures/graphql/comment_templates/saved_replies.query.graphql.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { updateText } from '~/lib/utils/text_markdown'; +import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue'; +import savedRepliesQuery from '~/vue_shared/components/markdown/saved_replies.query.graphql'; + +jest.mock('~/lib/utils/text_markdown'); + +let wrapper; +let savedRepliesResp; + +function createMockApolloProvider(response) { + Vue.use(VueApollo); + + savedRepliesResp = jest.fn().mockResolvedValue(response); + + const requestHandlers = [[savedRepliesQuery, savedRepliesResp]]; + + return createMockApollo(requestHandlers); +} + +function createComponent(options = {}) { + const { mockApollo } = options; + + return mountExtended(CommentTemplatesDropdown, { + attachTo: '#root', + propsData: { + newCommentTemplatePath: '/new', + }, + apolloProvider: mockApollo, + }); +} + +describe('Comment templates dropdown', () => { + beforeEach(() => { + setHTMLFixture('<div class="md-area"><textarea></textarea><div id="root"></div></div>'); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('fetches data when dropdown gets opened', async () => { + const mockApollo = createMockApolloProvider(savedRepliesResponse); + wrapper = createComponent({ mockApollo }); + + wrapper.findByTestId('comment-template-dropdown-toggle').trigger('click'); + + await waitForPromises(); + + expect(savedRepliesResp).toHaveBeenCalled(); + }); + + it('adds content to textarea', async () => { + const mockApollo = createMockApolloProvider(savedRepliesResponse); + wrapper = createComponent({ mockApollo }); + + wrapper.findByTestId('comment-template-dropdown-toggle').trigger('click'); + + await waitForPromises(); + + wrapper.find('.gl-new-dropdown-item').trigger('click'); + + expect(updateText).toHaveBeenCalledWith({ + textArea: document.querySelector('textarea'), + tag: savedRepliesResponse.data.currentUser.savedReplies.nodes[0].content, + cursorOffset: 0, + wrap: false, + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js index becd4257cbe..fd8493e0911 100644 --- a/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js @@ -23,8 +23,8 @@ describe('vue_shared/component/markdown/editor_mode_dropdown', () => { describe.each` modeText | value | dropdownText | otherMode - ${'Rich text'} | ${'richText'} | ${'Viewing rich text'} | ${'Markdown'} - ${'Markdown'} | ${'markdown'} | ${'Viewing markdown'} | ${'Rich text'} + ${'Rich text'} | ${'richText'} | ${'Editing rich text'} | ${'Markdown'} + ${'Markdown'} | ${'markdown'} | ${'Editing markdown'} | ${'Rich text'} `('$modeText', ({ modeText, value, dropdownText, otherMode }) => { beforeEach(() => { createComponent({ value }); 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 681ff6c8dd3..69dedd6b68a 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -9,6 +9,7 @@ import ContentEditor from '~/content_editor/components/content_editor.vue'; import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { assertProps } from 'helpers/assert_props'; import { stubComponent } from 'helpers/stub_component'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -33,23 +34,26 @@ describe('vue_shared/component/markdown/markdown_editor', () => { const autocompleteDataSources = { commands: '/foobar/-/autcomplete_sources' }; let mock; + const defaultProps = { + value, + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + enableAutocomplete, + autocompleteDataSources, + enablePreview, + formFieldProps: { + id: formFieldId, + name: formFieldName, + placeholder: formFieldPlaceholder, + 'aria-label': formFieldAriaLabel, + }, + }; const buildWrapper = ({ propsData = {}, attachTo, stubs = {} } = {}) => { wrapper = mountExtended(MarkdownEditor, { attachTo, propsData: { - value, - renderMarkdownPath, - markdownDocsPath, - quickActionsDocsPath, - enableAutocomplete, - autocompleteDataSources, - enablePreview, - formFieldProps: { - id: formFieldId, - name: formFieldName, - placeholder: formFieldPlaceholder, - 'aria-label': formFieldAriaLabel, - }, + ...defaultProps, ...propsData, }, stubs: { @@ -244,9 +248,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); 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"'); + expect(() => assertProps(MarkdownEditor, { ...defaultProps, 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 () => { @@ -275,7 +279,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { await findTextarea().setValue(newValue); - expect(wrapper.emitted('input')).toEqual([[newValue]]); + expect(wrapper.emitted('input')).toEqual([[value], [newValue]]); }); it('autosaves the markdown value to local storage', async () => { @@ -346,6 +350,16 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); }); + describe('when contentEditor is disabled', () => { + it('resets the editingMode to markdownField', () => { + localStorage.setItem('gl-markdown-editor-mode', 'contentEditor'); + + buildWrapper({ propsData: { autosaveKey: 'issue/1234', enableContentEditor: false } }); + + expect(wrapper.vm.editingMode).toBe(EDITING_MODE_MARKDOWN_FIELD); + }); + }); + describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => { beforeEach(async () => { buildWrapper({ propsData: { autosaveKey: 'issue/1234' } }); @@ -370,7 +384,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { await findContentEditor().vm.$emit('change', { markdown: newValue }); - expect(wrapper.emitted('input')).toEqual([[newValue]]); + expect(wrapper.emitted('input')).toEqual([[value], [newValue]]); }); it('autosaves the content editor value to local storage', async () => { diff --git a/spec/frontend/vue_shared/components/markdown/saved_replies_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/saved_replies_dropdown_spec.js deleted file mode 100644 index 8ad9ad30c1d..00000000000 --- a/spec/frontend/vue_shared/components/markdown/saved_replies_dropdown_spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import savedRepliesResponse from 'test_fixtures/graphql/saved_replies/saved_replies.query.graphql.json'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import SavedRepliesDropdown from '~/vue_shared/components/markdown/saved_replies_dropdown.vue'; -import savedRepliesQuery from '~/vue_shared/components/markdown/saved_replies.query.graphql'; - -let wrapper; -let savedRepliesResp; - -function createMockApolloProvider(response) { - Vue.use(VueApollo); - - savedRepliesResp = jest.fn().mockResolvedValue(response); - - const requestHandlers = [[savedRepliesQuery, savedRepliesResp]]; - - return createMockApollo(requestHandlers); -} - -function createComponent(options = {}) { - const { mockApollo } = options; - - return mountExtended(SavedRepliesDropdown, { - propsData: { - newSavedRepliesPath: '/new', - }, - apolloProvider: mockApollo, - }); -} - -describe('Saved replies dropdown', () => { - it('fetches data when dropdown gets opened', async () => { - const mockApollo = createMockApolloProvider(savedRepliesResponse); - wrapper = createComponent({ mockApollo }); - - wrapper.findByTestId('saved-replies-dropdown-toggle').trigger('click'); - - await waitForPromises(); - - expect(savedRepliesResp).toHaveBeenCalled(); - }); - - it('adds markdown toolbar attributes to dropdown items', async () => { - const mockApollo = createMockApolloProvider(savedRepliesResponse); - wrapper = createComponent({ mockApollo }); - - wrapper.findByTestId('saved-replies-dropdown-toggle').trigger('click'); - - await waitForPromises(); - - expect(wrapper.findByTestId('saved-reply-dropdown-item').attributes()).toEqual( - expect.objectContaining({ - 'data-md-cursor-offset': '0', - 'data-md-prepend': 'true', - 'data-md-tag': 'Saved Reply Content', - }), - ); - }); -}); diff --git a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js index 8f4235cfe41..2fdab40b4bd 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js @@ -1,4 +1,5 @@ -import Vue, { nextTick } from 'vue'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue'; const MOCK_DATA = { @@ -48,56 +49,37 @@ const MOCK_DATA = { }; describe('Suggestion component', () => { - let vm; - let diffTable; + let wrapper; - beforeEach(async () => { - const Component = Vue.extend(SuggestionsComponent); + const createComponent = (props = {}) => { + wrapper = mountExtended(SuggestionsComponent, { + propsData: { + ...MOCK_DATA, + ...props, + }, + }); + }; - vm = new Component({ - propsData: MOCK_DATA, - }).$mount(); + const findSuggestionsContainer = () => wrapper.findByTestId('suggestions-container'); - diffTable = vm.generateDiff(0).$mount().$el; + beforeEach(async () => { + createComponent(); - jest.spyOn(vm, 'renderSuggestions').mockImplementation(() => {}); - vm.renderSuggestions(); await nextTick(); }); describe('mounted', () => { it('renders a flash container', () => { - expect(vm.$el.querySelector('.js-suggestions-flash')).not.toBeNull(); + expect(wrapper.find('.js-suggestions-flash').exists()).toBe(true); }); it('renders a container for suggestions', () => { - expect(vm.$refs.container).not.toBeNull(); + expect(findSuggestionsContainer().exists()).toBe(true); }); it('renders suggestions', () => { - expect(vm.renderSuggestions).toHaveBeenCalled(); - expect(vm.$el.innerHTML.includes('oldtest')).toBe(true); - expect(vm.$el.innerHTML.includes('newtest')).toBe(true); - }); - }); - - describe('generateDiff', () => { - it('generates a diff table', () => { - expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull(); - }); - - it('generates a diff table that contains contents the suggested lines', () => { - MOCK_DATA.suggestions[0].diff_lines.forEach((line) => { - const text = line.text.substring(1); - - expect(diffTable.innerHTML.includes(text)).toBe(true); - }); - }); - - it('generates a diff table with the correct line number for each suggested line', () => { - const lines = diffTable.querySelectorAll('.old_line'); - - expect(parseInt([...lines][0].innerHTML, 10)).toBe(5); + expect(findSuggestionsContainer().text()).toContain('oldtest'); + expect(findSuggestionsContainer().text()).toContain('newtest'); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js index 37b0767616a..6f4902e3f96 100644 --- a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js +++ b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js @@ -156,7 +156,7 @@ describe('MarkdownDrawer', () => { renderGLFMSpy.mockClear(); }); - it('fetches the Markdown and caches it', async () => { + it('fetches the Markdown and caches it', () => { expect(getRenderedMarkdown).toHaveBeenCalledTimes(1); expect(Object.keys(cache)).toHaveLength(1); }); @@ -199,13 +199,13 @@ describe('MarkdownDrawer', () => { afterEach(() => { getRenderedMarkdown.mockClear(); }); - it('shows alert', () => { + it('shows an alert', () => { expect(findAlert().exists()).toBe(true); }); }); describe('While Markdown is fetching', () => { - beforeEach(async () => { + beforeEach(() => { getRenderedMarkdown.mockReturnValue(new Promise(() => {})); createComponent(); @@ -215,7 +215,7 @@ describe('MarkdownDrawer', () => { getRenderedMarkdown.mockClear(); }); - it('shows skeleton', async () => { + it('shows skeleton', () => { expect(findSkeleton().exists()).toBe(true); }); }); 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 29e1a9ccf4d..7f3912dcadb 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -64,7 +64,7 @@ describe('system note component', () => { it('should render svg icon', () => { createComponent(props); - expect(vm.find('.timeline-icon svg').exists()).toBe(true); + expect(vm.find('[data-testid="timeline-icon"]').exists()).toBe(true); }); // Redcarpet Markdown renderer wraps text in `<p>` tags diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js new file mode 100644 index 00000000000..a8e3536059e --- /dev/null +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js @@ -0,0 +1,169 @@ +import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui'; +import projects from 'test_fixtures/api/users/projects/get.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { + VISIBILITY_TYPE_ICON, + VISIBILITY_LEVEL_PRIVATE_STRING, + PROJECT_VISIBILITY_TYPE, +} from '~/visibility_level/constants'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import { FEATURABLE_DISABLED, FEATURABLE_ENABLED } from '~/featurable/constants'; + +describe('ProjectsListItem', () => { + let wrapper; + + const [project] = convertObjectPropsToCamelCase(projects, { deep: true }); + + const defaultPropsData = { project }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(ProjectsListItem, { + propsData: { ...defaultPropsData, ...propsData }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); + const findIssuesLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.issues }); + const findForksLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.forks }); + + it('renders project avatar', () => { + createComponent(); + + const avatarLabeled = findAvatarLabeled(); + + expect(avatarLabeled.props()).toMatchObject({ + label: project.name, + labelLink: project.webUrl, + }); + expect(avatarLabeled.attributes()).toMatchObject({ + 'entity-id': project.id.toString(), + 'entity-name': project.name, + shape: 'rect', + size: '48', + }); + }); + + it('renders visibility icon with tooltip', () => { + createComponent(); + + const icon = findAvatarLabeled().findComponent(GlIcon); + const tooltip = getBinding(icon.element, 'gl-tooltip'); + + expect(icon.props('name')).toBe(VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_PRIVATE_STRING]); + expect(tooltip.value).toBe(PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_PRIVATE_STRING]); + }); + + it('renders access role badge', () => { + createComponent(); + + expect(findAvatarLabeled().findComponent(UserAccessRoleBadge).text()).toBe( + ACCESS_LEVEL_LABELS[project.permissions.projectAccess.accessLevel], + ); + }); + + describe('if project is archived', () => { + beforeEach(() => { + createComponent({ + propsData: { + project: { + ...project, + archived: true, + }, + }, + }); + }); + + it('renders the archived badge', () => { + expect( + wrapper + .findAllComponents(GlBadge) + .wrappers.find((badge) => badge.text() === ProjectsListItem.i18n.archived), + ).not.toBeUndefined(); + }); + }); + + it('renders stars count', () => { + createComponent(); + + const starsLink = wrapper.findByRole('link', { name: ProjectsListItem.i18n.stars }); + const tooltip = getBinding(starsLink.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(ProjectsListItem.i18n.stars); + expect(starsLink.attributes('href')).toBe(`${project.webUrl}/-/starrers`); + expect(starsLink.text()).toBe(project.starCount.toString()); + expect(starsLink.findComponent(GlIcon).props('name')).toBe('star-o'); + }); + + describe('when issues are enabled', () => { + it('renders issues count', () => { + createComponent(); + + const issuesLink = findIssuesLink(); + const tooltip = getBinding(issuesLink.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(ProjectsListItem.i18n.issues); + expect(issuesLink.attributes('href')).toBe(`${project.webUrl}/-/issues`); + expect(issuesLink.text()).toBe(project.openIssuesCount.toString()); + expect(issuesLink.findComponent(GlIcon).props('name')).toBe('issues'); + }); + }); + + describe('when issues are not enabled', () => { + it('does not render issues count', () => { + createComponent({ + propsData: { + project: { + ...project, + issuesAccessLevel: FEATURABLE_DISABLED, + }, + }, + }); + + expect(findIssuesLink().exists()).toBe(false); + }); + }); + + describe('when forking is enabled', () => { + it('renders forks count', () => { + createComponent(); + + const forksLink = findForksLink(); + const tooltip = getBinding(forksLink.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(ProjectsListItem.i18n.forks); + expect(forksLink.attributes('href')).toBe(`${project.webUrl}/-/forks`); + expect(forksLink.text()).toBe(project.openIssuesCount.toString()); + expect(forksLink.findComponent(GlIcon).props('name')).toBe('fork'); + }); + }); + + describe('when forking is not enabled', () => { + it.each([ + { + ...project, + forksCount: 2, + forkingAccessLevel: FEATURABLE_DISABLED, + }, + { + ...project, + forksCount: undefined, + forkingAccessLevel: FEATURABLE_ENABLED, + }, + ])('does not render forks count', (modifiedProject) => { + createComponent({ + propsData: { + project: modifiedProject, + }, + }); + + expect(findForksLink().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js new file mode 100644 index 00000000000..9380e19c39e --- /dev/null +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js @@ -0,0 +1,34 @@ +import projects from 'test_fixtures/api/users/projects/get.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; +import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +describe('ProjectsList', () => { + let wrapper; + + const defaultPropsData = { + projects: convertObjectPropsToCamelCase(projects, { deep: true }), + }; + + const createComponent = () => { + wrapper = shallowMountExtended(ProjectsList, { + propsData: defaultPropsData, + }); + }; + + it('renders list with `ProjectListItem` component', () => { + createComponent(); + + const projectsListItemWrappers = wrapper.findAllComponents(ProjectsListItem).wrappers; + const expectedProps = projectsListItemWrappers.map((projectsListItemWrapper) => + projectsListItemWrapper.props(), + ); + + expect(expectedProps).toEqual( + defaultPropsData.projects.map((project) => ({ + project, + })), + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index e8d76991b90..eadcb6ceeb7 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -46,31 +46,15 @@ exports[`Package code instruction single line to match the default snapshot 1`] class="input-group-append" data-testid="instruction-button" > - <button - aria-label="Copy npm install command" - aria-live="polite" - class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-handle-tooltip="false" - data-clipboard-text="npm i @my-package" - id="clipboard-button-1" + <clipboard-button-stub + category="secondary" + class="input-group-text" + size="medium" + text="npm i @my-package" title="Copy npm install command" - type="button" - > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" - role="img" - > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> + tooltipplacement="top" + variant="default" + /> </span> </div> </div> diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap index 66cf2354bc7..5c487754b87 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap @@ -8,7 +8,7 @@ exports[`History Item renders the correct markup 1`] = ` class="timeline-entry-inner" > <div - class="timeline-icon" + class="gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600 gl-float-left" > <gl-icon-stub name="pencil" diff --git a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js index 60c1293b7c1..299535775e0 100644 --- a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js +++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import Tracking from '~/tracking'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -14,7 +14,7 @@ describe('Package code instruction', () => { }; function createComponent(props = {}) { - wrapper = mount(CodeInstruction, { + wrapper = shallowMount(CodeInstruction, { propsData: { ...defaultProps, ...props, diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap index 623f7d083c5..65427374e1b 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap @@ -15,8 +15,8 @@ exports[`Resizable Skeleton Loader default setup renders the bars, labels, and g </title> <rect clip-path="url(#null-idClip)" + fill="url(#null-idGradient)" height="130" - style="fill: url(#null-idGradient);" width="400" x="0" y="0" @@ -234,8 +234,8 @@ exports[`Resizable Skeleton Loader with custom settings renders the correct posi </title> <rect clip-path="url(#-idClip)" + fill="url(#-idGradient)" height="130" - style="fill: url(#-idGradient);" width="400" x="0" y="0" diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js index c4d4f80c573..c6cd963fc33 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js @@ -65,7 +65,7 @@ describe('RunnerCliInstructions component', () => { await waitForPromises(); }); - it('should not show alert', async () => { + it('should not show alert', () => { expect(findAlert().exists()).toBe(false); }); @@ -85,13 +85,13 @@ describe('RunnerCliInstructions component', () => { }); }); - it('binary instructions are shown', async () => { + it('binary instructions are shown', () => { const instructions = findBinaryInstructions().text(); expect(instructions).toBe(installInstructions.trim()); }); - it('register command is shown with a replaced token', async () => { + it('register command is shown with a replaced token', () => { const command = findRegisterCommand().text(); expect(command).toBe( 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 cb35cbd35ad..cd4ebe334c0 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 @@ -80,7 +80,7 @@ describe('RunnerInstructionsModal component', () => { await waitForPromises(); }); - it('should not show alert', async () => { + it('should not show alert', () => { expect(findAlert().exists()).toBe(false); }); @@ -202,7 +202,7 @@ describe('RunnerInstructionsModal component', () => { expect(findAlert().exists()).toBe(true); }); - it('should show alert when instructions cannot be loaded', async () => { + it('should show an alert when instructions cannot be loaded', async () => { createComponent(); await waitForPromises(); diff --git a/spec/frontend/vue_shared/components/slot_switch_spec.js b/spec/frontend/vue_shared/components/slot_switch_spec.js index f25b9877aba..daca4977817 100644 --- a/spec/frontend/vue_shared/components/slot_switch_spec.js +++ b/spec/frontend/vue_shared/components/slot_switch_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { assertProps } from 'helpers/assert_props'; import SlotSwitch from '~/vue_shared/components/slot_switch.vue'; @@ -26,7 +27,9 @@ describe('SlotSwitch', () => { }); it('throws an error if activeSlotNames is missing', () => { - expect(createComponent).toThrow('[Vue warn]: Missing required prop: "activeSlotNames"'); + expect(() => assertProps(SlotSwitch, {})).toThrow( + '[Vue warn]: Missing required prop: "activeSlotNames"', + ); }); it('renders no slots if activeSlotNames is empty', () => { diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js index 6c8fc244fa0..9a38a96663d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js @@ -10,16 +10,10 @@ const DEFAULT_PROPS = { describe('Chunk Line component', () => { let wrapper; - const fileLineBlame = true; const createComponent = (props = {}) => { wrapper = shallowMountExtended(ChunkLine, { propsData: { ...DEFAULT_PROPS, ...props }, - provide: { - glFeatures: { - fileLineBlame, - }, - }, }); }; 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 59880496d74..ff50326917f 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 @@ -11,7 +11,6 @@ describe('Chunk component', () => { const createComponent = (props = {}) => { wrapper = shallowMountExtended(Chunk, { propsData: { ...CHUNK_1, ...props }, - provide: { glFeatures: { fileLineBlame: true } }, }); }; 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 index c911e3d308b..4cec129b6e4 100644 --- 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 @@ -168,7 +168,7 @@ describe('Source Viewer component', () => { }); describe('LineHighlighter', () => { - it('instantiates the lineHighlighter class', async () => { + it('instantiates the lineHighlighter class', () => { expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); }); }); diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js index 6b869db4058..ffa25ae8448 100644 --- a/spec/frontend/vue_shared/components/split_button_spec.js +++ b/spec/frontend/vue_shared/components/split_button_spec.js @@ -2,6 +2,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { assertProps } from 'helpers/assert_props'; import SplitButton from '~/vue_shared/components/split_button.vue'; const mockActionItems = [ @@ -42,12 +43,12 @@ describe('SplitButton', () => { it('fails for empty actionItems', () => { const actionItems = []; - expect(() => createComponent({ actionItems })).toThrow(); + expect(() => assertProps(SplitButton, { actionItems })).toThrow(); }); it('fails for single actionItems', () => { const actionItems = [mockActionItems[0]]; - expect(() => createComponent({ actionItems })).toThrow(); + expect(() => assertProps(SplitButton, { actionItems })).toThrow(); }); it('renders actionItems', () => { diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js index 3807bb4cc63..f5da498a205 100644 --- a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js +++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js @@ -79,12 +79,12 @@ describe('TooltipOnTruncate component', () => { }; describe('when truncated', () => { - beforeEach(async () => { + beforeEach(() => { hasHorizontalOverflow.mockReturnValueOnce(true); createComponent(); }); - it('renders tooltip', async () => { + it('renders tooltip', () => { expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element); expect(getTooltipValue()).toStrictEqual({ title: MOCK_TITLE, @@ -96,7 +96,7 @@ describe('TooltipOnTruncate component', () => { }); describe('with default target', () => { - beforeEach(async () => { + beforeEach(() => { hasHorizontalOverflow.mockReturnValueOnce(false); createComponent(); }); diff --git a/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js index b04e578c931..a4efbda06ce 100644 --- a/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js +++ b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js @@ -31,18 +31,18 @@ describe('UserCalloutDismisser', () => { const MOCK_FEATURE_NAME = 'mock_feature_name'; // Query handlers - const successHandlerFactory = (dismissedCallouts = []) => async () => - userCalloutsResponse(dismissedCallouts); - const anonUserHandler = async () => anonUserCalloutsResponse(); + const successHandlerFactory = (dismissedCallouts = []) => () => + Promise.resolve(userCalloutsResponse(dismissedCallouts)); + const anonUserHandler = () => Promise.resolve(anonUserCalloutsResponse()); const errorHandler = () => Promise.reject(new Error('query error')); const pendingHandler = () => new Promise(() => {}); // Mutation handlers - const mutationSuccessHandlerSpy = jest.fn(async (variables) => - userCalloutMutationResponse(variables), + const mutationSuccessHandlerSpy = jest.fn((variables) => + Promise.resolve(userCalloutMutationResponse(variables)), ); - const mutationErrorHandlerSpy = jest.fn(async (variables) => - userCalloutMutationResponse(variables, ['mutation error']), + const mutationErrorHandlerSpy = jest.fn((variables) => + Promise.resolve(userCalloutMutationResponse(variables, ['mutation error'])), ); const defaultScopedSlotSpy = jest.fn(); diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js index 6491e5a66cd..d77e357a50c 100644 --- a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js +++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js @@ -61,7 +61,7 @@ describe('User deletion obstacles list', () => { ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} `('when current user', ({ isCurrentUser, titleText, footerText }) => { - it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call management`, async () => { + it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call management`, () => { createComponent({ isCurrentUser, }); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 8ecab5cc043..41181ab9a68 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -1,5 +1,6 @@ import { GlSkeletonLoader, GlIcon } from '@gitlab/ui'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import mrDiffCommentFixture from 'test_fixtures/merge_requests/diff_comment.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { sprintf } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; @@ -41,12 +42,10 @@ const DEFAULT_PROPS = { }; describe('User Popover Component', () => { - const fixtureTemplate = 'merge_requests/diff_comment.html'; - let wrapper; beforeEach(() => { - loadHTMLFixture(fixtureTemplate); + setHTMLFixture(mrDiffCommentFixture); gon.features = {}; }); @@ -276,7 +275,7 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(wrapper.findByText('(Busy)').exists()).toBe(true); + expect(wrapper.findByText('Busy').exists()).toBe(true); }); it('should hide the busy status for any other status', () => { @@ -287,7 +286,7 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(wrapper.findByText('(Busy)').exists()).toBe(false); + expect(wrapper.findByText('Busy').exists()).toBe(false); }); it('shows pronouns when user has them set', () => { diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js index acbb931b7b6..e24c5a4609d 100644 --- a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js +++ b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js @@ -3,10 +3,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; -const TestComponent = Vue.extend({ +const TestComponent = { inject: ['vuexModule'], template: `<div data-testid="vuexModule">{{ vuexModule }}</div> `, -}); +}; const TEST_VUEX_MODULE = 'testVuexModule'; @@ -32,6 +32,13 @@ describe('~/vue_shared/components/vuex_module_provider', () => { expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); }); + it('provides "vuexModel" set from "vuex-module" prop when using @vue/compat', () => { + createComponent({ + propsData: { 'vuex-module': TEST_VUEX_MODULE }, + }); + expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE); + }); + it('does not blow up when used with vue-apollo', () => { // See https://github.com/vuejs/vue-apollo/pull/1153 for details Vue.use(VueApollo); 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 f6eb11aaddf..4b2ce24a49f 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -345,7 +345,7 @@ describe('Web IDE link component', () => { it.each(testActions)( 'emits the correct event when an action handler is called', - async ({ props, expectedEventPayload }) => { + ({ props, expectedEventPayload }) => { createComponent({ ...props, needsToFork: true, disableForkModal: true }); findActionsButton().props('actions')[0].handle(); @@ -354,7 +354,7 @@ describe('Web IDE link component', () => { }, ); - it.each(testActions)('renders the fork confirmation modal', async ({ props }) => { + it.each(testActions)('renders the fork confirmation modal', ({ props }) => { createComponent({ ...props, needsToFork: true }); expect(findForkConfirmModal().exists()).toBe(true); diff --git a/spec/frontend/vue_shared/directives/track_event_spec.js b/spec/frontend/vue_shared/directives/track_event_spec.js index 4bf84b06246..fc69e884258 100644 --- a/spec/frontend/vue_shared/directives/track_event_spec.js +++ b/spec/frontend/vue_shared/directives/track_event_spec.js @@ -1,50 +1,47 @@ import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Tracking from '~/tracking'; import TrackEvent from '~/vue_shared/directives/track_event'; jest.mock('~/tracking'); -const Component = Vue.component('DummyElement', { - directives: { - TrackEvent, - }, - data() { - return { - trackingOptions: null, - }; - }, - template: '<button id="trackable" v-track-event="trackingOptions"></button>', -}); +describe('TrackEvent directive', () => { + let wrapper; -let wrapper; -let button; + const clickButton = () => wrapper.find('button').trigger('click'); -describe('Error Tracking directive', () => { - beforeEach(() => { - wrapper = shallowMount(Component); - button = wrapper.find('#trackable'); - }); + const createComponent = (trackingOptions) => + Vue.component('DummyElement', { + directives: { + TrackEvent, + }, + data() { + return { + trackingOptions, + }; + }, + template: '<button v-track-event="trackingOptions"></button>', + }); + + const mountComponent = (trackingOptions) => shallowMount(createComponent(trackingOptions)); + + it('does not track the event if required arguments are not provided', () => { + wrapper = mountComponent(); + clickButton(); - it('should not track the event if required arguments are not provided', () => { - button.trigger('click'); expect(Tracking.event).not.toHaveBeenCalled(); }); - it('should track event on click if tracking info provided', async () => { - const trackingOptions = { + it('tracks event on click if tracking info provided', () => { + wrapper = mountComponent({ category: 'Tracking', action: 'click_trackable_btn', label: 'Trackable Info', - }; - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ trackingOptions }); - const { category, action, label, property, value } = trackingOptions; + }); + clickButton(); - await nextTick(); - button.trigger('click'); - expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property, value }); + expect(Tracking.event).toHaveBeenCalledWith('Tracking', 'click_trackable_btn', { + label: 'Trackable Info', + }); }); }); 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 61e6d2a420a..d5603d4ba4b 100644 --- a/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js +++ b/spec/frontend/vue_shared/issuable/issuable_blocked_icon_spec.js @@ -139,7 +139,7 @@ describe('IssuableBlockedIcon', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); - it('should not query for blocking issuables by default', async () => { + it('should not query for blocking issuables by default', () => { createWrapperWithApollo(); expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title); @@ -195,18 +195,18 @@ describe('IssuableBlockedIcon', () => { await mouseenter(); }); - it('should render a title of the issuable', async () => { + it('should render a title of the issuable', () => { expect(findIssuableTitle().text()).toBe(mockBlockingIssue1.title); }); - it('should render issuable reference and link to the issuable', async () => { + it('should render issuable reference and link to the issuable', () => { const formattedRef = mockBlockingIssue1.reference.split('/')[1]; expect(findGlLink().text()).toBe(formattedRef); expect(findGlLink().attributes('href')).toBe(mockBlockingIssue1.webUrl); }); - it('should render popover title with correct blocking issuable count', async () => { + it('should render popover title with correct blocking issuable count', () => { expect(findPopoverTitle().text()).toBe('Blocked by 1 issue'); }); }); @@ -241,7 +241,7 @@ describe('IssuableBlockedIcon', () => { expect(wrapper.html()).toMatchSnapshot(); }); - it('should render popover title with correct blocking issuable count', async () => { + it('should render popover title with correct blocking issuable count', () => { expect(findPopoverTitle().text()).toBe('Blocked by 4 issues'); }); @@ -249,7 +249,7 @@ describe('IssuableBlockedIcon', () => { expect(findHiddenBlockingCount().text()).toBe('+ 1 more issue'); }); - it('should link to the blocked issue page at the related issue anchor', async () => { + it('should link to the blocked issue page at the related issue anchor', () => { expect(findViewAllIssuableLink().text()).toBe('View all blocking issues'); expect(findViewAllIssuableLink().attributes('href')).toBe( `${mockBlockedIssue2.webUrl}#related-issues`, diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index 45daf0dc34b..502fa609ebc 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -331,7 +331,7 @@ describe('IssuableItem', () => { }); }); - it('renders spam icon when issuable is hidden', async () => { + it('renders spam icon when issuable is hidden', () => { wrapper = createComponent({ issuable: { ...mockIssuable, hidden: true } }); const hiddenIcon = wrapper.findComponent(GlIcon); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 9a4636e0f4d..ec975dfdcb5 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -333,7 +333,7 @@ describe('IssuableListRoot', () => { describe('alert', () => { const error = 'oopsie!'; - it('shows alert when there is an error', () => { + it('shows an alert when there is an error', () => { wrapper = createComponent({ props: { error } }); expect(findAlert().text()).toBe(error); @@ -504,7 +504,7 @@ describe('IssuableListRoot', () => { }); }); - it('has the page size change component', async () => { + it('has the page size change component', () => { expect(findPageSizeSelector().exists()).toBe(true); }); diff --git a/spec/frontend/vue_shared/issuable/list/mock_data.js b/spec/frontend/vue_shared/issuable/list/mock_data.js index b67bd0f42fe..964b48f4275 100644 --- a/spec/frontend/vue_shared/issuable/list/mock_data.js +++ b/spec/frontend/vue_shared/issuable/list/mock_data.js @@ -60,6 +60,12 @@ export const mockIssuable = { type: 'issue', }; +export const mockIssuableItems = (n) => + [...Array(n).keys()].map((i) => ({ + id: i, + ...mockIssuable, + })); + export const mockIssuables = [ mockIssuable, { diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js index 7e665b7c76e..02e729a00bd 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js @@ -1,5 +1,5 @@ +import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import { useFakeDate } from 'helpers/fake_date'; import IssuableBody from '~/vue_shared/issuable/show/components/issuable_body.vue'; @@ -14,96 +14,76 @@ import { mockIssuableShowProps, mockIssuable } from '../mock_data'; jest.mock('~/autosave'); jest.mock('~/alert'); +jest.mock('~/task_list'); const issuableBodyProps = { ...mockIssuableShowProps, issuable: mockIssuable, }; -const createComponent = (propsData = issuableBodyProps) => - shallowMount(IssuableBody, { - propsData, - stubs: { - IssuableTitle, - IssuableDescription, - IssuableEditForm, - TimeAgoTooltip, - }, - slots: { - 'status-badge': 'Open', - 'edit-form-actions': ` - <button class="js-save">Save changes</button> - <button class="js-cancel">Cancel</button> - `, - }, - }); - describe('IssuableBody', () => { // Some assertions expect a date later than our default useFakeDate(2020, 11, 11); let wrapper; + const createComponent = (propsData = {}) => { + wrapper = shallowMount(IssuableBody, { + propsData: { + ...issuableBodyProps, + ...propsData, + }, + stubs: { + IssuableTitle, + IssuableDescription, + IssuableEditForm, + TimeAgoTooltip, + }, + slots: { + 'status-badge': 'Open', + 'edit-form-actions': ` + <button class="js-save">Save changes</button> + <button class="js-cancel">Cancel</button> + `, + }, + }); + }; + + const findUpdatedLink = () => wrapper.findComponent(GlLink); + const findIssuableEditForm = () => wrapper.findComponent(IssuableEditForm); + const findIssuableEditFormButton = (type) => findIssuableEditForm().find(`button.js-${type}`); + const findIssuableTitle = () => wrapper.findComponent(IssuableTitle); + beforeEach(() => { - wrapper = createComponent(); + createComponent(); + TaskList.mockClear(); }); describe('computed', () => { - describe('isUpdated', () => { - it.each` - updatedAt | returnValue - ${mockIssuable.updatedAt} | ${true} - ${null} | ${false} - ${''} | ${false} - `( - 'returns $returnValue when value of `updateAt` prop is `$updatedAt`', - async ({ updatedAt, returnValue }) => { - wrapper.setProps({ - issuable: { - ...mockIssuable, - updatedAt, - }, - }); - - await nextTick(); - - expect(wrapper.vm.isUpdated).toBe(returnValue); - }, - ); - }); - describe('updatedBy', () => { it('returns value of `issuable.updatedBy`', () => { - expect(wrapper.vm.updatedBy).toBe(mockIssuable.updatedBy); + expect(findUpdatedLink().text()).toBe(mockIssuable.updatedBy.name); + expect(findUpdatedLink().attributes('href')).toBe(mockIssuable.updatedBy.webUrl); }); }); }); describe('watchers', () => { describe('editFormVisible', () => { - it('calls initTaskList in nextTick', async () => { - jest.spyOn(wrapper.vm, 'initTaskList'); - wrapper.setProps({ - editFormVisible: true, - }); - - await nextTick(); - - wrapper.setProps({ + it('calls initTaskList in nextTick', () => { + createComponent({ editFormVisible: false, }); - await nextTick(); - - expect(wrapper.vm.initTaskList).toHaveBeenCalled(); + expect(TaskList).toHaveBeenCalled(); }); }); }); describe('mounted', () => { it('initializes TaskList instance when enabledEdit and enableTaskList props are true', () => { - expect(wrapper.vm.taskList instanceof TaskList).toBe(true); - expect(wrapper.vm.taskList).toMatchObject({ + createComponent(); + expect(TaskList).toHaveBeenCalledWith({ dataType: 'issue', fieldName: 'description', lockVersion: issuableBodyProps.taskListLockVersion, @@ -114,14 +94,12 @@ describe('IssuableBody', () => { }); it('does not initialize TaskList instance when either enabledEdit or enableTaskList prop is false', () => { - const wrapperNoTaskList = createComponent({ + createComponent({ ...issuableBodyProps, enableTaskList: false, }); - expect(wrapperNoTaskList.vm.taskList).not.toBeDefined(); - - wrapperNoTaskList.destroy(); + expect(TaskList).toHaveBeenCalledTimes(0); }); }); @@ -150,10 +128,8 @@ describe('IssuableBody', () => { describe('template', () => { it('renders issuable-title component', () => { - const titleEl = wrapper.findComponent(IssuableTitle); - - expect(titleEl.exists()).toBe(true); - expect(titleEl.props()).toMatchObject({ + expect(findIssuableTitle().exists()).toBe(true); + expect(findIssuableTitle().props()).toMatchObject({ issuable: issuableBodyProps.issuable, statusIcon: issuableBodyProps.statusIcon, enableEdit: issuableBodyProps.enableEdit, @@ -168,42 +144,37 @@ describe('IssuableBody', () => { }); it('renders issuable edit info', () => { - const editedEl = wrapper.find('small'); - - expect(editedEl.text()).toMatchInterpolatedText('Edited 3 months ago by Administrator'); + expect(wrapper.find('small').text()).toMatchInterpolatedText( + 'Edited 3 months ago by Administrator', + ); }); - it('renders issuable-edit-form when `editFormVisible` prop is true', async () => { - wrapper.setProps({ + it('renders issuable-edit-form when `editFormVisible` prop is true', () => { + createComponent({ editFormVisible: true, }); - await nextTick(); - - const editFormEl = wrapper.findComponent(IssuableEditForm); - expect(editFormEl.exists()).toBe(true); - expect(editFormEl.props()).toMatchObject({ + expect(findIssuableEditForm().exists()).toBe(true); + expect(findIssuableEditForm().props()).toMatchObject({ issuable: issuableBodyProps.issuable, enableAutocomplete: issuableBodyProps.enableAutocomplete, descriptionPreviewPath: issuableBodyProps.descriptionPreviewPath, descriptionHelpPath: issuableBodyProps.descriptionHelpPath, }); - expect(editFormEl.find('button.js-save').exists()).toBe(true); - expect(editFormEl.find('button.js-cancel').exists()).toBe(true); + expect(findIssuableEditFormButton('save').exists()).toBe(true); + expect(findIssuableEditFormButton('cancel').exists()).toBe(true); }); describe('events', () => { it('component emits `edit-issuable` event bubbled via issuable-title', () => { - const issuableTitle = wrapper.findComponent(IssuableTitle); - - issuableTitle.vm.$emit('edit-issuable'); + findIssuableTitle().vm.$emit('edit-issuable'); expect(wrapper.emitted('edit-issuable')).toHaveLength(1); }); it.each(['keydown-title', 'keydown-description'])( 'component emits `%s` event with event object and issuableMeta params via issuable-edit-form', - async (eventName) => { + (eventName) => { const eventObj = { preventDefault: jest.fn(), stopPropagation: jest.fn(), @@ -213,15 +184,11 @@ describe('IssuableBody', () => { issuableDescription: 'foobar', }; - wrapper.setProps({ + createComponent({ editFormVisible: true, }); - await nextTick(); - - const issuableEditForm = wrapper.findComponent(IssuableEditForm); - - issuableEditForm.vm.$emit(eventName, eventObj, issuableMeta); + findIssuableEditForm().vm.$emit(eventName, eventObj, issuableMeta); expect(wrapper.emitted(eventName)).toHaveLength(1); expect(wrapper.emitted(eventName)[0]).toMatchObject([eventObj, issuableMeta]); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js index 0d6cd1ad00b..4a52c2a8dad 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js @@ -165,7 +165,7 @@ describe('IssuableEditForm', () => { stopPropagation: jest.fn(), }; - it('component emits `keydown-title` event with event object and issuableMeta params via gl-form-input', async () => { + it('component emits `keydown-title` event with event object and issuableMeta params via gl-form-input', () => { const titleInputEl = wrapper.findComponent(GlFormInput); titleInputEl.vm.$emit('keydown', eventObj, 'title'); @@ -179,7 +179,7 @@ describe('IssuableEditForm', () => { ]); }); - it('component emits `keydown-description` event with event object and issuableMeta params via textarea', async () => { + it('component emits `keydown-description` event with event object and issuableMeta params via textarea', () => { const descriptionInputEl = wrapper.find('[data-testid="description"] textarea'); descriptionInputEl.trigger('keydown', eventObj, 'description'); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js index d9f1b6c15a8..fa38ab8d44d 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js @@ -1,4 +1,4 @@ -import { GlBadge, GlIcon, GlAvatarLabeled } from '@gitlab/ui'; +import { GlButton, GlBadge, GlIcon, GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue'; @@ -13,7 +13,10 @@ const issuableHeaderProps = { describe('IssuableHeader', () => { let wrapper; + const findAvatar = () => wrapper.findByTestId('avatar'); const findTaskStatusEl = () => wrapper.findByTestId('task-status'); + const findButton = () => wrapper.findComponent(GlButton); + const findGlAvatarLink = () => wrapper.findComponent(GlAvatarLink); const createComponent = (props = {}, { stubs } = {}) => { wrapper = shallowMountExtended(IssuableHeader, { @@ -40,7 +43,7 @@ describe('IssuableHeader', () => { describe('authorId', () => { it('returns numeric ID from GraphQL ID of `author` prop', () => { createComponent(); - expect(wrapper.vm.authorId).toBe(1); + expect(findGlAvatarLink().attributes('data-user-id')).toBe('1'); }); }); }); @@ -52,12 +55,14 @@ describe('IssuableHeader', () => { it('dispatches `click` event on sidebar toggle button', () => { createComponent(); - wrapper.vm.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); - jest.spyOn(wrapper.vm.toggleSidebarButtonEl, 'dispatchEvent').mockImplementation(jest.fn); + const toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); + const dispatchEvent = jest + .spyOn(toggleSidebarButtonEl, 'dispatchEvent') + .mockImplementation(jest.fn); - wrapper.vm.handleRightSidebarToggleClick(); + findButton().vm.$emit('click'); - expect(wrapper.vm.toggleSidebarButtonEl.dispatchEvent).toHaveBeenCalledWith( + expect(dispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'click', }), @@ -77,7 +82,7 @@ describe('IssuableHeader', () => { expect(statusBoxEl.text()).toContain('Open'); }); - it('renders blocked icon when issuable is blocked', async () => { + it('renders blocked icon when issuable is blocked', () => { createComponent({ blocked: true, }); @@ -88,7 +93,7 @@ describe('IssuableHeader', () => { expect(blockedEl.findComponent(GlIcon).props('name')).toBe('lock'); }); - it('renders confidential icon when issuable is confidential', async () => { + it('renders confidential icon when issuable is confidential', () => { createComponent({ confidential: true, }); @@ -109,7 +114,7 @@ describe('IssuableHeader', () => { href: webUrl, target: '_blank', }; - const avatarEl = wrapper.findByTestId('avatar'); + const avatarEl = findAvatar(); expect(avatarEl.exists()).toBe(true); expect(avatarEl.attributes()).toMatchObject(avatarElAttrs); expect(avatarEl.findComponent(GlAvatarLabeled).attributes()).toMatchObject({ diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js index 6345393951c..5cdb6612487 100644 --- a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js +++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js @@ -8,7 +8,9 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { humanize } from '~/lib/utils/text_utility'; import { redirectTo } from '~/lib/utils/url_utility'; -import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; +import ManageViaMr, { + i18n, +} from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { buildConfigureSecurityFeatureMockFactory } from './apollo_mocks'; @@ -17,6 +19,7 @@ jest.mock('~/lib/utils/url_utility'); Vue.use(VueApollo); const projectFullPath = 'namespace/project'; +const ufErrorPrefix = 'Foo:'; describe('ManageViaMr component', () => { let wrapper; @@ -56,6 +59,10 @@ describe('ManageViaMr component', () => { ); } + beforeEach(() => { + gon.uf_error_prefix = ufErrorPrefix; + }); + // This component supports different report types/mutations depending on // whether it's in a CE or EE context. This makes sure we are only testing // the ones available in the current test context. @@ -72,15 +79,19 @@ describe('ManageViaMr component', () => { const buildConfigureSecurityFeatureMock = buildConfigureSecurityFeatureMockFactory( mutationId, ); - const successHandler = jest.fn(async () => buildConfigureSecurityFeatureMock()); - const noSuccessPathHandler = async () => + const successHandler = jest.fn().mockResolvedValue(buildConfigureSecurityFeatureMock()); + const noSuccessPathHandler = jest.fn().mockResolvedValue( buildConfigureSecurityFeatureMock({ successPath: '', - }); - const errorHandler = async () => - buildConfigureSecurityFeatureMock({ - errors: ['foo'], - }); + }), + ); + const errorHandler = (message = 'foo') => { + return Promise.resolve( + buildConfigureSecurityFeatureMock({ + errors: [message], + }), + ); + }; const pendingHandler = () => new Promise(() => {}); describe('when feature is configured', () => { @@ -147,9 +158,12 @@ describe('ManageViaMr component', () => { }); describe.each` - handler | message - ${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`} - ${errorHandler} | ${'foo'} + handler | message + ${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`} + ${errorHandler.bind(null, `${ufErrorPrefix} message`)} | ${'message'} + ${errorHandler.bind(null, 'Blah: message')} | ${i18n.genericErrorText} + ${errorHandler.bind(null, 'message')} | ${i18n.genericErrorText} + ${errorHandler} | ${i18n.genericErrorText} `('given an error response', ({ handler, message }) => { beforeEach(() => { const apolloProvider = createMockApolloProvider(mutation, handler); diff --git a/spec/frontend/webhooks/components/push_events_spec.js b/spec/frontend/webhooks/components/push_events_spec.js index ccb61c4049a..6889d48e904 100644 --- a/spec/frontend/webhooks/components/push_events_spec.js +++ b/spec/frontend/webhooks/components/push_events_spec.js @@ -61,7 +61,7 @@ describe('Webhook push events form editor component', () => { await nextTick(); }); - it('all_branches should be selected by default', async () => { + it('all_branches should be selected by default', () => { expect(findPushEventRulesGroup().element).toMatchSnapshot(); }); diff --git a/spec/frontend/webhooks/components/test_dropdown_spec.js b/spec/frontend/webhooks/components/test_dropdown_spec.js index 2f62ca13469..36777b0ba64 100644 --- a/spec/frontend/webhooks/components/test_dropdown_spec.js +++ b/spec/frontend/webhooks/components/test_dropdown_spec.js @@ -1,6 +1,6 @@ import { GlDisclosureDropdown } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { getByRole } from '@testing-library/dom'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + import HookTestDropdown from '~/webhooks/components/test_dropdown.vue'; const mockItems = [ @@ -14,17 +14,14 @@ describe('HookTestDropdown', () => { let wrapper; const findDisclosure = () => wrapper.findComponent(GlDisclosureDropdown); - const clickItem = (itemText) => { - const item = getByRole(wrapper.element, 'button', { name: itemText }); - item.dispatchEvent(new MouseEvent('click')); - }; const createComponent = (props) => { - wrapper = mount(HookTestDropdown, { + wrapper = mountExtended(HookTestDropdown, { propsData: { items: mockItems, ...props, }, + attachTo: document.body, }); }; @@ -55,7 +52,7 @@ describe('HookTestDropdown', () => { }); }); - clickItem(mockItems[0].text); + wrapper.findByTestId('disclosure-dropdown-item').find('a').trigger('click'); return railsEventPromise; }); diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js index dac02ee07bd..8b5663ee764 100644 --- a/spec/frontend/whats_new/utils/notification_spec.js +++ b/spec/frontend/whats_new/utils/notification_spec.js @@ -1,4 +1,5 @@ -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlWhatsNewNotification from 'test_fixtures_static/whats_new_notification.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { setNotification, getVersionDigest } from '~/whats_new/utils/notification'; @@ -12,7 +13,7 @@ describe('~/whats_new/utils/notification', () => { const getAppEl = () => wrapper.querySelector('.app'); beforeEach(() => { - loadHTMLFixture('static/whats_new_notification.html'); + setHTMLFixture(htmlWhatsNewNotification); wrapper = document.querySelector('.whats-new-notification-fixture-root'); }); diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index aef310319ab..3a84ba4bd5e 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -47,7 +47,7 @@ describe('ItemTitle', () => { expect(wrapper.emitted(eventName)).toBeDefined(); }); - it('renders only the text content from clipboard', async () => { + it('renders only the text content from clipboard', () => { const htmlContent = '<strong>bold text</strong>'; const buildClipboardData = (data = {}) => ({ clipboardData: { diff --git a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js index 2a65e91a906..a97164f9dce 100644 --- a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js @@ -1,7 +1,6 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -28,7 +27,7 @@ jest.mock('~/lib/utils/autosave'); const workItemId = workItemQueryResponse.data.workItem.id; -describe('WorkItemCommentForm', () => { +describe('Work item add note', () => { let wrapper; Vue.use(VueApollo); @@ -38,6 +37,7 @@ describe('WorkItemCommentForm', () => { let workItemResponseHandler; const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm); + const findTextarea = () => wrapper.findByTestId('note-reply-textarea'); const createComponent = async ({ mutationHandler = mutationSuccessHandler, @@ -50,7 +50,6 @@ describe('WorkItemCommentForm', () => { workItemType = 'Task', } = {}) => { workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); - if (signedIn) { window.gon.current_user_id = '1'; window.gon.current_user_avatar_url = 'avatar.png'; @@ -76,7 +75,7 @@ describe('WorkItemCommentForm', () => { }); const { id } = workItemQueryResponse.data.workItem; - wrapper = shallowMount(WorkItemAddNote, { + wrapper = shallowMountExtended(WorkItemAddNote, { apolloProvider, propsData: { workItemId: id, @@ -84,6 +83,8 @@ describe('WorkItemCommentForm', () => { queryVariables, fetchByIid, workItemType, + markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem', + autocompleteDataSources: {}, }, stubs: { WorkItemCommentLocked, @@ -93,7 +94,7 @@ describe('WorkItemCommentForm', () => { await waitForPromises(); if (isEditing) { - wrapper.findComponent(GlButton).vm.$emit('click'); + findTextarea().trigger('click'); } }; @@ -209,6 +210,38 @@ describe('WorkItemCommentForm', () => { expect(wrapper.emitted('error')).toEqual([[error]]); }); + + it('ignores errors when mutation returns additional information as errors for quick actions', async () => { + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + createNote: { + note: { + id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', + discussion: { + id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122', + notes: { + nodes: [], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + __typename: 'Note', + }, + __typename: 'CreateNotePayload', + errors: ['Commands only Removed assignee @foobar.', 'Command names ["unassign"]'], + }, + }, + }), + }); + + findCommentForm().vm.$emit('submitForm', 'updated desc'); + + await waitForPromises(); + + expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); + }); }); it('calls the global ID work item query when `fetchByIid` prop is false', async () => { diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js index 23a9f285804..147f2904761 100644 --- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js @@ -1,11 +1,23 @@ import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import waitForPromises from 'helpers/wait_for_promises'; import * as autosave from '~/lib/utils/autosave'; import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys'; +import { + STATE_OPEN, + STATE_CLOSED, + STATE_EVENT_REOPEN, + STATE_EVENT_CLOSE, +} from '~/work_items/constants'; import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { updateWorkItemMutationResponse, workItemQueryResponse } from 'jest/work_items/mock_data'; + +Vue.use(VueApollo); const draftComment = 'draft comment'; @@ -18,6 +30,8 @@ jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => ({ confirmAction: jest.fn().mockResolvedValue(true), })); +const workItemId = 'gid://gitlab/WorkItem/1'; + describe('Work item comment form component', () => { let wrapper; @@ -27,14 +41,29 @@ describe('Work item comment form component', () => { const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]'); - const createComponent = ({ isSubmitting = false, initialValue = '' } = {}) => { + const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const createComponent = ({ + isSubmitting = false, + initialValue = '', + isNewDiscussion = false, + workItemState = STATE_OPEN, + workItemType = 'Task', + mutationHandler = mutationSuccessHandler, + } = {}) => { wrapper = shallowMount(WorkItemCommentForm, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { - workItemType: 'Issue', + workItemState, + workItemId, + workItemType, ariaLabel: 'test-aria-label', autosaveKey: mockAutosaveKey, isSubmitting, initialValue, + markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem', + autocompleteDataSources: {}, + isNewDiscussion, }, provide: { fullPath: 'test-project-path', @@ -42,11 +71,11 @@ describe('Work item comment form component', () => { }); }; - it('passes correct markdown preview path to markdown editor', () => { + it('passes markdown preview path to markdown editor', () => { createComponent(); expect(findMarkdownEditor().props('renderMarkdownPath')).toBe( - '/test-project-path/preview_markdown?target_type=Issue', + '/group/project/preview_markdown?target_type=WorkItem', ); }); @@ -99,7 +128,7 @@ describe('Work item comment form component', () => { expect(findMarkdownEditor().props('value')).toBe('new comment'); }); - it('calls `updateDraft` with correct parameters', async () => { + it('calls `updateDraft` with correct parameters', () => { findMarkdownEditor().vm.$emit('input', 'new comment'); expect(autosave.updateDraft).toHaveBeenCalledWith(mockAutosaveKey, 'new comment'); @@ -161,4 +190,63 @@ describe('Work item comment form component', () => { expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]); }); + + describe('when used as a top level/is a new discussion', () => { + describe('cancel button text', () => { + it.each` + workItemState | workItemType | buttonText + ${STATE_OPEN} | ${'Task'} | ${'Close task'} + ${STATE_CLOSED} | ${'Task'} | ${'Reopen task'} + ${STATE_OPEN} | ${'Objective'} | ${'Close objective'} + ${STATE_CLOSED} | ${'Objective'} | ${'Reopen objective'} + ${STATE_OPEN} | ${'Key result'} | ${'Close key result'} + ${STATE_CLOSED} | ${'Key result'} | ${'Reopen key result'} + `( + 'is "$buttonText" when "$workItemType" state is "$workItemState"', + ({ workItemState, workItemType, buttonText }) => { + createComponent({ isNewDiscussion: true, workItemState, workItemType }); + + expect(findCancelButton().text()).toBe(buttonText); + }, + ); + }); + + describe('Close/reopen button click', () => { + it.each` + workItemState | stateEvent + ${STATE_OPEN} | ${STATE_EVENT_CLOSE} + ${STATE_CLOSED} | ${STATE_EVENT_REOPEN} + `( + 'calls mutation with "$stateEvent" when workItemState is "$workItemState"', + async ({ workItemState, stateEvent }) => { + createComponent({ isNewDiscussion: true, workItemState }); + + findCancelButton().vm.$emit('click'); + + await waitForPromises(); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemQueryResponse.data.workItem.id, + stateEvent, + }, + }); + }, + ); + + it('emits an error message when the mutation was unsuccessful', async () => { + createComponent({ + isNewDiscussion: true, + mutationHandler: jest.fn().mockRejectedValue('Error!'), + }); + findCancelButton().vm.$emit('click'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while updating the task. Please try again.'], + ]); + }); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js index 6b95da0910b..568b76150c4 100644 --- a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js @@ -41,6 +41,8 @@ describe('Work Item Discussion', () => { fetchByIid, fullPath, workItemType, + markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem', + autocompleteDataSources: {}, }, }); }; @@ -70,7 +72,7 @@ describe('Work Item Discussion', () => { expect(findToggleRepliesWidget().exists()).toBe(true); }); - it('the number of threads should be equal to the response length', async () => { + it('the number of threads should be equal to the response length', () => { expect(findAllThreads()).toHaveLength( mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes.length, ); @@ -104,7 +106,7 @@ describe('Work Item Discussion', () => { await findWorkItemAddNote().vm.$emit('replying', 'reply text'); }); - it('should show optimistic behavior when replying', async () => { + it('should show optimistic behavior when replying', () => { expect(findAllThreads()).toHaveLength(2); expect(findWorkItemNoteReplying().exists()).toBe(true); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js index b293127b6af..b406c9d843a 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js @@ -1,3 +1,4 @@ +import { GlDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -17,6 +18,10 @@ describe('Work Item Note Actions', () => { const findReplyButton = () => wrapper.findComponent(ReplyButton); const findEditButton = () => wrapper.find('[data-testid="edit-work-item-note"]'); const findEmojiButton = () => wrapper.find('[data-testid="note-emoji-button"]'); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]'); + const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]'); + const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]'); const addEmojiMutationResolver = jest.fn().mockResolvedValue({ data: { @@ -29,13 +34,19 @@ describe('Work Item Note Actions', () => { template: '<div></div>', }; - const createComponent = ({ showReply = true, showEdit = true, showAwardEmoji = true } = {}) => { + const createComponent = ({ + showReply = true, + showEdit = true, + showAwardEmoji = true, + showAssignUnassign = false, + } = {}) => { wrapper = shallowMount(WorkItemNoteActions, { propsData: { showReply, showEdit, noteId, showAwardEmoji, + showAssignUnassign, }, provide: { glFeatures: { @@ -113,4 +124,75 @@ describe('Work Item Note Actions', () => { }); }); }); + + describe('delete note', () => { + it('should display the `Delete comment` dropdown item if user has a permission to delete a note', () => { + createComponent({ + showEdit: true, + }); + + expect(findDropdown().exists()).toBe(true); + expect(findDeleteNoteButton().exists()).toBe(true); + }); + + it('should not display the `Delete comment` dropdown item if user has no permission to delete a note', () => { + createComponent({ + showEdit: false, + }); + + expect(findDropdown().exists()).toBe(true); + expect(findDeleteNoteButton().exists()).toBe(false); + }); + + it('should emit `deleteNote` event when delete note action is clicked', () => { + createComponent({ + showEdit: true, + }); + + findDeleteNoteButton().vm.$emit('click'); + + expect(wrapper.emitted('deleteNote')).toEqual([[]]); + }); + }); + + describe('copy link', () => { + beforeEach(() => { + createComponent({}); + }); + it('should display Copy link always', () => { + expect(findCopyLinkButton().exists()).toBe(true); + }); + + it('should emit `notifyCopyDone` event when copy link note action is clicked', () => { + findCopyLinkButton().vm.$emit('click'); + + expect(wrapper.emitted('notifyCopyDone')).toEqual([[]]); + }); + }); + + describe('assign/unassign to commenting user', () => { + it('should not display assign/unassign by default', () => { + createComponent(); + + expect(findAssignUnassignButton().exists()).toBe(false); + }); + + it('should display assign/unassign when the props is true', () => { + createComponent({ + showAssignUnassign: true, + }); + + expect(findAssignUnassignButton().exists()).toBe(true); + }); + + it('should emit `assignUser` event when assign note action is clicked', () => { + createComponent({ + showAssignUnassign: true, + }); + + findAssignUnassignButton().vm.$emit('click'); + + expect(wrapper.emitted('assignUser')).toEqual([[]]); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js index 8e574dc1a81..69b7c7b0828 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -1,10 +1,9 @@ -import { GlAvatarLink, GlDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import mockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { updateDraft } from '~/lib/utils/autosave'; +import { updateDraft, clearDraft } from '~/lib/utils/autosave'; import EditedAt from '~/issues/show/components/edited.vue'; import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; @@ -12,8 +11,17 @@ import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue'; import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import updateWorkItemNoteMutation from '~/work_items/graphql/notes/update_work_item_note.mutation.graphql'; -import { mockWorkItemCommentNote } from 'jest/work_items/mock_data'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { + mockAssignees, + mockWorkItemCommentNote, + updateWorkItemMutationResponse, + workItemQueryResponse, +} from 'jest/work_items/mock_data'; +import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import { mockTracking } from 'helpers/tracking_helper'; Vue.use(VueApollo); jest.mock('~/lib/utils/autosave'); @@ -22,6 +30,7 @@ describe('Work Item Note', () => { let wrapper; const updatedNoteText = '# Some title'; const updatedNoteBody = '<h1 data-sourcepos="1:1-1:12" dir="auto">Some title</h1>'; + const mockWorkItemId = workItemQueryResponse.data.workItem.id; const successHandler = jest.fn().mockResolvedValue({ data: { @@ -35,32 +44,51 @@ describe('Work Item Note', () => { }, }, }); + + const workItemResponseHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + + const updateWorkItemMutationSuccessHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponse); + const errorHandler = jest.fn().mockRejectedValue('Oops'); - const findAuthorAvatarLink = () => wrapper.findComponent(GlAvatarLink); const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem); const findNoteHeader = () => wrapper.findComponent(NoteHeader); const findNoteBody = () => wrapper.findComponent(NoteBody); const findNoteActions = () => wrapper.findComponent(NoteActions); - const findDropdown = () => wrapper.findComponent(GlDropdown); const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm); const findEditedAt = () => wrapper.findComponent(EditedAt); - - const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]'); const findNoteWrapper = () => wrapper.find('[data-testid="note-wrapper"]'); const createComponent = ({ note = mockWorkItemCommentNote, isFirstNote = false, updateNoteMutationHandler = successHandler, + workItemId = mockWorkItemId, + updateWorkItemMutationHandler = updateWorkItemMutationSuccessHandler, + assignees = mockAssignees, + queryVariables = { id: mockWorkItemId }, + fetchByIid = false, } = {}) => { wrapper = shallowMount(WorkItemNote, { propsData: { + workItemId, note, isFirstNote, workItemType: 'Task', + markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem', + autocompleteDataSources: {}, + assignees, + queryVariables, + fetchByIid, + fullPath: 'test-project-path', }, - apolloProvider: mockApollo([[updateWorkItemNoteMutation, updateNoteMutationHandler]]), + apolloProvider: mockApollo([ + [workItemQuery, workItemResponseHandler], + [updateWorkItemNoteMutation, updateNoteMutationHandler], + [updateWorkItemMutation, updateWorkItemMutationHandler], + ]), }); }; @@ -124,6 +152,7 @@ describe('Work Item Note', () => { await waitForPromises(); expect(findCommentForm().exists()).toBe(false); + expect(clearDraft).toHaveBeenCalledWith(`${mockWorkItemCommentNote.id}-comment`); }); describe('when mutation fails', () => { @@ -178,8 +207,7 @@ describe('Work Item Note', () => { }, }); - expect(findEditedAt().exists()).toBe(true); - expect(findEditedAt().props()).toEqual({ + expect(findEditedAt().props()).toMatchObject({ updatedAt: '2023-02-12T07:47:40Z', updatedByName: 'Administrator', updatedByPath: 'test-path', @@ -215,45 +243,62 @@ describe('Work Item Note', () => { expect(findNoteActions().exists()).toBe(true); }); - it('should have the Avatar link for comment threads', () => { - expect(findAuthorAvatarLink().exists()).toBe(true); - }); - it('should not have the reply button props', () => { expect(findNoteActions().props('showReply')).toBe(false); }); }); - it('should display the `Delete comment` dropdown item if user has a permission to delete a note', () => { - createComponent({ - note: { - ...mockWorkItemCommentNote, - userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true }, - }, + describe('assign/unassign to commenting user', () => { + it('calls a mutation with correct variables', async () => { + createComponent({ assignees: mockAssignees }); + await waitForPromises(); + findNoteActions().vm.$emit('assignUser'); + + await waitForPromises(); + + expect(updateWorkItemMutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: mockWorkItemId, + assigneesWidget: { + assigneeIds: [mockAssignees[1].id], + }, + }, + }); }); - expect(findDropdown().exists()).toBe(true); - expect(findDeleteNoteButton().exists()).toBe(true); - }); + it('emits an error and resets assignees if mutation was rejected', async () => { + createComponent({ + updateWorkItemMutationHandler: errorHandler, + assignees: [mockAssignees[0]], + }); - it('should not display the `Delete comment` dropdown item if user has no permission to delete a note', () => { - createComponent(); + await waitForPromises(); - expect(findDropdown().exists()).toBe(true); - expect(findDeleteNoteButton().exists()).toBe(false); - }); + expect(findNoteActions().props('isAuthorAnAssignee')).toEqual(true); - it('should emit `deleteNote` event when delete note action is clicked', () => { - createComponent({ - note: { - ...mockWorkItemCommentNote, - userPermissions: { ...mockWorkItemCommentNote.userPermissions, adminNote: true }, - }, + findNoteActions().vm.$emit('assignUser'); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + expect(findNoteActions().props('isAuthorAnAssignee')).toEqual(true); }); - findDeleteNoteButton().vm.$emit('click'); + it('tracks the event', async () => { + createComponent(); + await waitForPromises(); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findNoteActions().vm.$emit('assignUser'); - expect(wrapper.emitted('deleteNote')).toEqual([[]]); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'unassigned_user', { + category: TRACKING_CATEGORY_SHOW, + label: 'work_item_note_actions', + property: 'type_Task', + }); + }); }); }); }); diff --git a/spec/frontend/work_items/components/widget_wrapper_spec.js b/spec/frontend/work_items/components/widget_wrapper_spec.js index a87233300fc..87fbd1b3830 100644 --- a/spec/frontend/work_items/components/widget_wrapper_spec.js +++ b/spec/frontend/work_items/components/widget_wrapper_spec.js @@ -30,7 +30,7 @@ describe('WidgetWrapper component', () => { expect(findWidgetBody().exists()).toBe(false); }); - it('shows alert when list loading fails', () => { + it('shows an alert when list loading fails', () => { const error = 'Some error'; createComponent({ error }); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index a0db8172bf6..a5006b46063 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -1,17 +1,35 @@ -import { GlDropdownDivider, GlModal } from '@gitlab/ui'; +import { GlDropdownDivider, GlModal, GlToggle } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import toast from '~/vue_shared/plugins/global_toast'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; +import { + TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, + TEST_ID_NOTIFICATIONS_TOGGLE_ACTION, + TEST_ID_NOTIFICATIONS_TOGGLE_FORM, + TEST_ID_DELETE_ACTION, +} from '~/work_items/constants'; +import updateWorkItemNotificationsMutation from '~/work_items/graphql/update_work_item_notifications.mutation.graphql'; +import { workItemResponseFactory } from '../mock_data'; -const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action'; -const TEST_ID_DELETE_ACTION = 'delete-action'; +jest.mock('~/lib/utils/common_utils'); +jest.mock('~/vue_shared/plugins/global_toast'); describe('WorkItemActions component', () => { + Vue.use(VueApollo); + let wrapper; let glModalDirective; const findModal = () => wrapper.findComponent(GlModal); const findConfidentialityToggleButton = () => wrapper.findByTestId(TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION); + const findNotificationsToggleButton = () => + wrapper.findByTestId(TEST_ID_NOTIFICATIONS_TOGGLE_ACTION); const findDeleteButton = () => wrapper.findByTestId(TEST_ID_DELETE_ACTION); const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *'); const findDropdownItemsActual = () => @@ -25,20 +43,27 @@ describe('WorkItemActions component', () => { text: x.text(), }; }); + const findNotificationsToggle = () => wrapper.findComponent(GlToggle); const createComponent = ({ canUpdate = true, canDelete = true, isConfidential = false, + subscribed = false, isParentConfidential = false, + notificationsMock = [updateWorkItemNotificationsMutation, jest.fn()], } = {}) => { + const handlers = [notificationsMock]; glModalDirective = jest.fn(); wrapper = shallowMountExtended(WorkItemActions, { + apolloProvider: createMockApollo(handlers), + isLoggedIn: isLoggedIn(), propsData: { - workItemId: '123', + workItemId: 'gid://gitlab/WorkItem/1', canUpdate, canDelete, isConfidential, + subscribed, isParentConfidential, workItemType: 'Task', }, @@ -52,6 +77,10 @@ describe('WorkItemActions component', () => { }); }; + beforeEach(() => { + isLoggedIn.mockReturnValue(true); + }); + it('renders modal', () => { createComponent(); @@ -64,6 +93,13 @@ describe('WorkItemActions component', () => { expect(findDropdownItemsActual()).toEqual([ { + testId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM, + text: '', + }, + { + divider: true, + }, + { testId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, text: 'Turn on confidentiality', }, @@ -133,7 +169,110 @@ describe('WorkItemActions component', () => { }); expect(findDeleteButton().exists()).toBe(false); - expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(false); + }); + }); + + describe('notifications action', () => { + const errorMessage = 'Failed to subscribe'; + const notificationToggledOffMessage = 'Notifications turned off.'; + const notificationToggledOnMessage = 'Notifications turned on.'; + + const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); + const inputVariablesOff = { + id: workItemQueryResponse.data.workItem.id, + notificationsWidget: { + subscribed: false, + }, + }; + + const inputVariablesOn = { + id: workItemQueryResponse.data.workItem.id, + notificationsWidget: { + subscribed: true, + }, + }; + + const notificationsOffExpectedResponse = workItemResponseFactory({ + subscribed: false, + }); + + const toggleNotificationsOffHandler = jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: notificationsOffExpectedResponse.data.workItem, + errors: [], + }, + }, + }); + + const notificationsOnExpectedResponse = workItemResponseFactory({ + subscribed: true, + }); + + const toggleNotificationsOnHandler = jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: notificationsOnExpectedResponse.data.workItem, + errors: [], + }, + }, + }); + + const toggleNotificationsFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); + + const notificationsOffMock = [ + updateWorkItemNotificationsMutation, + toggleNotificationsOffHandler, + ]; + + const notificationsOnMock = [updateWorkItemNotificationsMutation, toggleNotificationsOnHandler]; + + const notificationsFailureMock = [ + updateWorkItemNotificationsMutation, + toggleNotificationsFailureHandler, + ]; + + beforeEach(() => { + createComponent(); + isLoggedIn.mockReturnValue(true); + }); + + it('renders toggle button', () => { + expect(findNotificationsToggleButton().exists()).toBe(true); + }); + + it.each` + scenario | subscribedToNotifications | notificationsMock | inputVariables | toastMessage + ${'turned off'} | ${false} | ${notificationsOffMock} | ${inputVariablesOff} | ${notificationToggledOffMessage} + ${'turned on'} | ${true} | ${notificationsOnMock} | ${inputVariablesOn} | ${notificationToggledOnMessage} + `( + 'calls mutation and displays toast when notification toggle is $scenario', + async ({ subscribedToNotifications, notificationsMock, inputVariables, toastMessage }) => { + createComponent({ notificationsMock }); + + await waitForPromises(); + + findNotificationsToggle().vm.$emit('change', subscribedToNotifications); + + await waitForPromises(); + + expect(notificationsMock[1]).toHaveBeenCalledWith({ + input: inputVariables, + }); + expect(toast).toHaveBeenCalledWith(toastMessage); + }, + ); + + it('emits error when the update notification mutation fails', async () => { + createComponent({ notificationsMock: notificationsFailureMock }); + + await waitForPromises(); + + findNotificationsToggle().vm.$emit('change', false); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[errorMessage]]); }); }); }); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 2a8159f7294..af97b3680f9 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -318,7 +318,7 @@ describe('WorkItemAssignees component', () => { return waitForPromises(); }); - it('renders `Assign myself` button', async () => { + it('renders `Assign myself` button', () => { findTokenSelector().trigger('mouseover'); expect(findAssignSelfButton().exists()).toBe(true); }); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index b4b7b8989ea..099c45ac683 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -116,10 +116,7 @@ describe('WorkItemDescription', () => { supportsQuickActions: true, renderMarkdownPath: markdownPreviewPath(fullPath, iid), quickActionsDocsPath: wrapper.vm.$options.quickActionsDocsPath, - }); - - expect(findMarkdownEditor().vm.$attrs).toMatchObject({ - 'autocomplete-data-sources': autocompleteDataSources(fullPath, iid), + autocompleteDataSources: autocompleteDataSources(fullPath, iid), }); }); }); @@ -179,7 +176,7 @@ describe('WorkItemDescription', () => { }), }); - expect(findEditedAt().props()).toEqual({ + expect(findEditedAt().props()).toMatchObject({ updatedAt: lastEditedAt, updatedByName: lastEditedBy.name, updatedByPath: lastEditedBy.webPath, diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index fe7556f8ec6..8e5b607cee7 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -29,12 +29,13 @@ import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal. import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql'; +import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql'; import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; + import { mockParent, workItemDatesSubscriptionResponse, @@ -337,7 +338,7 @@ describe('WorkItemDetail component', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('shows alert message when mutation fails', async () => { + it('shows an alert when mutation fails', async () => { createComponent({ handler: handlerMock, confidentialityMock: confidentialityFailureMock, @@ -388,11 +389,12 @@ describe('WorkItemDetail component', () => { expect(findParent().exists()).toBe(false); }); - it('shows work item type if there is not a parent', async () => { + it('shows work item type with reference when there is no a parent', async () => { createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) }); await waitForPromises(); expect(findWorkItemType().exists()).toBe(true); + expect(findWorkItemType().text()).toBe('Task #1'); }); describe('with parent', () => { @@ -407,7 +409,7 @@ describe('WorkItemDetail component', () => { expect(findParent().exists()).toBe(true); }); - it('does not show work item type', async () => { + it('does not show work item type', () => { expect(findWorkItemType().exists()).toBe(false); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js index e693ccfb156..07efb1c5ac8 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js @@ -1,4 +1,4 @@ -import { GlLabel, GlAvatarsInline } from '@gitlab/ui'; +import { GlAvatarsInline } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -8,10 +8,9 @@ import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/w import { workItemObjectiveMetadataWidgets } from '../../mock_data'; describe('WorkItemLinkChildMetadata', () => { - const { MILESTONE, ASSIGNEES, LABELS } = workItemObjectiveMetadataWidgets; + const { MILESTONE, ASSIGNEES } = workItemObjectiveMetadataWidgets; const mockMilestone = MILESTONE.milestone; const mockAssignees = ASSIGNEES.assignees.nodes; - const mockLabels = LABELS.labels.nodes; let wrapper; const createComponent = ({ metadataWidgets = workItemObjectiveMetadataWidgets } = {}) => { @@ -53,18 +52,4 @@ describe('WorkItemLinkChildMetadata', () => { badgeSrOnlyText: '', }); }); - - it('renders labels', () => { - const labels = wrapper.findAllComponents(GlLabel); - const mockLabel = mockLabels[0]; - - expect(labels).toHaveLength(mockLabels.length); - expect(labels.at(0).props()).toMatchObject({ - title: mockLabel.title, - backgroundColor: mockLabel.color, - description: mockLabel.description, - scoped: false, - }); - expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped - }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js index 721436e217e..106f9d46513 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js @@ -1,4 +1,4 @@ -import { GlIcon } from '@gitlab/ui'; +import { GlLabel, GlIcon } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -11,6 +11,7 @@ import { createAlert } from '~/alert'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; import WorkItemTreeChildren from '~/work_items/components/work_item_links/work_item_tree_children.vue'; @@ -29,6 +30,8 @@ import { workItemHierarchyTreeResponse, workItemHierarchyTreeFailureResponse, workItemObjectiveMetadataWidgets, + changeIndirectWorkItemParentMutationResponse, + workItemUpdateFailureResponse, } from '../../mock_data'; jest.mock('~/alert'); @@ -37,6 +40,14 @@ describe('WorkItemLinkChild', () => { const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; let wrapper; let getWorkItemTreeQueryHandler; + let mutationChangeParentHandler; + const { LABELS } = workItemObjectiveMetadataWidgets; + const mockLabels = LABELS.labels.nodes; + + const $toast = { + show: jest.fn(), + hide: jest.fn(), + }; Vue.use(VueApollo); @@ -49,10 +60,17 @@ describe('WorkItemLinkChild', () => { apolloProvider = null, } = {}) => { getWorkItemTreeQueryHandler = jest.fn().mockResolvedValue(workItemHierarchyTreeResponse); + mutationChangeParentHandler = jest + .fn() + .mockResolvedValue(changeIndirectWorkItemParentMutationResponse); wrapper = shallowMountExtended(WorkItemLinkChild, { apolloProvider: - apolloProvider || createMockApollo([[getWorkItemTreeQuery, getWorkItemTreeQueryHandler]]), + apolloProvider || + createMockApollo([ + [getWorkItemTreeQuery, getWorkItemTreeQueryHandler], + [updateWorkItemMutation, mutationChangeParentHandler], + ]), propsData: { projectPath, canUpdate, @@ -60,6 +78,9 @@ describe('WorkItemLinkChild', () => { childItem, workItemType, }, + mocks: { + $toast, + }, }); }; @@ -165,8 +186,6 @@ describe('WorkItemLinkChild', () => { expect(metadataEl.props()).toMatchObject({ metadataWidgets: workItemObjectiveMetadataWidgets, }); - - expect(wrapper.find('[data-testid="links-child"]').classes()).toContain('gl-py-3'); }); it('does not render item metadata component when item has no metadata present', () => { @@ -176,8 +195,20 @@ describe('WorkItemLinkChild', () => { }); expect(findMetadataComponent().exists()).toBe(false); + }); + + it('renders labels', () => { + const labels = wrapper.findAllComponents(GlLabel); + const mockLabel = mockLabels[0]; - expect(wrapper.find('[data-testid="links-child"]').classes()).toContain('gl-py-0'); + expect(labels).toHaveLength(mockLabels.length); + expect(labels.at(0).props()).toMatchObject({ + title: mockLabel.title, + backgroundColor: mockLabel.color, + description: mockLabel.description, + scoped: false, + }); + expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped }); }); @@ -216,6 +247,13 @@ describe('WorkItemLinkChild', () => { const findExpandButton = () => wrapper.findByTestId('expand-child'); const findTreeChildren = () => wrapper.findComponent(WorkItemTreeChildren); + const getWidgetHierarchy = () => + workItemHierarchyTreeResponse.data.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + const getChildrenNodes = () => getWidgetHierarchy().children.nodes; + const findFirstItemId = () => getChildrenNodes()[0].id; + beforeEach(() => { getWorkItemTreeQueryHandler.mockClear(); createComponent({ @@ -238,10 +276,8 @@ describe('WorkItemLinkChild', () => { expect(getWorkItemTreeQueryHandler).toHaveBeenCalled(); expect(findTreeChildren().exists()).toBe(true); - const widgetHierarchy = workItemHierarchyTreeResponse.data.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_HIERARCHY, - ); - expect(findTreeChildren().props('children')).toEqual(widgetHierarchy.children.nodes); + const childrenNodes = getChildrenNodes(); + expect(findTreeChildren().props('children')).toEqual(childrenNodes); }); it('does not fetch children if already fetched once while clicking expand button', async () => { @@ -290,5 +326,74 @@ describe('WorkItemLinkChild', () => { expect(wrapper.emitted('click')).toEqual([['event']]); }); + + it('shows toast on removing child item', async () => { + findExpandButton().vm.$emit('click'); + await waitForPromises(); + + findTreeChildren().vm.$emit('removeChild', findFirstItemId()); + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Child removed', { + action: { onClick: expect.any(Function), text: 'Undo' }, + }); + }); + + it('renders correct number of children after the removal', async () => { + findExpandButton().vm.$emit('click'); + await waitForPromises(); + + const childrenNodes = getChildrenNodes(); + expect(findTreeChildren().props('children')).toEqual(childrenNodes); + + findTreeChildren().vm.$emit('removeChild', findFirstItemId()); + await waitForPromises(); + + expect(findTreeChildren().props('children')).toEqual([]); + }); + + it('calls correct mutation with correct variables', async () => { + const firstItemId = findFirstItemId(); + + findExpandButton().vm.$emit('click'); + await waitForPromises(); + + findTreeChildren().vm.$emit('removeChild', firstItemId); + + expect(mutationChangeParentHandler).toHaveBeenCalledWith({ + input: { + id: firstItemId, + hierarchyWidget: { + parentId: null, + }, + }, + }); + }); + + it('shows the alert when workItem update fails', async () => { + mutationChangeParentHandler = jest.fn().mockRejectedValue(workItemUpdateFailureResponse); + const apolloProvider = createMockApollo([ + [getWorkItemTreeQuery, getWorkItemTreeQueryHandler], + [updateWorkItemMutation, mutationChangeParentHandler], + ]); + + createComponent({ + childItem: workItemObjectiveWithChild, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + apolloProvider, + }); + + findExpandButton().vm.$emit('click'); + await waitForPromises(); + + findTreeChildren().vm.$emit('removeChild', findFirstItemId()); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.any(Object), + message: 'Something went wrong while removing child.', + }); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js index 4e53fc2987b..f02a9fbd021 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js @@ -13,7 +13,7 @@ describe('WorkItemLinksMenu', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findRemoveDropdownItem = () => wrapper.findComponent(GlDropdownItem); - beforeEach(async () => { + beforeEach(() => { createComponent(); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 99e44b4d89c..e97c2328b83 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -179,7 +179,7 @@ describe('WorkItemLinks', () => { expect(findWorkItemLinkChildItems()).toHaveLength(4); }); - it('shows alert when list loading fails', async () => { + it('shows an alert when list loading fails', async () => { const errorMessage = 'Some error'; await createComponent({ fetchHandler: jest.fn().mockRejectedValue(new Error(errorMessage)), diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js index a067923b9fc..3cc6a9813fc 100644 --- a/spec/frontend/work_items/components/work_item_notes_spec.js +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -18,6 +18,7 @@ import workItemNoteUpdatedSubscription from '~/work_items/graphql/notes/work_ite import workItemNoteDeletedSubscription from '~/work_items/graphql/notes/work_item_note_deleted.subscription.graphql'; import { DEFAULT_PAGE_SIZE_NOTES, WIDGET_TYPE_NOTES } from '~/work_items/constants'; import { ASC, DESC } from '~/notes/constants'; +import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils'; import { mockWorkItemNotesResponse, workItemQueryResponse, @@ -30,6 +31,7 @@ import { } from '../mock_data'; const mockWorkItemId = workItemQueryResponse.data.workItem.id; +const mockWorkItemIid = workItemQueryResponse.data.workItem.iid; const mockNotesWidgetResponse = mockWorkItemNotesResponse.data.workItem.widgets.find( (widget) => widget.type === WIDGET_TYPE_NOTES, ); @@ -92,6 +94,7 @@ describe('WorkItemNotes component', () => { const createComponent = ({ workItemId = mockWorkItemId, fetchByIid = false, + workItemIid = mockWorkItemIid, defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler, deleteWINoteMutationHandler = deleteWorkItemNoteMutationSuccessHandler, } = {}) => { @@ -106,6 +109,7 @@ describe('WorkItemNotes component', () => { ]), propsData: { workItemId, + workItemIid, queryVariables: { id: workItemId, }, @@ -119,7 +123,7 @@ describe('WorkItemNotes component', () => { }); }; - beforeEach(async () => { + beforeEach(() => { createComponent(); }); @@ -258,9 +262,11 @@ describe('WorkItemNotes component', () => { const commentIndex = 0; const firstCommentNote = findWorkItemCommentNoteAtIndex(commentIndex); - expect(firstCommentNote.props('discussion')).toEqual( - mockDiscussions[commentIndex].notes.nodes, - ); + expect(firstCommentNote.props()).toMatchObject({ + discussion: mockDiscussions[commentIndex].notes.nodes, + autocompleteDataSources: autocompleteDataSources('test-path', mockWorkItemIid), + markdownPreviewPath: markdownPreviewPath('test-path', mockWorkItemIid), + }); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index fecf98b2651..c3376556d6e 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -82,6 +82,7 @@ export const workItemQueryResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + setWorkItemMetadata: false, __typename: 'WorkItemPermissions', }, widgets: [ @@ -183,6 +184,7 @@ export const updateWorkItemMutationResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + setWorkItemMetadata: false, __typename: 'WorkItemPermissions', }, widgets: [ @@ -286,6 +288,8 @@ export const objectiveType = { export const workItemResponseFactory = ({ canUpdate = false, canDelete = false, + notificationsWidgetPresent = true, + subscribed = true, allowsMultipleAssignees = true, assigneesWidgetPresent = true, datesWidgetPresent = true, @@ -313,7 +317,7 @@ export const workItemResponseFactory = ({ workItem: { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', - iid: 1, + iid: '1', title: 'Updated title', state: 'OPEN', description: 'description', @@ -332,6 +336,7 @@ export const workItemResponseFactory = ({ userPermissions: { deleteWorkItem: canDelete, updateWorkItem: canUpdate, + setWorkItemMetadata: canUpdate, __typename: 'WorkItemPermissions', }, widgets: [ @@ -469,6 +474,13 @@ export const workItemResponseFactory = ({ type: 'NOTES', } : { type: 'MOCK TYPE' }, + notificationsWidgetPresent + ? { + __typename: 'WorkItemWidgetNotifications', + type: 'NOTIFICATIONS', + subscribed, + } + : { type: 'MOCK TYPE' }, ], }, }, @@ -542,6 +554,7 @@ export const createWorkItemMutationResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + setWorkItemMetadata: false, __typename: 'WorkItemPermissions', }, widgets: [], @@ -591,6 +604,7 @@ export const createWorkItemFromTaskMutationResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + setWorkItemMetadata: false, __typename: 'WorkItemPermissions', }, widgets: [ @@ -632,6 +646,7 @@ export const createWorkItemFromTaskMutationResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + setWorkItemMetadata: false, __typename: 'WorkItemPermissions', }, widgets: [], @@ -834,7 +849,7 @@ export const workItemHierarchyEmptyResponse = { data: { workItem: { id: 'gid://gitlab/WorkItem/1', - iid: 1, + iid: '1', state: 'OPEN', workItemType: { id: 'gid://gitlab/WorkItems::Type/1', @@ -857,6 +872,7 @@ export const workItemHierarchyEmptyResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + setWorkItemMetadata: false, __typename: 'WorkItemPermissions', }, confidential: false, @@ -881,7 +897,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { data: { workItem: { id: 'gid://gitlab/WorkItem/1', - iid: 1, + iid: '1', state: 'OPEN', workItemType: { id: 'gid://gitlab/WorkItems::Type/6', @@ -898,6 +914,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { userPermissions: { deleteWorkItem: false, updateWorkItem: false, + setWorkItemMetadata: false, __typename: 'WorkItemPermissions', }, project: { @@ -1039,6 +1056,7 @@ export const workItemHierarchyResponse = { userPermissions: { deleteWorkItem: true, updateWorkItem: true, + setWorkItemMetadata: true, __typename: 'WorkItemPermissions', }, author: { @@ -1128,6 +1146,7 @@ export const workItemObjectiveWithChild = { userPermissions: { deleteWorkItem: true, updateWorkItem: true, + setWorkItemMetadata: true, __typename: 'WorkItemPermissions', }, author: { @@ -1195,6 +1214,7 @@ export const workItemHierarchyTreeResponse = { userPermissions: { deleteWorkItem: true, updateWorkItem: true, + setWorkItemMetadata: true, __typename: 'WorkItemPermissions', }, confidential: false, @@ -1258,6 +1278,68 @@ export const workItemHierarchyTreeFailureResponse = { ], }; +export const changeIndirectWorkItemParentMutationResponse = { + data: { + workItemUpdate: { + workItem: { + __typename: 'WorkItem', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', + __typename: 'WorkItemType', + }, + userPermissions: { + deleteWorkItem: true, + updateWorkItem: true, + setWorkItemMetadata: true, + __typename: 'WorkItemPermissions', + }, + description: null, + id: 'gid://gitlab/WorkItem/13', + iid: '13', + state: 'OPEN', + title: 'Objective 2', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + updatedAt: null, + closedAt: null, + author: { + ...mockAssignees[0], + }, + project: { + __typename: 'Project', + id: '1', + fullPath: 'test-project-path', + archived: false, + }, + widgets: [ + { + __typename: 'WorkItemWidgetHierarchy', + type: 'HIERARCHY', + parent: null, + hasChildren: false, + children: { + nodes: [], + }, + }, + ], + }, + errors: [], + __typename: 'WorkItemUpdatePayload', + }, + }, +}; + +export const workItemUpdateFailureResponse = { + data: {}, + errors: [ + { + message: 'Something went wrong', + }, + ], +}; + export const changeWorkItemParentMutationResponse = { data: { workItemUpdate: { @@ -1272,6 +1354,7 @@ export const changeWorkItemParentMutationResponse = { userPermissions: { deleteWorkItem: true, updateWorkItem: true, + setWorkItemMetadata: true, __typename: 'WorkItemPermissions', }, description: null, diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 37326910e13..c480affe484 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -75,7 +75,7 @@ describe('Work items root component', () => { expect(visitUrl).toHaveBeenCalledWith(issuesListPath); }); - it('shows alert if delete fails', async () => { + it('shows an alert if delete fails', async () => { const deleteWorkItemHandler = jest.fn().mockRejectedValue(deleteWorkItemFailureResponse); createComponent({ diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 5dad7f7c43f..bd75c5be6f1 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -13,7 +13,7 @@ import { } from 'jest/work_items/mock_data'; import App from '~/work_items/components/app.vue'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; -import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql'; +import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql'; import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; |