diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-18 19:00:14 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-18 19:00:14 +0000 |
commit | 05f0ebba3a2c8ddf39e436f412dc2ab5bf1353b2 (patch) | |
tree | 11d0f2a6ec31c7793c184106cedc2ded3d9a2cc5 /spec/frontend | |
parent | ec73467c23693d0db63a797d10194da9e72a74af (diff) | |
download | gitlab-ce-05f0ebba3a2c8ddf39e436f412dc2ab5bf1353b2.tar.gz |
Add latest changes from gitlab-org/gitlab@15-8-stable-eev15.8.0-rc42
Diffstat (limited to 'spec/frontend')
305 files changed, 8859 insertions, 3618 deletions
diff --git a/spec/frontend/__mocks__/@cubejs-client/core.js b/spec/frontend/__mocks__/@cubejs-client/core.js new file mode 100644 index 00000000000..549899aa8d8 --- /dev/null +++ b/spec/frontend/__mocks__/@cubejs-client/core.js @@ -0,0 +1,26 @@ +let mockLoad = jest.fn(); +let mockMetadata = jest.fn(); + +export const CubejsApi = jest.fn().mockImplementation(() => ({ + load: mockLoad, + meta: mockMetadata, +})); + +export const HttpTransport = jest.fn(); + +export const GRANULARITIES = [ + { + name: 'seconds', + title: 'Seconds', + }, +]; + +// eslint-disable-next-line no-underscore-dangle +export const __setMockLoad = (x) => { + mockLoad = x; +}; + +// eslint-disable-next-line no-underscore-dangle +export const __setMockMetadata = (x) => { + mockMetadata = x; +}; diff --git a/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js b/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js new file mode 100644 index 00000000000..6efd9fb1dd0 --- /dev/null +++ b/spec/frontend/abuse_reports/components/abuse_category_selector_spec.js @@ -0,0 +1,126 @@ +import { GlDrawer, GlForm, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +jest.mock('~/lib/utils/common_utils', () => ({ + contentTop: jest.fn(), +})); + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('AbuseCategorySelector', () => { + let wrapper; + + const ACTION_PATH = '/abuse_reports/add_category'; + const USER_ID = '1'; + const REPORTED_FROM_URL = 'http://example.com'; + + const createComponent = (props) => { + wrapper = shallowMountExtended(AbuseCategorySelector, { + propsData: { + ...props, + }, + provide: { + reportAbusePath: ACTION_PATH, + reportedUserId: USER_ID, + reportedFromUrl: REPORTED_FROM_URL, + }, + }); + }; + + beforeEach(() => { + createComponent({ showDrawer: true }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDrawer = () => wrapper.findComponent(GlDrawer); + const findTitle = () => wrapper.findByTestId('category-drawer-title'); + + const findForm = () => wrapper.findComponent(GlForm); + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + + const findCSRFToken = () => findForm().find('input[name="authenticity_token"]'); + const findUserId = () => wrapper.findByTestId('input-user-id'); + const findReferer = () => wrapper.findByTestId('input-referer'); + + const findSubmitFormButton = () => wrapper.findByTestId('submit-form-button'); + + describe('Drawer', () => { + it('is open when prop showDrawer = true', () => { + expect(findDrawer().exists()).toBe(true); + expect(findDrawer().props('open')).toBe(true); + expect(findDrawer().props('zIndex')).toBe(300); + }); + + it('renders title', () => { + expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title); + }); + + it('emits close-drawer event', async () => { + await findDrawer().vm.$emit('close'); + + expect(wrapper.emitted('close-drawer')).toHaveLength(1); + }); + + describe('when props showDrawer = false', () => { + beforeEach(() => { + createComponent({ showDrawer: false }); + }); + + it('hides the drawer', () => { + expect(findDrawer().props('open')).toBe(false); + }); + }); + }); + + describe('Select category form', () => { + it('renders POST form with path', () => { + expect(findForm().attributes()).toMatchObject({ + method: 'post', + action: ACTION_PATH, + }); + }); + + it('renders csrf token', () => { + expect(findCSRFToken().attributes('value')).toBe('mock-csrf-token'); + }); + + it('renders label', () => { + expect(findFormGroup().exists()).toBe(true); + expect(findFormGroup().attributes('label')).toBe(wrapper.vm.$options.i18n.label); + }); + + it('renders radio group', () => { + expect(findRadioGroup().exists()).toBe(true); + expect(findRadioGroup().props('options')).toEqual(wrapper.vm.$options.categoryOptions); + expect(findRadioGroup().attributes('name')).toBe('abuse_report[category]'); + expect(findRadioGroup().attributes('required')).not.toBeUndefined(); + }); + + it('renders userId as a hidden fields', () => { + expect(findUserId().attributes()).toMatchObject({ + type: 'hidden', + name: 'user_id', + value: USER_ID, + }); + }); + + it('renders referer as a hidden fields', () => { + expect(findReferer().attributes()).toMatchObject({ + type: 'hidden', + name: 'abuse_report[reported_from_url]', + value: REPORTED_FROM_URL, + }); + }); + + it('renders submit button', () => { + expect(findSubmitFormButton().exists()).toBe(true); + expect(findSubmitFormButton().text()).toBe(wrapper.vm.$options.i18n.next); + }); + }); +}); diff --git a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js index 88ea79f38b3..36c0ac303ba 100644 --- a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js +++ b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js @@ -3,7 +3,7 @@ import { GlBroadcastMessage, GlForm } from '@gitlab/ui'; import AxiosMockAdapter from 'axios-mock-adapter'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_BAD_REQUEST } from '~/lib/utils/http_status'; import MessageForm from '~/admin/broadcast_messages/components/message_form.vue'; import { BROADCAST_MESSAGES_PATH, @@ -160,7 +160,7 @@ describe('MessageForm', () => { it('shows an error alert if the create request fails', async () => { createComponent({ broadcastMessage: { id: undefined } }); - axiosMock.onPost(BROADCAST_MESSAGES_PATH).replyOnce(httpStatus.BAD_REQUEST); + axiosMock.onPost(BROADCAST_MESSAGES_PATH).replyOnce(HTTP_STATUS_BAD_REQUEST); findForm().vm.$emit('submit', { preventDefault: () => {} }); await waitForPromises(); @@ -187,7 +187,7 @@ describe('MessageForm', () => { it('shows an error alert if the update request fails', async () => { const id = 1337; createComponent({ broadcastMessage: { id } }); - axiosMock.onPost(`${BROADCAST_MESSAGES_PATH}/${id}`).replyOnce(httpStatus.BAD_REQUEST); + axiosMock.onPost(`${BROADCAST_MESSAGES_PATH}/${id}`).replyOnce(HTTP_STATUS_BAD_REQUEST); findForm().vm.$emit('submit', { preventDefault: () => {} }); await waitForPromises(); diff --git a/spec/frontend/admin/users/components/user_date_spec.js b/spec/frontend/admin/users/components/user_date_spec.js index af262c6d3f0..73be33d5a9d 100644 --- a/spec/frontend/admin/users/components/user_date_spec.js +++ b/spec/frontend/admin/users/components/user_date_spec.js @@ -24,7 +24,7 @@ describe('FormatDate component', () => { it.each` date | dateFormat | output - ${mockDate} | ${undefined} | ${'13 Nov, 2020'} + ${mockDate} | ${undefined} | ${'Nov 13, 2020'} ${null} | ${undefined} | ${'Never'} ${undefined} | ${undefined} | ${'Never'} ${mockDate} | ${ISO_SHORT_FORMAT} | ${'2020-11-13'} diff --git a/spec/frontend/admin/users/mock_data.js b/spec/frontend/admin/users/mock_data.js index 193ac3fa043..17cddebfcaf 100644 --- a/spec/frontend/admin/users/mock_data.js +++ b/spec/frontend/admin/users/mock_data.js @@ -62,3 +62,11 @@ export const userDeletionObstacles = [ { name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules }, { name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies }, ]; + +export const userStatus = { + emoji: 'basketball', + message: 'test', + availability: 'busy', + message_html: 'test', + clear_status_at: '2023-01-04T10:00:00.000Z', +}; 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 62a3e07186a..a15c78cc456 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -32,7 +32,7 @@ import { } from '~/alerts_settings/utils/error_messages'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { HTTP_STATUS_FORBIDDEN, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { createHttpVariables, updateHttpVariables, @@ -365,7 +365,7 @@ describe('AlertsSettingsWrapper', () => { }); it('shows an error alert when integration is not activated', async () => { - mock.onPost(/(.*)/).replyOnce(httpStatusCodes.FORBIDDEN); + mock.onPost(/(.*)/).replyOnce(HTTP_STATUS_FORBIDDEN); await wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }); expect(createAlert).toHaveBeenCalledWith({ message: INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR, diff --git a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js index f87807804c9..3030fca126b 100644 --- a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/analytics/cycle_analytics/store/actions'; import * as getters from '~/analytics/cycle_analytics/store/getters'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { allowedStages, selectedStage, @@ -197,7 +197,7 @@ describe('Project Value Stream Analytics actions', () => { selectedStage, }; mock = new MockAdapter(axios); - mock.onGet(mockStagePath).reply(httpStatusCodes.OK, reviewEvents, headers); + mock.onGet(mockStagePath).reply(HTTP_STATUS_OK, reviewEvents, headers); }); it(`commits the 'RECEIVE_STAGE_DATA_SUCCESS' mutation`, () => @@ -223,7 +223,7 @@ describe('Project Value Stream Analytics actions', () => { selectedStage, }; mock = new MockAdapter(axios); - mock.onGet(mockStagePath).reply(httpStatusCodes.OK, { error: tooMuchDataError }); + mock.onGet(mockStagePath).reply(HTTP_STATUS_OK, { error: tooMuchDataError }); }); it(`commits the 'RECEIVE_STAGE_DATA_ERROR' mutation`, () => @@ -247,7 +247,7 @@ describe('Project Value Stream Analytics actions', () => { selectedStage, }; mock = new MockAdapter(axios); - mock.onGet(mockStagePath).reply(httpStatusCodes.BAD_REQUEST); + mock.onGet(mockStagePath).reply(HTTP_STATUS_BAD_REQUEST); }); it(`commits the 'RECEIVE_STAGE_DATA_ERROR' mutation`, () => @@ -269,7 +269,7 @@ describe('Project Value Stream Analytics actions', () => { endpoints: mockEndpoints, }; mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + mock.onGet(mockValueStreamPath).reply(HTTP_STATUS_OK); }); it(`commits the 'REQUEST_VALUE_STREAMS' mutation`, () => @@ -284,7 +284,7 @@ describe('Project Value Stream Analytics actions', () => { describe('with a failing request', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + mock.onGet(mockValueStreamPath).reply(HTTP_STATUS_BAD_REQUEST); }); it(`commits the 'RECEIVE_VALUE_STREAMS_ERROR' mutation`, () => @@ -294,7 +294,7 @@ describe('Project Value Stream Analytics actions', () => { payload: {}, expectedMutations: [ { type: 'REQUEST_VALUE_STREAMS' }, - { type: 'RECEIVE_VALUE_STREAMS_ERROR', payload: httpStatusCodes.BAD_REQUEST }, + { type: 'RECEIVE_VALUE_STREAMS_ERROR', payload: HTTP_STATUS_BAD_REQUEST }, ], expectedActions: [], })); @@ -337,7 +337,7 @@ describe('Project Value Stream Analytics actions', () => { selectedValueStream, }; mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + mock.onGet(mockValueStreamPath).reply(HTTP_STATUS_OK); }); it(`commits the 'REQUEST_VALUE_STREAM_STAGES' and 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS' mutations`, () => @@ -355,7 +355,7 @@ describe('Project Value Stream Analytics actions', () => { describe('with a failing request', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + mock.onGet(mockValueStreamPath).reply(HTTP_STATUS_BAD_REQUEST); }); it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () => @@ -365,7 +365,7 @@ describe('Project Value Stream Analytics actions', () => { payload: {}, expectedMutations: [ { type: 'REQUEST_VALUE_STREAM_STAGES' }, - { type: 'RECEIVE_VALUE_STREAM_STAGES_ERROR', payload: httpStatusCodes.BAD_REQUEST }, + { type: 'RECEIVE_VALUE_STREAM_STAGES_ERROR', payload: HTTP_STATUS_BAD_REQUEST }, ], expectedActions: [], })); @@ -382,7 +382,7 @@ describe('Project Value Stream Analytics actions', () => { ]; const stageMedianError = new Error( - `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`, + `Request failed with status code ${HTTP_STATUS_BAD_REQUEST}`, ); beforeEach(() => { @@ -392,7 +392,7 @@ describe('Project Value Stream Analytics actions', () => { stages: allowedStages, }; mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + mock.onGet(mockValueStreamPath).reply(HTTP_STATUS_OK); }); it(`commits the 'REQUEST_STAGE_MEDIANS' and 'RECEIVE_STAGE_MEDIANS_SUCCESS' mutations`, () => @@ -410,7 +410,7 @@ describe('Project Value Stream Analytics actions', () => { describe('with a failing request', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + mock.onGet(mockValueStreamPath).reply(HTTP_STATUS_BAD_REQUEST); }); it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () => @@ -435,9 +435,7 @@ describe('Project Value Stream Analytics actions', () => { { id: 'code', count: 3 }, ]; - const stageCountError = new Error( - `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`, - ); + const stageCountError = new Error(`Request failed with status code ${HTTP_STATUS_BAD_REQUEST}`); beforeEach(() => { state = { @@ -448,11 +446,11 @@ describe('Project Value Stream Analytics actions', () => { mock = new MockAdapter(axios); mock .onGet(mockValueStreamPath) - .replyOnce(httpStatusCodes.OK, { count: 1 }) + .replyOnce(HTTP_STATUS_OK, { count: 1 }) .onGet(mockValueStreamPath) - .replyOnce(httpStatusCodes.OK, { count: 2 }) + .replyOnce(HTTP_STATUS_OK, { count: 2 }) .onGet(mockValueStreamPath) - .replyOnce(httpStatusCodes.OK, { count: 3 }); + .replyOnce(HTTP_STATUS_OK, { count: 3 }); }); it(`commits the 'REQUEST_STAGE_COUNTS' and 'RECEIVE_STAGE_COUNTS_SUCCESS' mutations`, () => @@ -470,7 +468,7 @@ describe('Project Value Stream Analytics actions', () => { describe('with a failing request', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + mock.onGet(mockValueStreamPath).reply(HTTP_STATUS_BAD_REQUEST); }); it(`commits the 'RECEIVE_STAGE_COUNTS_ERROR' mutation`, () => diff --git a/spec/frontend/api/groups_api_spec.js b/spec/frontend/api/groups_api_spec.js index 9de588a02aa..c354d8a9416 100644 --- a/spec/frontend/api/groups_api_spec.js +++ b/spec/frontend/api/groups_api_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import getGroupTransferLocationsResponse from 'test_fixtures/api/groups/transfer_locations.json'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_PER_PAGE } from '~/api'; import { updateGroup, getGroupTransferLocations } from '~/api/groups_api'; @@ -35,7 +35,7 @@ describe('GroupsApi', () => { beforeEach(() => { mock.onPut(expectedUrl).reply(({ data }) => { - return [httpStatus.OK, { id: mockGroupId, ...JSON.parse(data) }]; + return [HTTP_STATUS_OK, { id: mockGroupId, ...JSON.parse(data) }]; }); }); diff --git a/spec/frontend/api/harbor_registry_spec.js b/spec/frontend/api/harbor_registry_spec.js index 8a4c377ebd1..db4b189835e 100644 --- a/spec/frontend/api/harbor_registry_spec.js +++ b/spec/frontend/api/harbor_registry_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import * as harborRegistryApi from '~/api/harbor_registry'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; describe('~/api/harbor_registry', () => { let mock; @@ -37,7 +37,7 @@ describe('~/api/harbor_registry', () => { location: 'http://demo.harbor.com/harbor/projects/2/repositories/image-1', }, ]; - mock.onGet(expectedUrl).reply(httpStatus.OK, expectResponse); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, expectResponse); return harborRegistryApi.getHarborRepositoriesList(expectedParams).then(({ data }) => { expect(data).toEqual(expectResponse); @@ -66,7 +66,7 @@ describe('~/api/harbor_registry', () => { tags: ['v2', 'v1', 'latest'], }, ]; - mock.onGet(expectedUrl).reply(httpStatus.OK, expectResponse); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, expectResponse); return harborRegistryApi.getHarborArtifacts(expectedParams).then(({ data }) => { expect(data).toEqual(expectResponse); @@ -97,7 +97,7 @@ describe('~/api/harbor_registry', () => { immutable: false, }, ]; - mock.onGet(expectedUrl).reply(httpStatus.OK, expectResponse); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, expectResponse); return harborRegistryApi.getHarborTags(expectedParams).then(({ data }) => { expect(data).toEqual(expectResponse); diff --git a/spec/frontend/api/packages_api_spec.js b/spec/frontend/api/packages_api_spec.js index d55d2036dcf..5f517bcf358 100644 --- a/spec/frontend/api/packages_api_spec.js +++ b/spec/frontend/api/packages_api_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { publishPackage } from '~/api/packages_api'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; describe('Api', () => { const dummyApiVersion = 'v3000'; @@ -35,7 +35,7 @@ describe('Api', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/packages/generic/${name}/${packageVersion}/${name}`; jest.spyOn(axios, 'put'); - mock.onPut(expectedUrl).replyOnce(httpStatus.OK, apiResponse); + mock.onPut(expectedUrl).replyOnce(HTTP_STATUS_OK, apiResponse); return publishPackage( { diff --git a/spec/frontend/api/tags_api_spec.js b/spec/frontend/api/tags_api_spec.js index a7436bf6a50..af3533f52b7 100644 --- a/spec/frontend/api/tags_api_spec.js +++ b/spec/frontend/api/tags_api_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import * as tagsApi from '~/api/tags_api'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; describe('~/api/tags_api.js', () => { let mock; @@ -25,7 +25,7 @@ describe('~/api/tags_api.js', () => { it('fetches a tag of a given tag name of a particular project', () => { const tagName = 'tag-name'; const expectedUrl = `/api/v7/projects/${projectId}/repository/tags/${tagName}`; - mock.onGet(expectedUrl).reply(httpStatus.OK, { + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { name: tagName, }); diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js index ba6b73e8c1a..9e901cf0f71 100644 --- a/spec/frontend/api/user_api_spec.js +++ b/spec/frontend/api/user_api_spec.js @@ -1,8 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; -import { followUser, unfollowUser, associationsCount } from '~/api/user_api'; +import { followUser, unfollowUser, associationsCount, updateUserStatus } from '~/api/user_api'; import axios from '~/lib/utils/axios_utils'; -import { associationsCount as associationsCountData } from 'jest/admin/users/mock_data'; +import { + associationsCount as associationsCountData, + userStatus as mockUserStatus, +} from 'jest/admin/users/mock_data'; +import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; +import { timeRanges } from '~/vue_shared/constants'; describe('~/api/user_api', () => { let axiosMock; @@ -62,4 +67,30 @@ describe('~/api/user_api', () => { expect(axiosMock.history.get[0].url).toBe(expectedUrl); }); }); + + describe('updateUserStatus', () => { + it('calls correct URL and returns expected response', async () => { + const expectedUrl = '/api/v4/user/status'; + const expectedData = { + emoji: 'basketball', + message: 'test', + availability: AVAILABILITY_STATUS.BUSY, + clear_status_after: timeRanges[0].shortcut, + }; + const expectedResponse = { data: mockUserStatus }; + + axiosMock.onPatch(expectedUrl).replyOnce(200, expectedResponse); + + await expect( + updateUserStatus({ + emoji: 'basketball', + message: 'test', + availability: AVAILABILITY_STATUS.BUSY, + clearStatusAfter: timeRanges[0].shortcut, + }), + ).resolves.toEqual(expect.objectContaining({ data: expectedResponse })); + expect(axiosMock.history.patch[0].url).toBe(expectedUrl); + expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual(expectedData); + }); + }); }); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 5209d9c2d2c..39fbe02480d 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1,10 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; -import httpStatus, { +import { HTTP_STATUS_ACCEPTED, HTTP_STATUS_CREATED, + HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_NOT_FOUND, + HTTP_STATUS_OK, } from '~/lib/utils/http_status'; jest.mock('~/flash'); @@ -64,7 +67,7 @@ describe('Api', () => { it('fetch all group packages', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/packages`; jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, apiResponse); return Api.groupPackages(groupId).then(({ data }) => { expect(data).toEqual(apiResponse); @@ -77,7 +80,7 @@ describe('Api', () => { it('fetch all project packages', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages`; jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, apiResponse); return Api.projectPackages(projectId).then(({ data }) => { expect(data).toEqual(apiResponse); @@ -99,7 +102,7 @@ describe('Api', () => { const expectedUrl = `foo`; jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl); jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, apiResponse); return Api.projectPackage(projectId, packageId).then(({ data }) => { expect(data).toEqual(apiResponse); @@ -114,7 +117,7 @@ describe('Api', () => { jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl); jest.spyOn(axios, 'delete'); - mock.onDelete(expectedUrl).replyOnce(httpStatus.OK, true); + mock.onDelete(expectedUrl).replyOnce(HTTP_STATUS_OK, true); return Api.deleteProjectPackage(projectId, packageId).then(({ data }) => { expect(data).toEqual(true); @@ -130,7 +133,7 @@ describe('Api', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages/${packageId}/package_files/${packageFileId}`; jest.spyOn(axios, 'delete'); - mock.onDelete(expectedUrl).replyOnce(httpStatus.OK, true); + mock.onDelete(expectedUrl).replyOnce(HTTP_STATUS_OK, true); return Api.deleteProjectPackageFile(projectId, packageId, packageFileId).then( ({ data }) => { @@ -150,7 +153,7 @@ describe('Api', () => { jest.spyOn(axios, 'get'); jest.spyOn(Api, 'buildUrl').mockReturnValueOnce(expectedUrl); - mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, apiResponse); const { data } = await Api.containerRegistryDetails(1); @@ -164,7 +167,7 @@ describe('Api', () => { it('fetches a group', () => { const groupId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`; - mock.onGet(expectedUrl).reply(httpStatus.OK, { + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { name: 'test', }); @@ -182,7 +185,7 @@ describe('Api', () => { const groupId = '54321'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`; const expectedData = [{ id: 7 }]; - mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, expectedData); return Api.groupMembers(groupId).then(({ data }) => { expect(data).toEqual(expectedData); @@ -232,7 +235,7 @@ describe('Api', () => { web_url: 'https://gitlab.com/groups/gitlab-org/-/milestones/42', }, ]; - mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, expectedData); return Api.groupMilestones(groupId).then(({ data }) => { expect(data).toEqual(expectedData); @@ -245,7 +248,7 @@ describe('Api', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -266,7 +269,7 @@ describe('Api', () => { const options = { params: { search: 'foo' } }; const expectedGroup = 'gitlab-org'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${expectedGroup}/labels`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { id: 1, name: 'Foo Label', @@ -284,7 +287,7 @@ describe('Api', () => { it('fetches namespaces', () => { const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -306,7 +309,7 @@ describe('Api', () => { const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; window.gon.current_user_id = 1; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -325,7 +328,7 @@ describe('Api', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -345,7 +348,7 @@ describe('Api', () => { it('update a project with the given payload', () => { const projectPath = 'foo'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}`; - mock.onPut(expectedUrl).reply(httpStatus.OK, { foo: 'bar' }); + mock.onPut(expectedUrl).reply(HTTP_STATUS_OK, { foo: 'bar' }); return Api.updateProject(projectPath, { foo: 'bar' }).then(({ data }) => { expect(data.foo).toBe('bar'); @@ -359,7 +362,7 @@ describe('Api', () => { const options = { unused: 'option' }; const projectPath = 'gitlab-org%2Fgitlab-ce'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/users`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -378,7 +381,7 @@ describe('Api', () => { it('fetches all merge requests for a project', () => { const mockData = [{ source_branch: 'foo' }, { source_branch: 'bar' }]; - mock.onGet(expectedUrl).reply(httpStatus.OK, mockData); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, mockData); return Api.projectMergeRequests(projectPath).then(({ data }) => { expect(data.length).toEqual(2); expect(data[0].source_branch).toBe('foo'); @@ -391,7 +394,7 @@ describe('Api', () => { source_branch: 'bar', }; const mockData = [{ source_branch: 'bar' }]; - mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); + mock.onGet(expectedUrl, { params }).reply(HTTP_STATUS_OK, mockData); return Api.projectMergeRequests(projectPath, params).then(({ data }) => { expect(data.length).toEqual(1); @@ -405,7 +408,7 @@ describe('Api', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`; - mock.onGet(expectedUrl).reply(httpStatus.OK, { + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { title: 'test', }); @@ -420,7 +423,7 @@ describe('Api', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`; - mock.onGet(expectedUrl).reply(httpStatus.OK, { + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { title: 'test', }); @@ -435,7 +438,7 @@ describe('Api', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { id: 123, }, @@ -454,7 +457,7 @@ describe('Api', () => { const params = { scope: 'active' }; const mockData = [{ id: 4 }]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`; - mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); + mock.onGet(expectedUrl, { params }).reply(HTTP_STATUS_OK, mockData); return Api.projectRunners(projectPath, { params }).then(({ data }) => { expect(data).toEqual(mockData); @@ -561,7 +564,7 @@ describe('Api', () => { expect(config.data).toBe(JSON.stringify(expectedData)); return [ - httpStatus.OK, + HTTP_STATUS_OK, { name: 'test', }, @@ -584,7 +587,7 @@ describe('Api', () => { expect(config.data).toBe(JSON.stringify({ color: labelData.color })); return [ - httpStatus.OK, + HTTP_STATUS_OK, { ...labelData, }, @@ -605,7 +608,7 @@ describe('Api', () => { const groupId = '123456'; const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -660,7 +663,7 @@ describe('Api', () => { )}/repository/commits/${sha}`; it('fetches a single commit', () => { - mock.onGet(expectedUrl).reply(httpStatus.OK, { id: sha }); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { id: sha }); return Api.commit(projectId, sha).then(({ data: commit }) => { expect(commit.id).toBe(sha); @@ -668,7 +671,7 @@ describe('Api', () => { }); it('fetches a single commit without stats', () => { - mock.onGet(expectedUrl, { params: { stats: false } }).reply(httpStatus.OK, { id: sha }); + mock.onGet(expectedUrl, { params: { stats: false } }).reply(HTTP_STATUS_OK, { id: sha }); return Api.commit(projectId, sha, { stats: false }).then(({ data: commit }) => { expect(commit.id).toBe(sha); @@ -686,7 +689,7 @@ describe('Api', () => { )}`; it('fetches an issue template', () => { - mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, 'test'); return new Promise((resolve) => { Api.issueTemplate(namespace, project, templateKey, templateType, (_, response) => { @@ -698,7 +701,7 @@ describe('Api', () => { describe('when an error occurs while fetching an issue template', () => { it('rejects the Promise', () => { - mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return new Promise((resolve) => { Api.issueTemplate(namespace, project, templateKey, templateType, () => { @@ -720,7 +723,7 @@ describe('Api', () => { const expectedData = [ { key: 'Template1', name: 'Template 1', content: 'This is template 1!' }, ]; - mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, expectedData); return new Promise((resolve) => { Api.issueTemplates(namespace, project, templateType, (_, response) => { @@ -736,7 +739,7 @@ describe('Api', () => { describe('when an error occurs while fetching issue templates', () => { it('rejects the Promise', () => { - mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); Api.issueTemplates(namespace, project, templateType, () => { expect(mock.history.get).toHaveLength(1); @@ -749,7 +752,7 @@ describe('Api', () => { it('fetches a list of templates', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses`; - mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, 'test'); return new Promise((resolve) => { Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => { @@ -765,7 +768,7 @@ describe('Api', () => { const data = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses/test%20license`; - mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, 'test'); return new Promise((resolve) => { Api.projectTemplate( @@ -787,7 +790,7 @@ describe('Api', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -804,7 +807,7 @@ describe('Api', () => { it('fetches single user', () => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`; - mock.onGet(expectedUrl).reply(httpStatus.OK, { + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { name: 'testuser', }); @@ -817,7 +820,7 @@ describe('Api', () => { describe('user counts', () => { it('fetches single user counts', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/user_counts`; - mock.onGet(expectedUrl).reply(httpStatus.OK, { + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { merge_requests: 4, }); @@ -831,7 +834,7 @@ describe('Api', () => { it('fetches single user status', () => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`; - mock.onGet(expectedUrl).reply(httpStatus.OK, { + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, { message: 'testmessage', }); @@ -847,7 +850,7 @@ describe('Api', () => { const options = { unused: 'option' }; const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/projects`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -868,7 +871,7 @@ describe('Api', () => { const projectId = 'example/foobar'; const commitSha = 'abc123def'; const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -893,7 +896,7 @@ describe('Api', () => { name: 'test', }, ]; - mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, payload); + mock.onGet(expectedUrl, { params }).reply(HTTP_STATUS_OK, payload); const { data } = await Api.pipelineJobs(projectId, pipelineId, params); expect(data).toEqual(payload); @@ -912,7 +915,7 @@ describe('Api', () => { jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl).replyOnce(httpStatus.OK, { + mock.onPost(expectedUrl).replyOnce(HTTP_STATUS_OK, { name: branch, }); @@ -932,7 +935,7 @@ describe('Api', () => { jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(httpStatus.OK, ['fork']); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, ['fork']); return Api.projectForks(dummyProjectPath, { visibility: 'private' }).then(({ data }) => { expect(data).toEqual(['fork']); @@ -1021,7 +1024,7 @@ describe('Api', () => { describe('when releases are successfully returned', () => { it('resolves the Promise', () => { - mock.onGet(expectedUrl).replyOnce(httpStatus.OK); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK); return Api.releases(dummyProjectPath).then(() => { expect(mock.history.get).toHaveLength(1); @@ -1031,7 +1034,7 @@ describe('Api', () => { describe('when an error occurs while fetching releases', () => { it('rejects the Promise', () => { - mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Api.releases(dummyProjectPath).catch(() => { expect(mock.history.get).toHaveLength(1); @@ -1045,7 +1048,7 @@ describe('Api', () => { describe('when the release is successfully returned', () => { it('resolves the Promise', () => { - mock.onGet(expectedUrl).replyOnce(httpStatus.OK); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK); return Api.release(dummyProjectPath, dummyTagName).then(() => { expect(mock.history.get).toHaveLength(1); @@ -1055,7 +1058,7 @@ describe('Api', () => { describe('when an error occurs while fetching the release', () => { it('rejects the Promise', () => { - mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Api.release(dummyProjectPath, dummyTagName).catch(() => { expect(mock.history.get).toHaveLength(1); @@ -1083,7 +1086,7 @@ describe('Api', () => { describe('when an error occurs while creating the release', () => { it('rejects the Promise', () => { - mock.onPost(expectedUrl, release).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onPost(expectedUrl, release).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Api.createRelease(dummyProjectPath, release).catch(() => { expect(mock.history.post).toHaveLength(1); @@ -1101,7 +1104,7 @@ describe('Api', () => { describe('when the release is successfully updated', () => { it('resolves the Promise', () => { - mock.onPut(expectedUrl, release).replyOnce(httpStatus.OK); + mock.onPut(expectedUrl, release).replyOnce(HTTP_STATUS_OK); return Api.updateRelease(dummyProjectPath, dummyTagName, release).then(() => { expect(mock.history.put).toHaveLength(1); @@ -1111,7 +1114,7 @@ describe('Api', () => { describe('when an error occurs while updating the release', () => { it('rejects the Promise', () => { - mock.onPut(expectedUrl, release).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onPut(expectedUrl, release).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Api.updateRelease(dummyProjectPath, dummyTagName, release).catch(() => { expect(mock.history.put).toHaveLength(1); @@ -1139,7 +1142,7 @@ describe('Api', () => { describe('when an error occurs while creating the Release', () => { it('rejects the Promise', () => { - mock.onPost(expectedUrl, expectedLink).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onPost(expectedUrl, expectedLink).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Api.createReleaseLink(dummyProjectPath, dummyTagName, expectedLink).catch(() => { expect(mock.history.post).toHaveLength(1); @@ -1154,7 +1157,7 @@ describe('Api', () => { describe('when the Release is successfully deleted', () => { it('resolves the Promise', () => { - mock.onDelete(expectedUrl).replyOnce(httpStatus.OK); + mock.onDelete(expectedUrl).replyOnce(HTTP_STATUS_OK); return Api.deleteReleaseLink(dummyProjectPath, dummyTagName, dummyLinkId).then(() => { expect(mock.history.delete).toHaveLength(1); @@ -1164,7 +1167,7 @@ describe('Api', () => { describe('when an error occurs while deleting the Release', () => { it('rejects the Promise', () => { - mock.onDelete(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onDelete(expectedUrl).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Api.deleteReleaseLink(dummyProjectPath, dummyTagName, dummyLinkId).catch(() => { expect(mock.history.delete).toHaveLength(1); @@ -1183,7 +1186,7 @@ describe('Api', () => { describe('when the raw file is successfully fetched', () => { beforeEach(() => { - mock.onGet(expectedUrl).replyOnce(httpStatus.OK); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK); }); it('resolves the Promise', () => { @@ -1206,7 +1209,7 @@ describe('Api', () => { describe('when an error occurs while getting a raw file', () => { it('rejects the Promise', () => { - mock.onPost(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onPost(expectedUrl).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Api.getRawFile(dummyProjectPath, dummyFilePath).catch(() => { expect(mock.history.get).toHaveLength(1); @@ -1238,7 +1241,7 @@ describe('Api', () => { describe('when an error occurs while getting a raw file', () => { it('rejects the Promise', () => { - mock.onPost(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mock.onPost(expectedUrl).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); return Api.createProjectMergeRequest(dummyProjectPath).catch(() => { expect(mock.history.post).toHaveLength(1); @@ -1253,7 +1256,7 @@ describe('Api', () => { const issue = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/issues/${issue}`; - mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); + mock.onPut(expectedUrl).reply(HTTP_STATUS_OK, { assigneeIds: expectedArray }); return Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }).then(({ data }) => { expect(data.assigneeIds).toEqual(expectedArray); @@ -1267,7 +1270,7 @@ describe('Api', () => { const mergeRequest = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/merge_requests/${mergeRequest}`; - mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); + mock.onPut(expectedUrl).reply(HTTP_STATUS_OK, { assigneeIds: expectedArray }); return Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }).then( ({ data }) => { @@ -1283,7 +1286,7 @@ describe('Api', () => { const options = { unused: 'option' }; const projectId = 8; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/repository/tags`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [ + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [ { name: 'test', }, @@ -1308,7 +1311,7 @@ describe('Api', () => { updated_at: '2020-07-10T05:10:35.122Z', }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/freeze_periods`; - mock.onGet(expectedUrl).reply(httpStatus.OK, [freezePeriod]); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, [freezePeriod]); return Api.freezePeriods(projectId).then(({ data }) => { expect(data[0]).toStrictEqual(freezePeriod); @@ -1368,7 +1371,7 @@ describe('Api', () => { describe('when the freeze period is successfully updated', () => { it('resolves the Promise', () => { - mock.onPut(expectedUrl, options).replyOnce(httpStatus.OK, expectedResult); + mock.onPut(expectedUrl, options).replyOnce(HTTP_STATUS_OK, expectedResult); return Api.updateFreezePeriod(projectId, options).then(({ data }) => { expect(data).toStrictEqual(expectedResult); @@ -1392,7 +1395,7 @@ describe('Api', () => { jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl).replyOnce(httpStatus.OK, { + mock.onPost(expectedUrl).replyOnce(HTTP_STATUS_OK, { web_url: redirectUrl, }); @@ -1423,7 +1426,7 @@ describe('Api', () => { it('returns null', () => { jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true); + mock.onPost(expectedUrl).replyOnce(HTTP_STATUS_OK, true); expect(axios.post).toHaveBeenCalledTimes(0); expect(Api.trackRedisCounterEvent(event)).toEqual(null); @@ -1437,7 +1440,7 @@ describe('Api', () => { it('resolves the Promise', () => { jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); + mock.onPost(expectedUrl, { event }).replyOnce(HTTP_STATUS_OK, true); return Api.trackRedisCounterEvent(event).then(({ data }) => { expect(data).toEqual(true); @@ -1483,7 +1486,7 @@ describe('Api', () => { it('resolves the Promise', () => { jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); + mock.onPost(expectedUrl, { event }).replyOnce(HTTP_STATUS_OK, true); return Api.trackRedisHllUserEvent(event).then(({ data }) => { expect(data).toEqual(true); @@ -1544,7 +1547,7 @@ describe('Api', () => { ]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/deploy_keys`; - mock.onGet(expectedUrl).reply(httpStatus.OK, deployKeys); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, deployKeys); const params = { page: 2, public: true }; const { data } = await Api.deployKeys(params); @@ -1569,7 +1572,7 @@ describe('Api', () => { ]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files`; - mock.onGet(expectedUrl).reply(httpStatus.OK, secureFiles); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, secureFiles); const { data } = await Api.projectSecureFiles(projectId, {}); expect(data).toEqual(secureFiles); @@ -1589,7 +1592,7 @@ describe('Api', () => { }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files`; - mock.onPost(expectedUrl).reply(httpStatus.OK, secureFile); + mock.onPost(expectedUrl).reply(HTTP_STATUS_OK, secureFile); const { data } = await Api.uploadProjectSecureFile(projectId, 'some data'); expect(data).toEqual(secureFile); @@ -1639,7 +1642,7 @@ describe('Api', () => { describe('fetchFeatureFlagUserLists', () => { it('GETs the right url', () => { - mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, []); return Api.fetchFeatureFlagUserLists(projectId).then(({ data }) => { expect(data).toEqual([]); @@ -1649,7 +1652,7 @@ describe('Api', () => { describe('searchFeatureFlagUserLists', () => { it('GETs the right url', () => { - mock.onGet(expectedUrl, { params: { search: 'test' } }).replyOnce(httpStatus.OK, []); + mock.onGet(expectedUrl, { params: { search: 'test' } }).replyOnce(HTTP_STATUS_OK, []); return Api.searchFeatureFlagUserLists(projectId, 'test').then(({ data }) => { expect(data).toEqual([]); @@ -1663,7 +1666,7 @@ describe('Api', () => { name: 'mock_user_list', user_xids: '1,2,3,4', }; - mock.onPost(expectedUrl, mockUserListData).replyOnce(httpStatus.OK, mockUserList); + mock.onPost(expectedUrl, mockUserListData).replyOnce(HTTP_STATUS_OK, mockUserList); return Api.createFeatureFlagUserList(projectId, mockUserListData).then(({ data }) => { expect(data).toEqual(mockUserList); @@ -1673,7 +1676,7 @@ describe('Api', () => { describe('fetchFeatureFlagUserList', () => { it('GETs the right url', () => { - mock.onGet(`${expectedUrl}/1`).replyOnce(httpStatus.OK, mockUserList); + mock.onGet(`${expectedUrl}/1`).replyOnce(HTTP_STATUS_OK, mockUserList); return Api.fetchFeatureFlagUserList(projectId, 1).then(({ data }) => { expect(data).toEqual(mockUserList); @@ -1685,7 +1688,7 @@ describe('Api', () => { it('PUTs the right url', () => { mock .onPut(`${expectedUrl}/1`) - .replyOnce(httpStatus.OK, { ...mockUserList, user_xids: '5' }); + .replyOnce(HTTP_STATUS_OK, { ...mockUserList, user_xids: '5' }); return Api.updateFeatureFlagUserList(projectId, { ...mockUserList, @@ -1698,7 +1701,7 @@ describe('Api', () => { describe('deleteFeatureFlagUserList', () => { it('DELETEs the right url', () => { - mock.onDelete(`${expectedUrl}/1`).replyOnce(httpStatus.OK, 'deleted'); + mock.onDelete(`${expectedUrl}/1`).replyOnce(HTTP_STATUS_OK, 'deleted'); return Api.deleteFeatureFlagUserList(projectId, 1).then(({ data }) => { expect(data).toBe('deleted'); @@ -1715,12 +1718,12 @@ describe('Api', () => { it('returns 404 for non-existing branch', () => { jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(httpStatus.NOT_FOUND, { + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_NOT_FOUND, { message: '404 Not found', }); return Api.projectProtectedBranch(dummyProjectId, branchName).catch((error) => { - expect(error.response.status).toBe(httpStatus.NOT_FOUND); + expect(error.response.status).toBe(HTTP_STATUS_NOT_FOUND); expect(axios.get).toHaveBeenCalledWith(expectedUrl); }); }); @@ -1730,7 +1733,7 @@ describe('Api', () => { jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(httpStatus.OK, expectedObj); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedObj); return Api.projectProtectedBranch(dummyProjectId, branchName).then((data) => { expect(data).toEqual(expectedObj); diff --git a/spec/frontend/artifacts/components/artifact_row_spec.js b/spec/frontend/artifacts/components/artifact_row_spec.js index dcc0d684f13..2a7156bf480 100644 --- a/spec/frontend/artifacts/components/artifact_row_spec.js +++ b/spec/frontend/artifacts/components/artifact_row_spec.js @@ -16,13 +16,14 @@ describe('ArtifactRow component', () => { const findDownloadButton = () => wrapper.findByTestId('job-artifact-row-download-button'); const findDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button'); - const createComponent = (mountFn = shallowMountExtended) => { - wrapper = mountFn(ArtifactRow, { + const createComponent = ({ canDestroyArtifacts = true } = {}) => { + wrapper = shallowMountExtended(ArtifactRow, { propsData: { artifact, isLoading: false, isLastRow: false, }, + provide: { canDestroyArtifacts }, stubs: { GlBadge, GlButton, GlFriendlyWrap }, }); }; @@ -50,12 +51,24 @@ describe('ArtifactRow component', () => { it('displays the download button as a link to the download path', () => { expect(findDownloadButton().attributes('href')).toBe(artifact.downloadPath); }); + }); + + describe('delete button', () => { + it('does not show when user does not have permission', () => { + createComponent({ canDestroyArtifacts: false }); + + expect(findDeleteButton().exists()).toBe(false); + }); + + it('shows when user has permission', () => { + createComponent(); - it('displays the delete button', () => { expect(findDeleteButton().exists()).toBe(true); }); - it('emits the delete event when the delete button is clicked', async () => { + it('emits the delete event when clicked', async () => { + createComponent(); + expect(wrapper.emitted('delete')).toBeUndefined(); findDeleteButton().trigger('click'); diff --git a/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js b/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js index c6ad13462f9..d006e0285d2 100644 --- a/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js +++ b/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js @@ -40,6 +40,7 @@ describe('ArtifactsTableRowDetails component', () => { refetchArtifacts, queryVariables: {}, }, + provide: { canDestroyArtifacts: true }, data() { return { deletingArtifactId: null }; }, diff --git a/spec/frontend/artifacts/components/feedback_banner_spec.js b/spec/frontend/artifacts/components/feedback_banner_spec.js new file mode 100644 index 00000000000..3421486020a --- /dev/null +++ b/spec/frontend/artifacts/components/feedback_banner_spec.js @@ -0,0 +1,63 @@ +import { GlBanner } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import FeedbackBanner from '~/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'; + +const mockBannerImagePath = 'banner/image/path'; + +describe('Artifacts management feedback banner', () => { + let wrapper; + let userCalloutDismissSpy; + + const findBanner = () => wrapper.findComponent(GlBanner); + + const createComponent = ({ shouldShowCallout = true } = {}) => { + userCalloutDismissSpy = jest.fn(); + + wrapper = shallowMount(FeedbackBanner, { + provide: { + artifactsManagementFeedbackImagePath: mockBannerImagePath, + }, + stubs: { + UserCalloutDismisser: makeMockUserCalloutDismisser({ + dismiss: userCalloutDismissSpy, + shouldShowCallout, + }), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('is displayed with the correct props', () => { + createComponent(); + + expect(findBanner().props()).toMatchObject({ + title: I18N_FEEDBACK_BANNER_TITLE, + buttonText: I18N_FEEDBACK_BANNER_BUTTON, + buttonLink: FEEDBACK_URL, + svgPath: mockBannerImagePath, + }); + }); + + it('dismisses the callout when closed', () => { + createComponent(); + + findBanner().vm.$emit('close'); + + expect(userCalloutDismissSpy).toHaveBeenCalled(); + }); + + it('is not displayed once it has been dismissed', () => { + createComponent({ shouldShowCallout: false }); + + expect(findBanner().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/artifacts/components/job_artifacts_table_spec.js index 131b4b99bb2..dbe4598f599 100644 --- a/spec/frontend/artifacts/components/job_artifacts_table_spec.js +++ b/spec/frontend/artifacts/components/job_artifacts_table_spec.js @@ -5,6 +5,7 @@ import getJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/que 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 createMockApollo from 'helpers/mock_apollo_helper'; @@ -23,6 +24,8 @@ describe('JobArtifactsTable component', () => { let wrapper; let requestHandlers; + const findBanner = () => wrapper.findComponent(FeedbackBanner); + const findLoadingState = () => wrapper.findComponent(GlLoadingIcon); const findTable = () => wrapper.findComponent(GlTable); const findDetailsRows = () => wrapper.findAllComponents(ArtifactsTableRowDetails); @@ -79,13 +82,18 @@ describe('JobArtifactsTable component', () => { getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse), }, data = {}, + canDestroyArtifacts = true, ) => { requestHandlers = handlers; wrapper = mountExtended(JobArtifactsTable, { apolloProvider: createMockApollo([ [getJobArtifactsQuery, requestHandlers.getJobArtifactsQuery], ]), - provide: { projectPath: 'project/path' }, + provide: { + projectPath: 'project/path', + canDestroyArtifacts, + artifactsManagementFeedbackImagePath: 'banner/image/path', + }, data() { return data; }, @@ -96,6 +104,12 @@ describe('JobArtifactsTable component', () => { wrapper.destroy(); }); + it('renders feedback banner', () => { + createComponent(); + + expect(findBanner().exists()).toBe(true); + }); + it('when loading, shows a loading state', () => { createComponent(); @@ -283,6 +297,14 @@ describe('JobArtifactsTable component', () => { }); describe('delete button', () => { + it('does not show when user does not have permission', async () => { + createComponent({}, {}, false); + + await waitForPromises(); + + expect(findDeleteButton().exists()).toBe(false); + }); + it('shows a disabled delete button for now (coming soon)', async () => { createComponent(); diff --git a/spec/frontend/autosave_spec.js b/spec/frontend/autosave_spec.js index 7a9262cd004..88460221168 100644 --- a/spec/frontend/autosave_spec.js +++ b/spec/frontend/autosave_spec.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import Autosave from '~/autosave'; import AccessorUtilities from '~/lib/utils/accessor'; @@ -7,12 +6,19 @@ describe('Autosave', () => { useLocalStorageSpy(); let autosave; - const field = $('<textarea></textarea>'); - const checkbox = $('<input type="checkbox">'); + const field = document.createElement('textarea'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; const key = 'key'; const fallbackKey = 'fallbackKey'; const lockVersionKey = 'lockVersionKey'; const lockVersion = 1; + const getAutosaveKey = () => `autosave/${key}`; + const getAutosaveLockKey = () => `autosave/${key}/lockVersion`; + + afterEach(() => { + autosave?.dispose?.(); + }); describe('class constructor', () => { beforeEach(() => { @@ -43,18 +49,10 @@ describe('Autosave', () => { }); describe('restore', () => { - beforeEach(() => { - autosave = { - field, - key, - }; - }); - describe('if .isLocalStorageAvailable is `false`', () => { beforeEach(() => { - autosave.isLocalStorageAvailable = false; - - Autosave.prototype.restore.call(autosave); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); + autosave = new Autosave(field, key); }); it('should not call .getItem', () => { @@ -63,97 +61,73 @@ describe('Autosave', () => { }); describe('if .isLocalStorageAvailable is `true`', () => { - beforeEach(() => { - autosave.isLocalStorageAvailable = true; - }); - it('should call .getItem', () => { - Autosave.prototype.restore.call(autosave); - - expect(window.localStorage.getItem).toHaveBeenCalledWith(key); + autosave = new Autosave(field, key); + expect(window.localStorage.getItem.mock.calls).toEqual([[getAutosaveKey()], []]); }); - it('triggers jquery event', () => { - jest.spyOn(autosave.field, 'trigger').mockImplementation(() => {}); - - Autosave.prototype.restore.call(autosave); - - expect(field.trigger).toHaveBeenCalled(); - }); - - it('triggers native event', () => { - const fieldElement = autosave.field.get(0); - const eventHandler = jest.fn(); - fieldElement.addEventListener('change', eventHandler); - - Autosave.prototype.restore.call(autosave); + describe('if saved value is present', () => { + const storedValue = 'bar'; - expect(eventHandler).toHaveBeenCalledTimes(1); - fieldElement.removeEventListener('change', eventHandler); - }); - - describe('if field type is checkbox', () => { beforeEach(() => { - autosave = { - field: checkbox, - key, - isLocalStorageAvailable: true, - type: 'checkbox', - }; + field.value = 'foo'; + window.localStorage.setItem(getAutosaveKey(), storedValue); }); - it('should restore', () => { - window.localStorage.setItem(key, true); - expect(checkbox.is(':checked')).toBe(false); - Autosave.prototype.restore.call(autosave); - expect(checkbox.is(':checked')).toBe(true); + it('restores the value', () => { + autosave = new Autosave(field, key); + expect(field.value).toEqual(storedValue); }); - }); - }); - describe('if field gets deleted from DOM', () => { - beforeEach(() => { - autosave.field = $('.not-a-real-element'); - }); + it('triggers native event', () => { + const eventHandler = jest.fn(); + field.addEventListener('change', eventHandler); + autosave = new Autosave(field, key); - it('does not trigger event', () => { - jest.spyOn(field, 'trigger'); + expect(eventHandler).toHaveBeenCalledTimes(1); + field.removeEventListener('change', eventHandler); + }); + + describe('if field type is checkbox', () => { + beforeEach(() => { + checkbox.checked = false; + window.localStorage.setItem(getAutosaveKey(), true); + autosave = new Autosave(checkbox, key); + }); - expect(field.trigger).not.toHaveBeenCalled(); + it('should restore', () => { + expect(checkbox.checked).toBe(true); + }); + }); }); }); }); describe('getSavedLockVersion', () => { - beforeEach(() => { - autosave = { - field, - key, - lockVersionKey, - }; - }); - describe('if .isLocalStorageAvailable is `false`', () => { beforeEach(() => { - autosave.isLocalStorageAvailable = false; - - Autosave.prototype.getSavedLockVersion.call(autosave); + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); + autosave = new Autosave(field, key); }); it('should not call .getItem', () => { + autosave.getSavedLockVersion(); expect(window.localStorage.getItem).not.toHaveBeenCalled(); }); }); describe('if .isLocalStorageAvailable is `true`', () => { beforeEach(() => { - autosave.isLocalStorageAvailable = true; + autosave = new Autosave(field, key); }); it('should call .getItem', () => { - Autosave.prototype.getSavedLockVersion.call(autosave); - - expect(window.localStorage.getItem).toHaveBeenCalledWith(lockVersionKey); + autosave.getSavedLockVersion(); + expect(window.localStorage.getItem.mock.calls).toEqual([ + [getAutosaveKey()], + [], + [getAutosaveLockKey()], + ]); }); }); }); @@ -162,7 +136,7 @@ describe('Autosave', () => { beforeEach(() => { autosave = { reset: jest.fn() }; autosave.field = field; - field.val('value'); + field.value = 'value'; }); describe('if .isLocalStorageAvailable is `false`', () => { @@ -200,14 +174,14 @@ describe('Autosave', () => { }); it('should save true when checkbox on', () => { - checkbox.prop('checked', true); + checkbox.checked = true; Autosave.prototype.save.call(autosave); expect(window.localStorage.setItem).toHaveBeenCalledWith(key, true); }); it('should call reset when checkbox off', () => { autosave.reset = jest.fn(); - checkbox.prop('checked', false); + checkbox.checked = false; Autosave.prototype.save.call(autosave); expect(autosave.reset).toHaveBeenCalled(); expect(window.localStorage.setItem).not.toHaveBeenCalled(); diff --git a/spec/frontend/batch_comments/components/submit_dropdown_spec.js b/spec/frontend/batch_comments/components/submit_dropdown_spec.js index 462ef7e7280..003a6d86371 100644 --- a/spec/frontend/batch_comments/components/submit_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/submit_dropdown_spec.js @@ -3,6 +3,8 @@ import Vuex from 'vuex'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import SubmitDropdown from '~/batch_comments/components/submit_dropdown.vue'; +jest.mock('~/autosave'); + Vue.use(Vuex); let wrapper; diff --git a/spec/frontend/behaviors/markdown/render_gfm_spec.js b/spec/frontend/behaviors/markdown/render_gfm_spec.js new file mode 100644 index 00000000000..0bbb92282e5 --- /dev/null +++ b/spec/frontend/behaviors/markdown/render_gfm_spec.js @@ -0,0 +1,9 @@ +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +describe('renderGFM', () => { + it('handles a missing element', () => { + expect(() => { + renderGFM(); + }).not.toThrow(); + }); +}); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index d05e057095d..2c8e6306431 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,7 +1,7 @@ import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { range } from 'lodash'; +import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import { nextTick } from 'vue'; import setWindowLocation from 'helpers/set_window_location_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -17,6 +17,8 @@ import { mockLabelList, mockIssue, mockIssueFullPath } from './mock_data'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/boards/eventhub'); +Vue.use(Vuex); + describe('Board card component', () => { const user = { id: 1, @@ -52,25 +54,19 @@ describe('Board card component', () => { const performSearchMock = jest.fn(); - const createStore = ({ isProjectBoard = false } = {}) => { + const createStore = () => { store = new Vuex.Store({ - ...defaultStore, actions: { performSearch: performSearchMock, }, state: { ...defaultStore.state, - issuableType: issuableTypes.issue, isShowingLabels: true, }, - getters: { - isGroupBoard: () => true, - isProjectBoard: () => isProjectBoard, - }, }); }; - const createWrapper = ({ props = {}, isEpicBoard = false } = {}) => { + const createWrapper = ({ props = {}, isEpicBoard = false, isGroupBoard = true } = {}) => { wrapper = mountExtended(BoardCardInner, { store, propsData: { @@ -97,6 +93,8 @@ describe('Board card component', () => { rootPath: '/', scopedLabelsAvailable: false, isEpicBoard, + issuableType: issuableTypes.issue, + isGroupBoard, }, }); }; @@ -164,8 +162,8 @@ describe('Board card component', () => { }); it('does not render item reference path', () => { - createStore({ isProjectBoard: true }); - createWrapper(); + createStore(); + createWrapper({ isGroupBoard: false }); expect(wrapper.find('.board-card-number').text()).not.toContain(mockIssueFullPath); }); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index c5c3faf1712..1ba546f24a8 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -58,8 +58,6 @@ export default function createComponent({ ...state, }, getters: { - isGroupBoard: () => false, - isProjectBoard: () => true, isEpicBoard: () => false, ...getters, }, @@ -88,7 +86,6 @@ export default function createComponent({ apolloProvider: fakeApollo, store, propsData: { - disabled: false, list, boardItems: [issue], canAdminList: true, @@ -97,12 +94,16 @@ export default function createComponent({ provide: { groupId: null, rootPath: '/', + fullPath: 'gitlab-org', boardId: '1', weightFeatureAvailable: false, boardWeight: null, canAdminList: true, isIssueBoard: true, isEpicBoard: false, + isGroupBoard: false, + isProjectBoard: true, + disabled: false, ...provide, }, stubs, diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 34c0504143c..abe8c230bd8 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -267,7 +267,7 @@ describe('Board list component', () => { describe('when dragging is not allowed', () => { beforeEach(() => { wrapper = createComponent({ - componentProps: { + provide: { disabled: true, }, }); diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js index c209f2f82e6..872a67a71fb 100644 --- a/spec/frontend/boards/components/board_app_spec.js +++ b/spec/frontend/boards/components/board_app_spec.js @@ -23,11 +23,10 @@ describe('BoardApp', () => { }); }; - const createComponent = ({ provide = { disabled: true } } = {}) => { + const createComponent = () => { wrapper = shallowMount(BoardApp, { store, provide: { - ...provide, fullBoardId: 'gid://gitlab/Board/1', }, }); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 38b79e2e3f3..f8ad7c468c1 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -29,9 +29,6 @@ describe('Board card', () => { ...initialState, }, actions: mockActions, - getters: { - isProjectBoard: () => false, - }, }); }; @@ -52,7 +49,6 @@ describe('Board card', () => { propsData: { list: mockLabelList, item, - disabled: false, index: 0, ...propsData, }, @@ -61,6 +57,10 @@ describe('Board card', () => { rootPath: '/', scopedLabelsAvailable: false, isEpicBoard: false, + issuableType: 'issue', + isProjectBoard: false, + isGroupBoard: true, + disabled: false, ...provide, }, }); diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index c13f7caba76..d34e228a2d7 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -34,7 +34,6 @@ describe('Board Column Component', () => { wrapper = shallowMount(BoardColumn, { store, propsData: { - disabled: false, list: listMock, }, }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 0d5b1d16e30..51c42b48535 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -7,7 +7,7 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow import { stubComponent } from 'helpers/stub_component'; import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { ISSUABLE } from '~/boards/constants'; +import { ISSUABLE, issuableTypes } from '~/boards/constants'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; @@ -26,7 +26,6 @@ describe('BoardContentSidebar', () => { sidebarType: ISSUABLE, issues: { [mockIssue.id]: { ...mockIssue, epic: null } }, activeId: mockIssue.id, - issuableType: 'issue', }, getters: { activeBoardItem: () => { @@ -35,7 +34,6 @@ describe('BoardContentSidebar', () => { groupPathForActiveIssue: () => mockIssueGroupPath, projectPathForActiveIssue: () => mockIssueProjectPath, isSidebarOpen: () => true, - isGroupBoard: () => false, ...mockGetters, }, actions: mockActions, @@ -55,6 +53,8 @@ describe('BoardContentSidebar', () => { canUpdate: true, rootPath: '/', groupId: 1, + issuableType: issuableTypes.issue, + isGroupBoard: false, }, store, stubs: { diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 82e7ab48e7d..97596c86198 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -60,7 +60,6 @@ describe('BoardContent', () => { wrapper = shallowMount(BoardContent, { apolloProvider: fakeApollo, propsData: { - disabled: false, boardId: 'gid://gitlab/Board/1', ...props, }, @@ -71,6 +70,8 @@ describe('BoardContent', () => { issuableType, isIssueBoard, isEpicBoard, + isGroupBoard: true, + disabled: false, isApolloBoard, }, store, diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index e80c66f7fb8..4c0cc36889c 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -139,6 +139,7 @@ describe('BoardFilteredSearch', () => { { type: TOKEN_TYPE_ITERATION, value: { data: 'Any&3', operator: '=' } }, { type: TOKEN_TYPE_RELEASE, value: { data: 'v1.0.0', operator: '=' } }, { type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: '=' } }, + { type: TOKEN_TYPE_HEALTH, value: { data: 'atRisk', operator: '!=' } }, ]; jest.spyOn(urlUtility, 'updateHistory'); findFilteredSearch().vm.$emit('onFilter', mockFilters); @@ -147,7 +148,7 @@ describe('BoardFilteredSearch', () => { title: '', replace: true, url: - 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0&health_status=onTrack', + 'http://test.host/?not[health_status]=atRisk&author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0&health_status=onTrack', }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index fdc16b46167..f8154145d43 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -53,10 +53,6 @@ describe('BoardForm', () => { const setErrorMock = jest.fn(); const store = new Vuex.Store({ - getters: { - isGroupBoard: () => true, - isProjectBoard: () => false, - }, actions: { setBoard: setBoardMock, setError: setErrorMock, @@ -73,6 +69,8 @@ describe('BoardForm', () => { }, provide: { boardBaseUrl: 'root', + isGroupBoard: true, + isProjectBoard: false, }, mocks: { $apollo: { diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 4633612891c..a16b99728c3 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -68,7 +68,6 @@ describe('Board List Header Component', () => { apolloProvider: fakeApollo, store, propsData: { - disabled: false, list: listMock, }, provide: { @@ -76,6 +75,7 @@ describe('Board List Header Component', () => { weightFeatureAvailable: false, currentUserId, isEpicBoard: false, + disabled: false, }, }), ); diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index f097f42476a..c3e69ba0e40 100644 --- a/spec/frontend/boards/components/board_new_issue_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -14,9 +14,10 @@ const addListNewIssuesSpy = jest.fn().mockResolvedValue(); const mockActions = { addListNewIssue: addListNewIssuesSpy }; const createComponent = ({ - state = { selectedProject: mockGroupProjects[0], fullPath: mockGroupProjects[0].fullPath }, + state = { selectedProject: mockGroupProjects[0] }, actions = mockActions, - getters = { isGroupBoard: () => true, getBoardItemsByList: () => () => [] }, + getters = { getBoardItemsByList: () => () => [] }, + isGroupBoard = true, } = {}) => shallowMount(BoardNewIssue, { store: new Vuex.Store({ @@ -29,8 +30,10 @@ const createComponent = ({ }, provide: { groupId: 1, + fullPath: mockGroupProjects[0].fullPath, weightFeatureAvailable: false, boardWeight: null, + isGroupBoard, }, stubs: { BoardNewItem, @@ -84,9 +87,9 @@ describe('Issue boards new issue form', () => { beforeEach(() => { wrapper = createComponent({ getters: { - isGroupBoard: () => true, getBoardItemsByList: () => () => [mockIssue, mockIssue2], }, + isGroupBoard: true, }); }); @@ -128,7 +131,7 @@ describe('Issue boards new issue form', () => { describe('when in project issue board', () => { beforeEach(() => { wrapper = createComponent({ - getters: { isGroupBoard: () => false }, + isGroupBoard: false, }); }); diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js index 08b5042f70f..af492145eb0 100644 --- a/spec/frontend/boards/components/board_top_bar_spec.js +++ b/spec/frontend/boards/components/board_top_bar_spec.js @@ -33,6 +33,7 @@ describe('BoardTopBar', () => { boardType: 'group', releasesFetchPath: '/releases', isIssueBoard: true, + isGroupBoard: true, ...provide, }, stubs: { IssueBoardFilteredSearch }, diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index f3be66db36f..7b61ca5e6fd 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -10,7 +10,6 @@ import groupBoardsQuery from '~/boards/graphql/group_boards.query.graphql'; import projectBoardsQuery from '~/boards/graphql/project_boards.query.graphql'; import groupRecentBoardsQuery from '~/boards/graphql/group_recent_boards.query.graphql'; import projectRecentBoardsQuery from '~/boards/graphql/project_recent_boards.query.graphql'; -import defaultStore from '~/boards/stores'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { @@ -28,25 +27,20 @@ import { const throttleDuration = 1; Vue.use(VueApollo); +Vue.use(Vuex); describe('BoardsSelector', () => { let wrapper; let fakeApollo; let store; - const createStore = ({ isGroupBoard = false, isProjectBoard = false } = {}) => { + const createStore = () => { store = new Vuex.Store({ - ...defaultStore, actions: { setError: jest.fn(), setBoardConfig: jest.fn(), }, - getters: { - isGroupBoard: () => isGroupBoard, - isProjectBoard: () => isProjectBoard, - }, state: { - boardType: isGroupBoard ? 'group' : 'project', board: mockBoard, }, }); @@ -86,6 +80,8 @@ describe('BoardsSelector', () => { const createComponent = ({ projectBoardsQueryHandler = projectBoardsQueryHandlerSuccess, projectRecentBoardsQueryHandler = projectRecentBoardsQueryHandlerSuccess, + isGroupBoard = false, + isProjectBoard = false, } = {}) => { fakeApollo = createMockApollo([ [projectBoardsQuery, projectBoardsQueryHandler], @@ -109,6 +105,9 @@ describe('BoardsSelector', () => { multipleIssueBoardsAvailable: true, scopedIssueBoardFeatureEnabled: true, weights: [], + boardType: isGroupBoard ? 'group' : 'project', + isGroupBoard, + isProjectBoard, }, }); }; @@ -120,8 +119,8 @@ describe('BoardsSelector', () => { describe('template', () => { beforeEach(() => { - createStore({ isProjectBoard: true }); - createComponent(); + createStore(); + createComponent({ isProjectBoard: true }); }); describe('loading', () => { @@ -229,11 +228,11 @@ describe('BoardsSelector', () => { ${BoardType.group} | ${groupBoardsQueryHandlerSuccess} | ${projectBoardsQueryHandlerSuccess} ${BoardType.project} | ${projectBoardsQueryHandlerSuccess} | ${groupBoardsQueryHandlerSuccess} `('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => { - createStore({ - isProjectBoard: boardType === BoardType.project, + createStore(); + createComponent({ isGroupBoard: boardType === BoardType.group, + isProjectBoard: boardType === BoardType.project, }); - createComponent(); await nextTick(); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index 513561307cd..57a30ddc512 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -18,7 +18,7 @@ describe('IssueBoardFilter', () => { isSignedIn, releasesFetchPath: '/releases', fullPath: 'gitlab-org', - boardType: 'group', + isGroupBoard: true, }, }); }; diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index 304f2aad98e..c86a256bd96 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -12,42 +12,6 @@ import { } from '../mock_data'; describe('Boards - Getters', () => { - describe('isGroupBoard', () => { - it('returns true when boardType on state is group', () => { - const state = { - boardType: 'group', - }; - - expect(getters.isGroupBoard(state)).toBe(true); - }); - - it('returns false when boardType on state is not group', () => { - const state = { - boardType: 'project', - }; - - expect(getters.isGroupBoard(state)).toBe(false); - }); - }); - - describe('isProjectBoard', () => { - it('returns true when boardType on state is project', () => { - const state = { - boardType: 'project', - }; - - expect(getters.isProjectBoard(state)).toBe(true); - }); - - it('returns false when boardType on state is not project', () => { - const state = { - boardType: 'group', - }; - - expect(getters.isProjectBoard(state)).toBe(false); - }); - }); - describe('isSidebarOpen', () => { it('returns true when activeId is not equal to 0', () => { const state = { diff --git a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js index b2a25bc93ea..002fe7c6e71 100644 --- a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js +++ b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js @@ -4,9 +4,11 @@ import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_i import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error'; import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes, { +import { HTTP_STATUS_CONFLICT, HTTP_STATUS_METHOD_NOT_ALLOWED, + HTTP_STATUS_NOT_FOUND, + HTTP_STATUS_OK, } from '~/lib/utils/http_status'; jest.mock('~/captcha/wait_for_captcha_to_be_solved'); @@ -46,7 +48,7 @@ describe('registerCaptchaModalInterceptor', () => { } = config.headers; if (captchaResponse === CAPTCHA_RESPONSE && spamLogId === SPAM_LOG_ID) { - return [httpStatusCodes.OK, { ...data, method: config.method, CAPTCHA_SUCCESS }]; + return [HTTP_STATUS_OK, { ...data, method: config.method, CAPTCHA_SUCCESS }]; } return [HTTP_STATUS_CONFLICT, NEEDS_CAPTCHA_RESPONSE]; @@ -64,7 +66,7 @@ describe('registerCaptchaModalInterceptor', () => { it('successful requests are passed through', async () => { const { data, status } = await axios[method]('/endpoint-without-captcha'); - expect(status).toEqual(httpStatusCodes.OK); + expect(status).toEqual(HTTP_STATUS_OK); expect(data).toEqual(AXIOS_RESPONSE); expect(mock.history[method]).toHaveLength(1); }); @@ -73,7 +75,7 @@ describe('registerCaptchaModalInterceptor', () => { await expect(() => axios[method]('/endpoint-with-unrelated-error')).rejects.toThrow( expect.objectContaining({ response: expect.objectContaining({ - status: httpStatusCodes.NOT_FOUND, + status: HTTP_STATUS_NOT_FOUND, data: AXIOS_RESPONSE, }), }), diff --git a/spec/frontend/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 2210b0f48d6..e4abedb412f 100644 --- a/spec/frontend/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,6 +1,6 @@ import $ from 'jquery'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import VariableList from '~/ci_variable_list/ci_variable_list'; +import VariableList from '~/ci/ci_variable_list/ci_variable_list'; const HIDE_CLASS = 'hide'; diff --git a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js index 57f666e29d6..71e8e6d3afb 100644 --- a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; +import setupNativeFormVariableList from '~/ci/ci_variable_list/native_form_variable_list'; describe('NativeFormVariableList', () => { let $wrapper; diff --git a/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_admin_variables_spec.js index aa83638773d..5e0c35c9f90 100644 --- a/spec/frontend/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,7 @@ import { shallowMount } from '@vue/test-utils'; -import ciAdminVariables from '~/ci_variable_list/components/ci_admin_variables.vue'; -import ciVariableShared from '~/ci_variable_list/components/ci_variable_shared.vue'; +import ciAdminVariables from '~/ci/ci_variable_list/components/ci_admin_variables.vue'; +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; describe('Ci Project Variable wrapper', () => { let wrapper; 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 new file mode 100644 index 00000000000..2fd395a1230 --- /dev/null +++ b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -0,0 +1,118 @@ +import { GlListboxItem, GlCollapsibleListbox, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { allEnvironments } 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 findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const findListboxItemByIndex = (index) => wrapper.findAllComponents(GlListboxItem).at(index); + const findActiveIconByIndex = (index) => findListboxItemByIndex(index).findComponent(GlIcon); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxText = () => findListbox().props('toggleText'); + const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem); + + const createComponent = ({ props = {}, searchTerm = '' } = {}) => { + wrapper = mount(CiEnvironmentsDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + }); + + findListbox().vm.$emit('search', searchTerm); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('No environments found', () => { + beforeEach(() => { + createComponent({ searchTerm: 'stable' }); + }); + + it('renders create button with search term if environments do not contain search term', () => { + const button = findCreateWildcardButton(); + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Create wildcard: stable'); + }); + }); + + describe('Search term is empty', () => { + beforeEach(() => { + createComponent({ props: { environments: envs } }); + }); + + 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('does not display active checkmark on the inactive stage', () => { + expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); + }); + }); + + describe('when `*` is the value of selectedEnvironmentScope props', () => { + const wildcardScope = '*'; + + beforeEach(() => { + createComponent({ props: { selectedEnvironmentScope: wildcardScope } }); + }); + + it('shows the `All environments` text and not the wildcard', () => { + expect(findListboxText()).toContain(allEnvironments.text); + expect(findListboxText()).not.toContain(wildcardScope); + }); + }); + + describe('Environments found', () => { + const currentEnv = envs[2]; + + beforeEach(() => { + createComponent({ searchTerm: currentEnv }); + }); + + it('renders only the environment searched for', () => { + expect(findAllListboxItems()).toHaveLength(1); + expect(findListboxItemByIndex(0).text()).toBe(currentEnv); + }); + + it('does not display create button', () => { + expect(findCreateWildcardButton().exists()).toBe(false); + }); + + describe('Custom events', () => { + describe('when selecting an environment', () => { + const itemIndex = 0; + + beforeEach(() => { + createComponent(); + }); + + it('emits `select-environment` when an environment is clicked', () => { + findListbox().vm.$emit('select', envs[itemIndex]); + 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_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js index ef624d8e4b4..3f1eebbc6a5 100644 --- a/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import ciGroupVariables from '~/ci_variable_list/components/ci_group_variables.vue'; -import ciVariableShared from '~/ci_variable_list/components/ci_variable_shared.vue'; +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 { GRAPHQL_GROUP_TYPE } from '~/ci_variable_list/constants'; +import { GRAPHQL_GROUP_TYPE } from '~/ci/ci_variable_list/constants'; const mockProvide = { glFeatures: { diff --git a/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js index 53c25e430f2..7230017c560 100644 --- a/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import ciProjectVariables from '~/ci_variable_list/components/ci_project_variables.vue'; -import ciVariableShared from '~/ci_variable_list/components/ci_variable_shared.vue'; +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 { GRAPHQL_PROJECT_TYPE } from '~/ci_variable_list/constants'; +import { GRAPHQL_PROJECT_TYPE } from '~/ci/ci_variable_list/constants'; const mockProvide = { projectFullPath: '/namespace/project', diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js index d177e755591..7838e4884d8 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js @@ -1,8 +1,8 @@ import { GlButton, GlFormInput } from '@gitlab/ui'; import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; -import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; -import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; +import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; +import CiVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue'; import { ADD_VARIABLE_ACTION, AWS_ACCESS_KEY_ID, @@ -12,7 +12,7 @@ import { ENVIRONMENT_SCOPE_LINK_TITLE, instanceString, variableOptions, -} from '~/ci_variable_list/constants'; +} from '~/ci/ci_variable_list/constants'; import { mockVariablesWithScopes } from '../mocks'; import ModalStub from '../stubs'; diff --git a/spec/frontend/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js index 5e459ee390f..32af2ec4de9 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_settings_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js @@ -1,14 +1,14 @@ import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; -import CiVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue'; -import ciVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; -import ciVariableTable from '~/ci_variable_list/components/ci_variable_table.vue'; +import CiVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; +import ciVariableModal from '~/ci/ci_variable_list/components/ci_variable_modal.vue'; +import ciVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; import { ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION, projectString, -} from '~/ci_variable_list/constants'; -import { mapEnvironmentNames } from '~/ci_variable_list/utils'; +} from '~/ci/ci_variable_list/constants'; +import { mapEnvironmentNames } from '~/ci/ci_variable_list/utils'; import { mockEnvs, mockVariablesWithScopes, newVariable } from '../mocks'; diff --git a/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js index 65a58a1647f..2d39bff8ce0 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js @@ -5,16 +5,16 @@ import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; -import { resolvers } from '~/ci_variable_list/graphql/settings'; +import { resolvers } from '~/ci/ci_variable_list/graphql/settings'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import ciVariableShared from '~/ci_variable_list/components/ci_variable_shared.vue'; -import ciVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue'; -import ciVariableTable from '~/ci_variable_list/components/ci_variable_table.vue'; -import getProjectEnvironments from '~/ci_variable_list/graphql/queries/project_environments.query.graphql'; -import getAdminVariables from '~/ci_variable_list/graphql/queries/variables.query.graphql'; -import getGroupVariables from '~/ci_variable_list/graphql/queries/group_variables.query.graphql'; -import getProjectVariables from '~/ci_variable_list/graphql/queries/project_variables.query.graphql'; +import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue'; +import ciVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue'; +import ciVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql'; +import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql'; +import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; +import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql'; import { ADD_MUTATION_ACTION, @@ -23,7 +23,7 @@ import { environmentFetchErrorText, genericMutationErrorText, variableFetchErrorText, -} from '~/ci_variable_list/constants'; +} from '~/ci/ci_variable_list/constants'; import { createGroupProps, diff --git a/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js index 9891bc397b6..9e2508c56ee 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js @@ -1,8 +1,8 @@ import { GlAlert } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import CiVariableTable from '~/ci_variable_list/components/ci_variable_table.vue'; -import { EXCEEDS_VARIABLE_LIMIT_TEXT, projectString } from '~/ci_variable_list/constants'; +import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; +import { EXCEEDS_VARIABLE_LIMIT_TEXT, projectString } from '~/ci/ci_variable_list/constants'; import { mockVariables } from '../mocks'; describe('Ci variable table', () => { diff --git a/spec/frontend/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js index 065e9fa6667..4da4f53f69f 100644 --- a/spec/frontend/ci_variable_list/mocks.js +++ b/spec/frontend/ci/ci_variable_list/mocks.js @@ -6,22 +6,22 @@ import { groupString, instanceString, projectString, -} from '~/ci_variable_list/constants'; - -import addAdminVariable from '~/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql'; -import deleteAdminVariable from '~/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql'; -import updateAdminVariable from '~/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql'; -import addGroupVariable from '~/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql'; -import deleteGroupVariable from '~/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql'; -import updateGroupVariable from '~/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql'; -import addProjectVariable from '~/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql'; -import deleteProjectVariable from '~/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql'; -import updateProjectVariable from '~/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql'; - -import getAdminVariables from '~/ci_variable_list/graphql/queries/variables.query.graphql'; -import getGroupVariables from '~/ci_variable_list/graphql/queries/group_variables.query.graphql'; -import getProjectEnvironments from '~/ci_variable_list/graphql/queries/project_environments.query.graphql'; -import getProjectVariables from '~/ci_variable_list/graphql/queries/project_variables.query.graphql'; +} from '~/ci/ci_variable_list/constants'; + +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 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'; +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'; + +import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql'; +import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql'; +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'; export const devName = 'dev'; export const prodName = 'prod'; diff --git a/spec/frontend/ci_variable_list/services/mock_data.js b/spec/frontend/ci/ci_variable_list/services/mock_data.js index 44f4db93c63..44f4db93c63 100644 --- a/spec/frontend/ci_variable_list/services/mock_data.js +++ b/spec/frontend/ci/ci_variable_list/services/mock_data.js diff --git a/spec/frontend/ci_variable_list/stubs.js b/spec/frontend/ci/ci_variable_list/stubs.js index 5769d6190f6..5769d6190f6 100644 --- a/spec/frontend/ci_variable_list/stubs.js +++ b/spec/frontend/ci/ci_variable_list/stubs.js diff --git a/spec/frontend/ci_variable_list/utils_spec.js b/spec/frontend/ci/ci_variable_list/utils_spec.js index 081c399792f..beeae71376a 100644 --- a/spec/frontend/ci_variable_list/utils_spec.js +++ b/spec/frontend/ci/ci_variable_list/utils_spec.js @@ -2,8 +2,8 @@ import { createJoinedEnvironments, convertEnvironmentScope, mapEnvironmentNames, -} from '~/ci_variable_list/utils'; -import { allEnvironments } from '~/ci_variable_list/constants'; +} from '~/ci/ci_variable_list/utils'; +import { allEnvironments } from '~/ci/ci_variable_list/constants'; describe('utils', () => { const environments = ['dev', 'prod']; diff --git a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js index 63e23c41263..ec987be8cb8 100644 --- a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js @@ -26,14 +26,13 @@ describe('Pipeline Editor | Text editor component', () => { props: ['value', 'fileName', 'editorOptions', 'debounceValue'], }; - const createComponent = (glFeatures = {}, mountFn = shallowMount) => { + const createComponent = (mountFn = shallowMount) => { wrapper = mountFn(TextEditor, { provide: { projectPath: mockProjectPath, projectNamespace: mockProjectNamespace, ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, - glFeatures, }, propsData: { commitSha: mockCommitSha, @@ -107,28 +106,14 @@ describe('Pipeline Editor | Text editor component', () => { }); describe('CI schema', () => { - describe('when `schema_linting` feature flag is on', () => { - beforeEach(() => { - createComponent({ schemaLinting: true }); - findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); - }); - - it('configures editor with syntax highlight', () => { - expect(mockUse).toHaveBeenCalledTimes(1); - expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); - }); + beforeEach(() => { + createComponent(); + findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); }); - describe('when `schema_linting` feature flag is off', () => { - beforeEach(() => { - createComponent(); - findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); - }); - - it('does not call the register CI schema function', () => { - expect(mockUse).not.toHaveBeenCalled(); - expect(mockRegisterCiSchema).not.toHaveBeenCalled(); - }); + it('configures editor with syntax highlight', () => { + expect(mockUse).toHaveBeenCalledTimes(1); + expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js index e54c72a758f..6a6cc3a14de 100644 --- a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import { mockLintResponse } from '../mock_data'; @@ -20,7 +20,7 @@ describe('~/ci/pipeline_editor/graphql/resolvers', () => { beforeEach(async () => { mock = new MockAdapter(axios); - mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse); + mock.onPost(endpoint).reply(HTTP_STATUS_OK, mockLintResponse); result = await resolvers.Mutation.lintCI(null, { endpoint, diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js index 2360dd7d103..cd16045f92d 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/ci/pipeline_new/components/pipeline_new_form_spec.js @@ -8,12 +8,16 @@ import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_help import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { + HTTP_STATUS_BAD_REQUEST, + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_OK, +} from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; -import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; -import ciConfigVariablesQuery from '~/pipeline_new/graphql/queries/ci_config_variables.graphql'; -import { resolvers } from '~/pipeline_new/graphql/resolvers'; -import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; +import PipelineNewForm from '~/ci/pipeline_new/components/pipeline_new_form.vue'; +import ciConfigVariablesQuery from '~/ci/pipeline_new/graphql/queries/ci_config_variables.graphql'; +import { resolvers } from '~/ci/pipeline_new/graphql/resolvers'; +import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue'; import { mockCreditCardValidationRequiredError, mockCiConfigVariablesResponse, @@ -108,7 +112,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock = new MockAdapter(axios); mockCiConfigVariables = jest.fn(); - mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs); + mock.onGet(projectRefsEndpoint).reply(HTTP_STATUS_OK, mockRefs); dummySubmitEvent = { preventDefault: jest.fn(), @@ -173,7 +177,7 @@ describe('Pipeline New Form', () => { describe('Pipeline creation', () => { beforeEach(async () => { mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); + mock.onPost(pipelinesPath).reply(HTTP_STATUS_OK, newPipelinePostResponse); }); it('does not submit the native HTML form', async () => { @@ -365,7 +369,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock .onGet(projectRefsEndpoint, { params: { search: '' } }) - .reply(httpStatusCodes.INTERNAL_SERVER_ERROR); + .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); findRefsDropdown().vm.$emit('loadingError'); }); @@ -378,7 +382,7 @@ describe('Pipeline New Form', () => { describe('when the error response can be handled', () => { beforeEach(async () => { - mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError); + mock.onPost(pipelinesPath).reply(HTTP_STATUS_BAD_REQUEST, mockError); findForm().vm.$emit('submit', dummySubmitEvent); @@ -416,7 +420,7 @@ describe('Pipeline New Form', () => { beforeEach(async () => { mock .onPost(pipelinesPath) - .reply(httpStatusCodes.BAD_REQUEST, mockCreditCardValidationRequiredError); + .reply(HTTP_STATUS_BAD_REQUEST, mockCreditCardValidationRequiredError); window.gon = { subscriptions_url: TEST_HOST, @@ -449,9 +453,7 @@ describe('Pipeline New Form', () => { describe('when the error response cannot be handled', () => { beforeEach(async () => { - mock - .onPost(pipelinesPath) - .reply(httpStatusCodes.INTERNAL_SERVER_ERROR, 'something went wrong'); + mock.onPost(pipelinesPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, 'something went wrong'); findForm().vm.$emit('submit', dummySubmitEvent); diff --git a/spec/frontend/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js index 8cba876c688..cf8009e388f 100644 --- a/spec/frontend/pipeline_new/components/refs_dropdown_spec.js +++ b/spec/frontend/ci/pipeline_new/components/refs_dropdown_spec.js @@ -1,13 +1,13 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlListbox, GlListboxItem } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; +import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue'; -import { mockRefs, mockFilteredRefs } from '../mock_data'; +import { mockBranches, mockRefs, mockFilteredRefs, mockTags } from '../mock_data'; const projectRefsEndpoint = '/root/project/refs'; const refShortName = 'main'; @@ -19,11 +19,12 @@ describe('Pipeline New Form', () => { let wrapper; let mock; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findRefsDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdown = () => wrapper.findComponent(GlListbox); + const findRefsDropdownItems = () => wrapper.findAllComponents(GlListboxItem); + const findSearchBox = () => wrapper.findByTestId('listbox-search-input'); + const findListboxGroups = () => wrapper.findAll('ul[role="group"]'); - const createComponent = (props = {}, mountFn = shallowMount) => { + const createComponent = (props = {}, mountFn = shallowMountExtended) => { wrapper = mountFn(RefsDropdown, { provide: { projectRefsEndpoint, @@ -40,22 +41,15 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(httpStatusCodes.OK, mockRefs); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - - mock.restore(); + mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockRefs); }); beforeEach(() => { createComponent(); }); - it('displays empty dropdown initially', async () => { - await findDropdown().vm.$emit('show'); + it('displays empty dropdown initially', () => { + findDropdown().vm.$emit('shown'); expect(findRefsDropdownItems()).toHaveLength(0); }); @@ -66,19 +60,19 @@ describe('Pipeline New Form', () => { describe('when user opens dropdown', () => { beforeEach(async () => { - await findDropdown().vm.$emit('show'); + createComponent({}, mountExtended); + findDropdown().vm.$emit('shown'); await waitForPromises(); }); - it('requests unfiltered tags and branches', async () => { + it('requests unfiltered tags and branches', () => { expect(mock.history.get).toHaveLength(1); expect(mock.history.get[0].url).toBe(projectRefsEndpoint); expect(mock.history.get[0].params).toEqual({ search: '' }); }); - it('displays dropdown with branches and tags', async () => { + it('displays dropdown with branches and tags', () => { const refLength = mockRefs.Tags.length + mockRefs.Branches.length; - expect(findRefsDropdownItems()).toHaveLength(refLength); }); @@ -99,7 +93,8 @@ describe('Pipeline New Form', () => { const selectedIndex = 1; beforeEach(async () => { - await findRefsDropdownItems().at(selectedIndex).vm.$emit('click'); + findRefsDropdownItems().at(selectedIndex).vm.$emit('select', 'refs/heads/branch-1'); + await waitForPromises(); }); it('component emits @input', () => { @@ -116,7 +111,7 @@ describe('Pipeline New Form', () => { beforeEach(async () => { mock .onGet(projectRefsEndpoint, { params: { search: mockSearchTerm } }) - .reply(httpStatusCodes.OK, mockFilteredRefs); + .reply(HTTP_STATUS_OK, mockFilteredRefs); await findSearchBox().vm.$emit('input', mockSearchTerm); await waitForPromises(); @@ -147,20 +142,23 @@ describe('Pipeline New Form', () => { .onGet(projectRefsEndpoint, { params: { ref: mockFullName }, }) - .reply(httpStatusCodes.OK, mockRefs); - - createComponent({ - value: { - shortName: mockShortName, - fullName: mockFullName, + .reply(HTTP_STATUS_OK, mockRefs); + + createComponent( + { + value: { + shortName: mockShortName, + fullName: mockFullName, + }, }, - }); - await findDropdown().vm.$emit('show'); + mountExtended, + ); + findDropdown().vm.$emit('shown'); await waitForPromises(); }); it('branch is checked', () => { - expect(findRefsDropdownItems().at(selectedIndex).props('isChecked')).toBe(true); + expect(findRefsDropdownItems().at(selectedIndex).props('isSelected')).toBe(true); }); }); @@ -168,9 +166,9 @@ describe('Pipeline New Form', () => { beforeEach(async () => { mock .onGet(projectRefsEndpoint, { params: { search: '' } }) - .reply(httpStatusCodes.INTERNAL_SERVER_ERROR); + .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - await findDropdown().vm.$emit('show'); + findDropdown().vm.$emit('shown'); await waitForPromises(); }); @@ -179,4 +177,25 @@ describe('Pipeline New Form', () => { expect(wrapper.emitted('loadingError')[0]).toEqual([expect.any(Error)]); }); }); + + describe('should display branches and tags based on its length', () => { + it.each` + mockData | expectedGroupLength | expectedListboxItemsLength + ${{ ...mockBranches, Tags: [] }} | ${1} | ${mockBranches.Branches.length} + ${{ Branches: [], ...mockTags }} | ${1} | ${mockTags.Tags.length} + ${{ ...mockRefs }} | ${2} | ${mockBranches.Branches.length + mockTags.Tags.length} + ${{ Branches: undefined, Tags: undefined }} | ${0} | ${0} + `( + 'should render branches and tags based on presence', + async ({ mockData, expectedGroupLength, expectedListboxItemsLength }) => { + mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(HTTP_STATUS_OK, mockData); + createComponent({}, mountExtended); + findDropdown().vm.$emit('shown'); + await waitForPromises(); + + expect(findListboxGroups()).toHaveLength(expectedGroupLength); + expect(findRefsDropdownItems()).toHaveLength(expectedListboxItemsLength); + }, + ); + }); }); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/ci/pipeline_new/mock_data.js index 2af0ef4d7c4..dfb643a0ba4 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/ci/pipeline_new/mock_data.js @@ -1,8 +1,16 @@ -export const mockRefs = { +export const mockBranches = { Branches: ['main', 'branch-1', 'branch-2'], +}; + +export const mockTags = { Tags: ['1.0.0', '1.1.0', '1.2.0'], }; +export const mockRefs = { + ...mockBranches, + ...mockTags, +}; + export const mockFilteredRefs = { Branches: ['branch-1'], Tags: ['1.0.0', '1.1.0'], diff --git a/spec/frontend/pipeline_new/utils/filter_variables_spec.js b/spec/frontend/ci/pipeline_new/utils/filter_variables_spec.js index 42bc6244456..d1b89704b58 100644 --- a/spec/frontend/pipeline_new/utils/filter_variables_spec.js +++ b/spec/frontend/ci/pipeline_new/utils/filter_variables_spec.js @@ -1,4 +1,4 @@ -import filterVariables from '~/pipeline_new/utils/filter_variables'; +import filterVariables from '~/ci/pipeline_new/utils/filter_variables'; import { mockVariables } from '../mock_data'; describe('Filter variables utility function', () => { diff --git a/spec/frontend/ci/pipeline_new/utils/format_refs_spec.js b/spec/frontend/ci/pipeline_new/utils/format_refs_spec.js new file mode 100644 index 00000000000..137a9339649 --- /dev/null +++ b/spec/frontend/ci/pipeline_new/utils/format_refs_spec.js @@ -0,0 +1,82 @@ +import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '~/ci/pipeline_new/constants'; +import { + formatRefs, + formatListBoxItems, + searchByFullNameInListboxOptions, +} from '~/ci/pipeline_new/utils/format_refs'; +import { mockBranchRefs, mockTagRefs } from '../mock_data'; + +describe('Format refs util', () => { + it('formats branch ref correctly', () => { + expect(formatRefs(mockBranchRefs, BRANCH_REF_TYPE)).toEqual([ + { fullName: 'refs/heads/main', shortName: 'main' }, + { fullName: 'refs/heads/dev', shortName: 'dev' }, + { fullName: 'refs/heads/release', shortName: 'release' }, + ]); + }); + + it('formats tag ref correctly', () => { + expect(formatRefs(mockTagRefs, TAG_REF_TYPE)).toEqual([ + { fullName: 'refs/tags/1.0.0', shortName: '1.0.0' }, + { fullName: 'refs/tags/1.1.0', shortName: '1.1.0' }, + { fullName: 'refs/tags/1.2.0', shortName: '1.2.0' }, + ]); + }); +}); + +describe('formatListBoxItems', () => { + it('formats branches and tags to listbox items correctly', () => { + expect(formatListBoxItems(mockBranchRefs, mockTagRefs)).toEqual([ + { + text: 'Branches', + options: [ + { value: 'refs/heads/main', text: 'main' }, + { value: 'refs/heads/dev', text: 'dev' }, + { value: 'refs/heads/release', text: 'release' }, + ], + }, + { + text: 'Tags', + options: [ + { value: 'refs/tags/1.0.0', text: '1.0.0' }, + { value: 'refs/tags/1.1.0', text: '1.1.0' }, + { value: 'refs/tags/1.2.0', text: '1.2.0' }, + ], + }, + ]); + + expect(formatListBoxItems(mockBranchRefs, [])).toEqual([ + { + text: 'Branches', + options: [ + { value: 'refs/heads/main', text: 'main' }, + { value: 'refs/heads/dev', text: 'dev' }, + { value: 'refs/heads/release', text: 'release' }, + ], + }, + ]); + + expect(formatListBoxItems([], mockTagRefs)).toEqual([ + { + text: 'Tags', + options: [ + { value: 'refs/tags/1.0.0', text: '1.0.0' }, + { value: 'refs/tags/1.1.0', text: '1.1.0' }, + { value: 'refs/tags/1.2.0', text: '1.2.0' }, + ], + }, + ]); + }); +}); + +describe('searchByFullNameInListboxOptions', () => { + const listbox = formatListBoxItems(mockBranchRefs, mockTagRefs); + + it.each` + fullName | expectedResult + ${'refs/heads/main'} | ${{ fullName: 'refs/heads/main', shortName: 'main' }} + ${'refs/tags/1.0.0'} | ${{ fullName: 'refs/tags/1.0.0', shortName: '1.0.0' }} + `('should search item in listbox correctly', ({ fullName, expectedResult }) => { + expect(searchByFullNameInListboxOptions(fullName, listbox)).toEqual(expectedResult); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js index 4aa4cdf89a1..611993556e3 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { trimText } from 'helpers/text_helper'; @@ -10,13 +10,16 @@ import DeletePipelineScheduleModal from '~/ci/pipeline_schedules/components/dele import TakeOwnershipModal from '~/ci/pipeline_schedules/components/take_ownership_modal.vue'; import PipelineSchedulesTable from '~/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue'; import deletePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql'; +import playPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '~/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; import { mockGetPipelineSchedulesGraphQLResponse, mockPipelineScheduleNodes, deleteMutationResponse, + playMutationResponse, takeOwnershipMutationResponse, + emptyPipelineSchedulesResponse, } from '../mock_data'; Vue.use(VueApollo); @@ -29,10 +32,13 @@ describe('Pipeline schedules app', () => { let wrapper; const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); + const successEmptyHandler = jest.fn().mockResolvedValue(emptyPipelineSchedulesResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); const deleteMutationHandlerSuccess = jest.fn().mockResolvedValue(deleteMutationResponse); const deleteMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const playMutationHandlerSuccess = jest.fn().mockResolvedValue(playMutationResponse); + const playMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); const takeOwnershipMutationHandlerSuccess = jest .fn() .mockResolvedValue(takeOwnershipMutationResponse); @@ -60,14 +66,18 @@ describe('Pipeline schedules app', () => { const findTable = () => wrapper.findComponent(PipelineSchedulesTable); const findAlert = () => wrapper.findComponent(GlAlert); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDeleteModal = () => wrapper.findComponent(DeletePipelineScheduleModal); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findTakeOwnershipModal = () => wrapper.findComponent(TakeOwnershipModal); const findTabs = () => wrapper.findComponent(GlTabs); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findLink = () => wrapper.findComponent(GlLink); const findNewButton = () => wrapper.findByTestId('new-schedule-button'); const findAllTab = () => wrapper.findByTestId('pipeline-schedules-all-tab'); const findActiveTab = () => wrapper.findByTestId('pipeline-schedules-active-tab'); const findInactiveTab = () => wrapper.findByTestId('pipeline-schedules-inactive-tab'); + const findSchedulesCharacteristics = () => + wrapper.findByTestId('pipeline-schedules-characteristics'); afterEach(() => { wrapper.destroy(); @@ -181,6 +191,45 @@ describe('Pipeline schedules app', () => { }); }); + describe('playing a pipeline schedule', () => { + it('shows play mutation error alert', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [playPipelineScheduleMutation, playMutationHandlerFailed], + ]); + + await waitForPromises(); + + findTable().vm.$emit('playPipelineSchedule'); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was a problem playing the pipeline schedule.'); + }); + + it('plays pipeline schedule', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [playPipelineScheduleMutation, playMutationHandlerSuccess], + ]); + + await waitForPromises(); + + const scheduleId = mockPipelineScheduleNodes[0].id; + + findTable().vm.$emit('playPipelineSchedule', scheduleId); + + await waitForPromises(); + + expect(playMutationHandlerSuccess).toHaveBeenCalledWith({ + id: scheduleId, + }); + expect(findAlert().text()).toBe( + 'Successfully scheduled a pipeline to run. Go to the Pipelines page for details.', + ); + }); + }); + describe('taking ownership of a pipeline schedule', () => { it('shows take ownership mutation error alert', async () => { createComponent([ @@ -277,4 +326,24 @@ describe('Pipeline schedules app', () => { expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalledTimes(1); }); }); + + describe('Empty pipeline schedules response', () => { + it('should show an empty state', async () => { + createComponent([[getPipelineSchedulesQuery, successEmptyHandler]]); + + await waitForPromises(); + + const schedulesCharacteristics = findSchedulesCharacteristics(); + + expect(findEmptyState().exists()).toBe(true); + expect(schedulesCharacteristics.text()).toContain('Runs for a specific branch or tag.'); + expect(schedulesCharacteristics.text()).toContain('Can have custom CI/CD variables.'); + expect(schedulesCharacteristics.text()).toContain( + 'Runs with the same project permissions as the schedule owner.', + ); + + expect(findLink().exists()).toBe(true); + expect(findLink().text()).toContain('scheduled pipelines documentation.'); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js index 3364c61d155..6fb6a8bc33b 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js @@ -25,6 +25,7 @@ describe('Pipeline schedule actions', () => { const findAllButtons = () => wrapper.findAllComponents(GlButton); const findDeleteBtn = () => wrapper.findByTestId('delete-pipeline-schedule-btn'); const findTakeOwnershipBtn = () => wrapper.findByTestId('take-ownership-pipeline-schedule-btn'); + const findPlayScheduleBtn = () => wrapper.findByTestId('play-pipeline-schedule-btn'); afterEach(() => { wrapper.destroy(); @@ -61,4 +62,14 @@ describe('Pipeline schedule actions', () => { showTakeOwnershipModal: [[mockTakeOwnershipNodes[0].id]], }); }); + + it('play button emits playPipelineSchedule event and schedule id', () => { + createComponent(); + + findPlayScheduleBtn().vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({ + playPipelineSchedule: [[mockPipelineScheduleNodes[0].id]], + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js index 17bf465baf3..0821c59c8a0 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js @@ -1,5 +1,5 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import PipelineScheduleLastPipeline from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue'; import { mockPipelineScheduleNodes } from '../../../mock_data'; @@ -18,7 +18,7 @@ describe('Pipeline schedule last pipeline', () => { }); }; - const findCIBadge = () => wrapper.findComponent(CiBadge); + const findCIBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findStatusText = () => wrapper.findByTestId('pipeline-schedule-status-text'); afterEach(() => { @@ -28,8 +28,10 @@ describe('Pipeline schedule last pipeline', () => { it('displays pipeline status', () => { createComponent(); - expect(findCIBadge().exists()).toBe(true); - expect(findCIBadge().props('status')).toBe(defaultProps.schedule.lastPipeline.detailedStatus); + expect(findCIBadgeLink().exists()).toBe(true); + expect(findCIBadgeLink().props('status')).toBe( + defaultProps.schedule.lastPipeline.detailedStatus, + ); expect(findStatusText().exists()).toBe(false); }); @@ -37,6 +39,6 @@ describe('Pipeline schedule last pipeline', () => { createComponent({ schedule: mockPipelineScheduleNodes[0] }); expect(findStatusText().text()).toBe('None'); - expect(findCIBadge().exists()).toBe(false); + expect(findCIBadgeLink().exists()).toBe(false); }); }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 3010f1d06c3..2826c054249 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -32,6 +32,14 @@ export const mockPipelineScheduleNodes = nodes; export const mockPipelineScheduleAsGuestNodes = guestNodes; export const mockTakeOwnershipNodes = takeOwnershipNodes; +export const emptyPipelineSchedulesResponse = { + data: { + project: { + id: 'gid://gitlab/Project/1', + pipelineSchedules: { nodes: [], count: 0 }, + }, + }, +}; export const deleteMutationResponse = { data: { @@ -43,6 +51,16 @@ export const deleteMutationResponse = { }, }; +export const playMutationResponse = { + data: { + pipelineSchedulePlay: { + clientMutationId: null, + errors: [], + __typename: 'PipelineSchedulePlayPayload', + }, + }, +}; + export const takeOwnershipMutationResponse = { data: { pipelineScheduleTakeOwnership: { 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 cb46c668930..0ecafdd7d83 100644 --- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -13,12 +13,12 @@ import RegistrationTokenResetDropdownItem from '~/ci/runner/components/registrat import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; -import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; -import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; +import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql'; import { - mockGraphqlRunnerPlatforms, - mockGraphqlInstructions, + mockRunnerPlatforms, + mockInstructions, } from 'jest/vue_shared/components/runner_instructions/mock_data'; const mockToken = '0123456789'; @@ -67,8 +67,8 @@ describe('RegistrationDropdown', () => { const createComponentWithModal = () => { const requestHandlers = [ - [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], - [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockRunnerPlatforms)], + [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockInstructions)], ]; createComponent( diff --git a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js deleted file mode 100644 index e9966576cab..00000000000 --- a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js +++ /dev/null @@ -1,139 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlIcon, GlSearchBoxByType } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { allEnvironments } from '~/ci_variable_list/constants'; -import CiEnvironmentsDropdown from '~/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 findDropdownText = () => wrapper.findComponent(GlDropdown).text(); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); - const findActiveIconByIndex = (index) => findDropdownItemByIndex(index).findComponent(GlIcon); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); - - const createComponent = ({ props = {}, searchTerm = '' } = {}) => { - wrapper = mount(CiEnvironmentsDropdown, { - propsData: { - ...defaultProps, - ...props, - }, - }); - - findSearchBox().vm.$emit('input', searchTerm); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('No environments found', () => { - beforeEach(() => { - createComponent({ searchTerm: 'stable' }); - }); - - it('renders create button with search term if environments do not contain search term', () => { - expect(findAllDropdownItems()).toHaveLength(2); - expect(findDropdownItemByIndex(1).text()).toBe('Create wildcard: stable'); - }); - - it('renders empty results message', () => { - expect(findDropdownItemByIndex(0).text()).toBe('No matching results'); - }); - }); - - describe('Search term is empty', () => { - beforeEach(() => { - createComponent({ props: { environments: envs } }); - }); - - it('renders all environments when search term is empty', () => { - expect(findAllDropdownItems()).toHaveLength(3); - expect(findDropdownItemByIndex(0).text()).toBe(envs[0]); - expect(findDropdownItemByIndex(1).text()).toBe(envs[1]); - expect(findDropdownItemByIndex(2).text()).toBe(envs[2]); - }); - - it('should not display active checkmark on the inactive stage', () => { - expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); - }); - }); - - describe('when `*` is the value of selectedEnvironmentScope props', () => { - const wildcardScope = '*'; - - beforeEach(() => { - createComponent({ props: { selectedEnvironmentScope: wildcardScope } }); - }); - - it('shows the `All environments` text and not the wildcard', () => { - expect(findDropdownText()).toContain(allEnvironments.text); - expect(findDropdownText()).not.toContain(wildcardScope); - }); - }); - - describe('Environments found', () => { - const currentEnv = envs[2]; - - beforeEach(async () => { - createComponent({ searchTerm: currentEnv }); - await nextTick(); - }); - - it('renders only the environment searched for', () => { - expect(findAllDropdownItems()).toHaveLength(1); - expect(findDropdownItemByIndex(0).text()).toBe(currentEnv); - }); - - it('should not display create button', () => { - const environments = findAllDropdownItems().filter((env) => env.text().startsWith('Create')); - expect(environments).toHaveLength(0); - expect(findAllDropdownItems()).toHaveLength(1); - }); - - it('should not display empty results message', () => { - expect(wrapper.findComponent({ ref: 'noMatchingResults' }).exists()).toBe(false); - }); - - it('should clear the search term when showing the dropdown', () => { - wrapper.findComponent(GlDropdown).trigger('click'); - - expect(findSearchBox().text()).toBe(''); - }); - - describe('Custom events', () => { - describe('when clicking on an environment', () => { - const itemIndex = 0; - - beforeEach(() => { - createComponent(); - }); - - it('should emit `select-environment` if an environment is clicked', async () => { - await nextTick(); - - await findDropdownItemByIndex(itemIndex).vm.$emit('click'); - - 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('should emit createClicked if an environment is clicked', async () => { - await nextTick(); - findDropdownItemByIndex(1).vm.$emit('click'); - expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]); - }); - }); - }); - }); -}); diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index d89a238105b..6865b721441 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -7,7 +7,11 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; import PipelinesTable from '~/commit/pipelines/pipelines_table.vue'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { + HTTP_STATUS_BAD_REQUEST, + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_UNAUTHORIZED, +} from '~/lib/utils/http_status'; import { createAlert } from '~/flash'; import { TOAST_MESSAGE } from '~/pipelines/constants'; import axios from '~/lib/utils/axios_utils'; @@ -243,10 +247,10 @@ describe('Pipelines table in Commits and Merge requests', () => { 'An error occurred while trying to run a new pipeline for this merge request.'; it.each` - status | message - ${httpStatusCodes.BAD_REQUEST} | ${defaultMsg} - ${httpStatusCodes.UNAUTHORIZED} | ${permissionsMsg} - ${httpStatusCodes.INTERNAL_SERVER_ERROR} | ${defaultMsg} + status | message + ${HTTP_STATUS_BAD_REQUEST} | ${defaultMsg} + ${HTTP_STATUS_UNAUTHORIZED} | ${permissionsMsg} + ${HTTP_STATUS_INTERNAL_SERVER_ERROR} | ${defaultMsg} `('displays permissions error message', async ({ status, message }) => { const response = { response: { status } }; diff --git a/spec/frontend/constants_spec.js b/spec/frontend/constants_spec.js new file mode 100644 index 00000000000..b596b62f72c --- /dev/null +++ b/spec/frontend/constants_spec.js @@ -0,0 +1,30 @@ +import * as constants from '~/constants'; + +describe('Global JS constants', () => { + describe('getModifierKey()', () => { + afterEach(() => { + delete window.gl; + }); + + it.each` + isMac | removeSuffix | expectedKey + ${true} | ${false} | ${'⌘'} + ${false} | ${false} | ${'Ctrl+'} + ${true} | ${true} | ${'⌘'} + ${false} | ${true} | ${'Ctrl'} + `( + 'returns correct keystroke when isMac=$isMac and removeSuffix=$removeSuffix', + ({ isMac, removeSuffix, expectedKey }) => { + Object.assign(window, { + gl: { + client: { + isMac, + }, + }, + }); + + expect(constants.getModifierKey(removeSuffix)).toBe(expectedKey); + }, + ); + }); +}); diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js index 3ebb305afbf..5a725ac1ca4 100644 --- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; @@ -22,8 +22,6 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { const buildWrapper = (propsData = {}) => { wrapper = shallowMountExtended(ToolbarTextStyleDropdown, { stubs: { - GlDropdown, - GlDropdownItem, EditorStateObserver, }, provide: { @@ -35,7 +33,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { }, }); }; - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); beforeEach(() => { buildEditor(); @@ -48,9 +46,10 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { it('renders all text styles as dropdown items', () => { buildWrapper(); - TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle) => { - expect(wrapper.findByText(textStyle.label).exists()).toBe(true); + TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => { + expect(findListbox().props('items').at(index).text).toContain(textStyle.label); }); + expect(findListbox().props('items').length).toBe(TEXT_STYLE_DROPDOWN_ITEMS.length); }); describe('when there is an active item', () => { @@ -69,19 +68,11 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { }); it('displays the active text style label as the dropdown toggle text', () => { - expect(findDropdown().props().text).toBe(activeTextStyle.label); + expect(findListbox().props('toggleText')).toBe(activeTextStyle.label); }); it('sets dropdown as enabled', () => { - expect(findDropdown().props().disabled).toBe(false); - }); - - it('sets active item as active', () => { - const activeItem = wrapper - .findAllComponents(GlDropdownItem) - .filter((item) => item.text() === activeTextStyle.label) - .at(0); - expect(activeItem.props().isChecked).toBe(true); + expect(findListbox().props('disabled')).toBe(false); }); }); @@ -93,11 +84,11 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { }); it('sets dropdown as disabled', () => { - expect(findDropdown().props().disabled).toBe(true); + expect(findListbox().props('disabled')).toBe(true); }); it('sets dropdown toggle text to Text style', () => { - expect(findDropdown().props().text).toBe('Text style'); + expect(findListbox().props('toggleText')).toBe('Text style'); }); }); @@ -109,7 +100,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { const { editorCommand, commandParams } = textStyle; const commands = mockChainedCommands(tiptapEditor, [editorCommand, 'focus', 'run']); - wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click'); + findListbox().vm.$emit('select', TEXT_STYLE_DROPDOWN_ITEMS[index].label); expect(commands[editorCommand]).toHaveBeenCalledWith(commandParams || {}); expect(commands.focus).toHaveBeenCalled(); expect(commands.run).toHaveBeenCalled(); @@ -121,7 +112,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { buildWrapper(); const { contentType, commandParams } = textStyle; - wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click'); + findListbox().vm.$emit('select', TEXT_STYLE_DROPDOWN_ITEMS[index].label); expect(wrapper.emitted('execute')).toEqual([ [ { diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index d528096be34..6b804b3b4c6 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -8,7 +8,7 @@ import Video from '~/content_editor/extensions/video'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import { VARIANT_DANGER } from '~/flash'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, createDocBuilder } from '../test_utils'; import { @@ -132,7 +132,7 @@ describe('content_editor/extensions/attachment', () => { }; beforeEach(() => { - mock.onPost().reply(httpStatus.OK, successResponse); + mock.onPost().reply(HTTP_STATUS_OK, successResponse); }); it('inserts a media content with src set to the encoded content and uploading true', async () => { @@ -167,7 +167,7 @@ describe('content_editor/extensions/attachment', () => { describe('when uploading request fails', () => { beforeEach(() => { - mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); + mock.onPost().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); }); it('resets the doc to original state', async () => { @@ -209,7 +209,7 @@ describe('content_editor/extensions/attachment', () => { }; beforeEach(() => { - mock.onPost().reply(httpStatus.OK, successResponse); + mock.onPost().reply(HTTP_STATUS_OK, successResponse); }); it('inserts a loading mark', async () => { @@ -246,7 +246,7 @@ describe('content_editor/extensions/attachment', () => { describe('when uploading request fails', () => { beforeEach(() => { - mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); + mock.onPost().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); }); it('resets the doc to orginal state', async () => { diff --git a/spec/frontend/content_editor/extensions/link_spec.js b/spec/frontend/content_editor/extensions/link_spec.js index bb841357d37..ead898554d1 100644 --- a/spec/frontend/content_editor/extensions/link_spec.js +++ b/spec/frontend/content_editor/extensions/link_spec.js @@ -33,7 +33,7 @@ describe('content_editor/extensions/link', () => { ${'documentation](readme.md'} | ${() => p('documentation](readme.md')} ${'http://example.com '} | ${() => p(link({ href: 'http://example.com' }, 'http://example.com'))} ${'https://example.com '} | ${() => p(link({ href: 'https://example.com' }, 'https://example.com'))} - ${'www.example.com '} | ${() => p(link({ href: 'http://www.example.com' }, 'www.example.com'))} + ${'www.example.com '} | ${() => p(link({ href: 'www.example.com' }, 'www.example.com'))} ${'example.com/ab.html '} | ${() => p('example.com/ab.html')} ${'https://www.google.com '} | ${() => p(link({ href: 'https://www.google.com' }, 'https://www.google.com'))} `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js deleted file mode 100644 index 3930f47289a..00000000000 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ /dev/null @@ -1,16 +0,0 @@ -import path from 'path'; -import { describeMarkdownProcessing } from 'jest/content_editor/markdown_processing_spec_helper'; - -jest.mock('~/emoji'); - -const markdownYamlPath = path.join( - __dirname, - '..', - '..', - 'fixtures', - 'markdown', - 'markdown_golden_master_examples.yml', -); - -// See spec/fixtures/markdown/markdown_golden_master_examples.yml for documentation on how this spec works. -describeMarkdownProcessing('CE markdown processing in ContentEditor', markdownYamlPath); diff --git a/spec/frontend/content_editor/markdown_processing_spec_helper.js b/spec/frontend/content_editor/markdown_processing_spec_helper.js deleted file mode 100644 index 6f10f294fb0..00000000000 --- a/spec/frontend/content_editor/markdown_processing_spec_helper.js +++ /dev/null @@ -1,92 +0,0 @@ -import fs from 'fs'; -import jsYaml from 'js-yaml'; -import { memoize } from 'lodash'; -import MockAdapter from 'axios-mock-adapter'; -import axios from 'axios'; -import { createContentEditor } from '~/content_editor'; -import httpStatus from '~/lib/utils/http_status'; - -const getFocusedMarkdownExamples = memoize( - () => process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || [], -); - -const includeExample = ({ name }) => { - const focusedMarkdownExamples = getFocusedMarkdownExamples(); - if (!focusedMarkdownExamples.length) { - return true; - } - return focusedMarkdownExamples.includes(name); -}; - -const getPendingReason = (pendingStringOrObject) => { - if (!pendingStringOrObject) { - return null; - } - if (typeof pendingStringOrObject === 'string') { - return pendingStringOrObject; - } - if (pendingStringOrObject.frontend) { - return pendingStringOrObject.frontend; - } - - return null; -}; - -const loadMarkdownApiExamples = (markdownYamlPath) => { - const apiMarkdownYamlText = fs.readFileSync(markdownYamlPath); - const apiMarkdownExampleObjects = jsYaml.safeLoad(apiMarkdownYamlText); - - return apiMarkdownExampleObjects - .filter(includeExample) - .map(({ name, pending, markdown, html }) => [ - name, - { pendingReason: getPendingReason(pending), markdown, html }, - ]); -}; - -const testSerializesHtmlToMarkdownForElement = async ({ markdown, html }) => { - const mock = new MockAdapter(axios); - - // Ignore any API requests from the suggestions plugin - mock.onGet().reply(httpStatus.OK, []); - - const contentEditor = createContentEditor({ - // Overwrite renderMarkdown to always return this specific html - renderMarkdown: () => html, - }); - - await contentEditor.setSerializedContent(markdown); - - // This serializes the ContentEditor document, which was based on the HTML, to markdown - const serializedContent = contentEditor.getSerializedContent(); - - // Assert that the markdown we ended up with after sending it through all the ContentEditor - // plumbing matches the original markdown from the YAML. - expect(serializedContent.trim()).toBe(markdown.trim()); - - mock.restore(); -}; - -// describeMarkdownProcesssing -// -// This is used to dynamically generate examples (for both CE and EE) to ensure -// we generate same markdown that was provided to Markdown API. -// -// eslint-disable-next-line jest/no-export -export const describeMarkdownProcessing = (description, markdownYamlPath) => { - const examples = loadMarkdownApiExamples(markdownYamlPath); - - describe(description, () => { - describe.each(examples)('%s', (name, { pendingReason, ...example }) => { - const exampleName = 'correctly serializes HTML to markdown'; - if (pendingReason) { - it.todo(`${exampleName}: ${pendingReason}`); - return; - } - - it(`${exampleName}`, async () => { - await testSerializesHtmlToMarkdownForElement(example); - }); - }); - }); -}; diff --git a/spec/frontend/content_editor/markdown_snapshot_spec.js b/spec/frontend/content_editor/markdown_snapshot_spec.js index 146208bf8c7..fd64003420e 100644 --- a/spec/frontend/content_editor/markdown_snapshot_spec.js +++ b/spec/frontend/content_editor/markdown_snapshot_spec.js @@ -1,11 +1,96 @@ -import { describeMarkdownSnapshots } from 'jest/content_editor/markdown_snapshot_spec_helper'; - -jest.mock('~/emoji'); - // See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing // for documentation on this spec. // // NOTE: Unlike the backend markdown_snapshot_spec.rb which has a CE and EE version, there is only // one version of this spec. This is because the frontend markdown rendering does not require EE-only // backend features. -describeMarkdownSnapshots('markdown example snapshots in ContentEditor'); + +import jsYaml from 'js-yaml'; +import { pick } from 'lodash'; +import glfmExampleStatusYml from '../../../glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml'; +import markdownYml from '../../../glfm_specification/output_example_snapshots/markdown.yml'; +import htmlYml from '../../../glfm_specification/output_example_snapshots/html.yml'; +import prosemirrorJsonYml from '../../../glfm_specification/output_example_snapshots/prosemirror_json.yml'; +import { + IMPLEMENTATION_ERROR_MSG, + renderHtmlAndJsonForAllExamples, +} from './render_html_and_json_for_all_examples'; + +jest.mock('~/emoji'); + +const filterExamples = (examples) => { + const focusedMarkdownExamples = process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || []; + if (!focusedMarkdownExamples.length) { + return examples; + } + return pick(examples, focusedMarkdownExamples); +}; + +const loadExamples = (yaml) => { + const examples = jsYaml.safeLoad(yaml, {}); + return filterExamples(examples); +}; + +describe('markdown example snapshots in ContentEditor', () => { + let actualHtmlAndJsonExamples; + let skipRunningSnapshotWysiwygHtmlTests; + let skipRunningSnapshotProsemirrorJsonTests; + + const exampleStatuses = loadExamples(glfmExampleStatusYml); + const markdownExamples = loadExamples(markdownYml); + const expectedHtmlExamples = loadExamples(htmlYml); + const expectedProseMirrorJsonExamples = loadExamples(prosemirrorJsonYml); + const exampleNames = Object.keys(markdownExamples); + + beforeAll(async () => { + return renderHtmlAndJsonForAllExamples(markdownExamples).then((examples) => { + actualHtmlAndJsonExamples = examples; + }); + }); + + describe.each(exampleNames)('%s', (name) => { + const exampleNamePrefix = 'verifies conversion of GLFM to'; + skipRunningSnapshotWysiwygHtmlTests = + exampleStatuses[name]?.skip_running_snapshot_wysiwyg_html_tests; + skipRunningSnapshotProsemirrorJsonTests = + exampleStatuses[name]?.skip_running_snapshot_prosemirror_json_tests; + + const markdown = markdownExamples[name]; + + if (skipRunningSnapshotWysiwygHtmlTests) { + it.todo(`${exampleNamePrefix} HTML: ${skipRunningSnapshotWysiwygHtmlTests}`); + } else { + it(`${exampleNamePrefix} HTML`, async () => { + const expectedHtml = expectedHtmlExamples[name].wysiwyg; + const { html: actualHtml } = actualHtmlAndJsonExamples[name]; + + // noinspection JSUnresolvedFunction (required to avoid RubyMine type inspection warning, because custom matchers auto-imported via Jest test setup are not automatically resolved - see https://youtrack.jetbrains.com/issue/WEB-42350/matcher-for-jest-is-not-recognized-but-it-is-runable) + expect(actualHtml).toMatchExpectedForMarkdown( + 'HTML', + name, + markdown, + IMPLEMENTATION_ERROR_MSG, + expectedHtml, + ); + }); + } + + if (skipRunningSnapshotProsemirrorJsonTests) { + it.todo(`${exampleNamePrefix} ProseMirror JSON: ${skipRunningSnapshotProsemirrorJsonTests}`); + } else { + it(`${exampleNamePrefix} ProseMirror JSON`, async () => { + const expectedJson = expectedProseMirrorJsonExamples[name]; + const { json: actualJson } = actualHtmlAndJsonExamples[name]; + + // noinspection JSUnresolvedFunction + expect(actualJson).toMatchExpectedForMarkdown( + 'JSON', + name, + markdown, + IMPLEMENTATION_ERROR_MSG, + expectedJson, + ); + }); + } + }); +}); diff --git a/spec/frontend/content_editor/markdown_snapshot_spec_helper.js b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js deleted file mode 100644 index 64988c5b717..00000000000 --- a/spec/frontend/content_editor/markdown_snapshot_spec_helper.js +++ /dev/null @@ -1,96 +0,0 @@ -// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing -// for documentation on this spec. - -import jsYaml from 'js-yaml'; -import { pick } from 'lodash'; -import glfmExampleStatusYml from '../../../glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml'; -import markdownYml from '../../../glfm_specification/output_example_snapshots/markdown.yml'; -import htmlYml from '../../../glfm_specification/output_example_snapshots/html.yml'; -import prosemirrorJsonYml from '../../../glfm_specification/output_example_snapshots/prosemirror_json.yml'; -import { - IMPLEMENTATION_ERROR_MSG, - renderHtmlAndJsonForAllExamples, -} from './render_html_and_json_for_all_examples'; - -const filterExamples = (examples) => { - const focusedMarkdownExamples = process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || []; - if (!focusedMarkdownExamples.length) { - return examples; - } - return pick(examples, focusedMarkdownExamples); -}; - -const loadExamples = (yaml) => { - const examples = jsYaml.safeLoad(yaml, {}); - return filterExamples(examples); -}; - -// eslint-disable-next-line jest/no-export -export const describeMarkdownSnapshots = (description) => { - let actualHtmlAndJsonExamples; - let skipRunningSnapshotWysiwygHtmlTests; - let skipRunningSnapshotProsemirrorJsonTests; - - const exampleStatuses = loadExamples(glfmExampleStatusYml); - const markdownExamples = loadExamples(markdownYml); - const expectedHtmlExamples = loadExamples(htmlYml); - const expectedProseMirrorJsonExamples = loadExamples(prosemirrorJsonYml); - - beforeAll(async () => { - return renderHtmlAndJsonForAllExamples(markdownExamples).then((examples) => { - actualHtmlAndJsonExamples = examples; - }); - }); - - describe(description, () => { - const exampleNames = Object.keys(markdownExamples); - - describe.each(exampleNames)('%s', (name) => { - const exampleNamePrefix = 'verifies conversion of GLFM to'; - skipRunningSnapshotWysiwygHtmlTests = - exampleStatuses[name]?.skip_running_snapshot_wysiwyg_html_tests; - skipRunningSnapshotProsemirrorJsonTests = - exampleStatuses[name]?.skip_running_snapshot_prosemirror_json_tests; - - const markdown = markdownExamples[name]; - - if (skipRunningSnapshotWysiwygHtmlTests) { - it.todo(`${exampleNamePrefix} HTML: ${skipRunningSnapshotWysiwygHtmlTests}`); - } else { - it(`${exampleNamePrefix} HTML`, async () => { - const expectedHtml = expectedHtmlExamples[name].wysiwyg; - const { html: actualHtml } = actualHtmlAndJsonExamples[name]; - - // noinspection JSUnresolvedFunction (required to avoid RubyMine type inspection warning, because custom matchers auto-imported via Jest test setup are not automatically resolved - see https://youtrack.jetbrains.com/issue/WEB-42350/matcher-for-jest-is-not-recognized-but-it-is-runable) - expect(actualHtml).toMatchExpectedForMarkdown( - 'HTML', - name, - markdown, - IMPLEMENTATION_ERROR_MSG, - expectedHtml, - ); - }); - } - - if (skipRunningSnapshotProsemirrorJsonTests) { - it.todo( - `${exampleNamePrefix} ProseMirror JSON: ${skipRunningSnapshotProsemirrorJsonTests}`, - ); - } else { - it(`${exampleNamePrefix} ProseMirror JSON`, async () => { - const expectedJson = expectedProseMirrorJsonExamples[name]; - const { json: actualJson } = actualHtmlAndJsonExamples[name]; - - // noinspection JSUnresolvedFunction - expect(actualJson).toMatchExpectedForMarkdown( - 'JSON', - name, - markdown, - IMPLEMENTATION_ERROR_MSG, - expectedJson, - ); - }); - } - }); - }); -}; diff --git a/spec/frontend/content_editor/services/upload_helpers_spec.js b/spec/frontend/content_editor/services/upload_helpers_spec.js index ee9333232db..3423e4db3dc 100644 --- a/spec/frontend/content_editor/services/upload_helpers_spec.js +++ b/spec/frontend/content_editor/services/upload_helpers_spec.js @@ -1,7 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { uploadFile } from '~/content_editor/services/upload_helpers'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; describe('content_editor/services/upload_helpers', () => { const uploadsPath = '/uploads'; @@ -26,7 +26,7 @@ describe('content_editor/services/upload_helpers', () => { renderedMarkdown = parseHTML(renderedAttachmentLinkFixture); mock = new MockAdapter(axios); - mock.onPost(uploadsPath, formData).reply(httpStatus.OK, successResponse); + mock.onPost(uploadsPath, formData).reply(HTTP_STATUS_OK, successResponse); renderMarkdown = jest.fn().mockResolvedValue(renderedAttachmentLinkFixture); }); diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js index 984105d6655..a1e80ef0e6c 100644 --- a/spec/frontend/deploy_freeze/store/mutations_spec.js +++ b/spec/frontend/deploy_freeze/store/mutations_spec.js @@ -33,9 +33,9 @@ describe('Deploy freeze mutations', () => { describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => { it('should set freeze periods and format timezones from identifiers to names', () => { const timezoneNames = { - 'Europe/Berlin': '[UTC + 2] Berlin', + 'Europe/Berlin': '[UTC+2] Berlin', 'Etc/UTC': '[UTC 0] UTC', - 'America/New_York': '[UTC - 4] Eastern Time (US & Canada)', + 'America/New_York': '[UTC-4] Eastern Time (US & Canada)', }; mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture); 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 5fd61b25edc..f4d4f9cf896 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 @@ -5,6 +5,7 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); +jest.mock('~/autosave'); describe('Design reply form component', () => { let wrapper; @@ -78,12 +79,11 @@ describe('Design reply form component', () => { createComponent({ discussionId }); await nextTick(); - // We discourage testing `wrapper.vm` properties but - // since `autosave` library instantiates on component - // there's no other way to test whether instantiation - // happened correctly or not. - expect(wrapper.vm.autosaveDiscussion).toBeInstanceOf(Autosave); - expect(wrapper.vm.autosaveDiscussion.key).toBe(`autosave/Discussion/6/${shortDiscussionId}`); + expect(Autosave).toHaveBeenCalledWith(expect.any(Element), [ + 'Discussion', + 6, + shortDiscussionId, + ]); }, ); @@ -141,7 +141,7 @@ describe('Design reply form component', () => { }); it('emits submitForm event on Comment button click', async () => { - const autosaveResetSpy = jest.spyOn(wrapper.vm.autosaveDiscussion, 'reset'); + const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset'); findSubmitButton().vm.$emit('click'); @@ -151,7 +151,7 @@ describe('Design reply form component', () => { }); it('emits submitForm event on textarea ctrl+enter keydown', async () => { - const autosaveResetSpy = jest.spyOn(wrapper.vm.autosaveDiscussion, 'reset'); + const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset'); findTextarea().trigger('keydown.enter', { ctrlKey: true, @@ -163,7 +163,7 @@ describe('Design reply form component', () => { }); it('emits submitForm event on textarea meta+enter keydown', async () => { - const autosaveResetSpy = jest.spyOn(wrapper.vm.autosaveDiscussion, 'reset'); + const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset'); findTextarea().trigger('keydown.enter', { metaKey: true, @@ -178,7 +178,7 @@ describe('Design reply form component', () => { findTextarea().setValue('test2'); await nextTick(); - expect(wrapper.emitted('input')).toEqual([['test'], ['test2']]); + expect(wrapper.emitted('input')).toEqual([['test2']]); }); it('emits cancelForm event on Escape key if text was not changed', () => { @@ -211,7 +211,7 @@ describe('Design reply form component', () => { it('emits cancelForm event when confirmed', async () => { confirmAction.mockResolvedValueOnce(true); - const autosaveResetSpy = jest.spyOn(wrapper.vm.autosaveDiscussion, 'reset'); + const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset'); wrapper.setProps({ value: 'test3' }); await nextTick(); @@ -228,7 +228,7 @@ describe('Design reply form component', () => { it("doesn't emit cancelForm event when not confirmed", async () => { confirmAction.mockResolvedValueOnce(false); - const autosaveResetSpy = jest.spyOn(wrapper.vm.autosaveDiscussion, 'reset'); + const autosaveResetSpy = jest.spyOn(Autosave.prototype, 'reset'); wrapper.setProps({ value: 'test3' }); await nextTick(); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 1acbf14db88..a4af73dd194 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -12,6 +12,7 @@ exports[`Design management design version dropdown component renders design vers toggletext="Showing latest version" variant="default" > + <!----> <!----> @@ -24,6 +25,7 @@ exports[`Design management design version dropdown component renders design vers tabindex="-1" > <gl-listbox-item-stub + data-testid="listbox-item-gid://gitlab/DesignManagement::Version/1" ischeckcentered="true" > <span @@ -66,6 +68,7 @@ exports[`Design management design version dropdown component renders design vers </span> </gl-listbox-item-stub> <gl-listbox-item-stub + data-testid="listbox-item-gid://gitlab/DesignManagement::Version/2" ischeckcentered="true" > <span @@ -107,6 +110,10 @@ exports[`Design management design version dropdown component renders design vers </span> </span> </gl-listbox-item-stub> + + <!----> + + <!----> </ul> <!----> @@ -126,6 +133,7 @@ exports[`Design management design version dropdown component renders design vers toggletext="Showing latest version" variant="default" > + <!----> <!----> @@ -138,6 +146,7 @@ exports[`Design management design version dropdown component renders design vers tabindex="-1" > <gl-listbox-item-stub + data-testid="listbox-item-gid://gitlab/DesignManagement::Version/1" ischeckcentered="true" > <span @@ -180,6 +189,7 @@ exports[`Design management design version dropdown component renders design vers </span> </gl-listbox-item-stub> <gl-listbox-item-stub + data-testid="listbox-item-gid://gitlab/DesignManagement::Version/2" ischeckcentered="true" > <span @@ -221,6 +231,10 @@ exports[`Design management design version dropdown component renders design vers </span> </span> </gl-listbox-item-stub> + + <!----> + + <!----> </ul> <!----> diff --git a/spec/frontend/diff_spec.js b/spec/frontend/diff_spec.js new file mode 100644 index 00000000000..759ae32ac51 --- /dev/null +++ b/spec/frontend/diff_spec.js @@ -0,0 +1,72 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +import Diff from '~/diff'; + +describe('Diff', () => { + describe('diff <-> tabs interactions', () => { + let hub; + + beforeEach(() => { + hub = createEventHub(); + }); + + describe('constructor', () => { + it("takes in the `mergeRequestEventHub` when it's provided", () => { + const diff = new Diff({ mergeRequestEventHub: hub }); + + expect(diff.mrHub).toBe(hub); + }); + + it('does not fatal if no event hub is provided', () => { + expect(() => { + new Diff(); /* eslint-disable-line no-new */ + }).not.toThrow(); + }); + + it("doesn't set the mrHub property if none is provided by the construction arguments", () => { + const diff = new Diff(); + + expect(diff.mrHub).toBe(undefined); + }); + }); + + describe('viewTypeSwitch', () => { + const clickPath = '/path/somewhere?params=exist'; + const jsonPath = 'http://test.host/path/somewhere.json?params=exist'; + const simulatejQueryClick = { + originalEvent: { + target: { + getAttribute() { + return clickPath; + }, + }, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }, + }; + + it('emits the correct switch view event when called and there is an `mrHub`', async () => { + const diff = new Diff({ mergeRequestEventHub: hub }); + const hubEmit = new Promise((resolve) => { + hub.$on('diff:switch-view-type', resolve); + }); + + diff.viewTypeSwitch(simulatejQueryClick); + const { source } = await hubEmit; + + expect(simulatejQueryClick.originalEvent.preventDefault).toHaveBeenCalled(); + expect(simulatejQueryClick.originalEvent.stopPropagation).toHaveBeenCalled(); + expect(source).toBe(jsonPath); + }); + + it('is effectively a noop when there is no `mrHub`', () => { + const diff = new Diff(); + + expect(diff.mrHub).toBe(undefined); + expect(() => { + diff.viewTypeSwitch(simulatejQueryClick); + }).not.toThrow(); + }); + }); + }); +}); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 936f4744e94..c8be0bedb4c 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -107,6 +107,7 @@ describe('diffs/components/app', () => { beforeEach(() => { const fetchResolver = () => { store.state.diffs.retrievingBatches = false; + store.state.notes.doneFetchingBatchDiscussions = true; store.state.notes.discussions = 'test'; return Promise.resolve({ real_size: 100 }); }; diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 944cec77efb..ccfc36f8f16 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -18,7 +18,7 @@ import createDiffsStore from '~/diffs/store/modules'; import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; import { scrollToElement } from '~/lib/utils/common_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import createNotesStore from '~/notes/stores/modules'; import { getDiffFileMock } from '../mock_data/diff_file'; import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable'; @@ -436,7 +436,7 @@ describe('DiffFile', () => { describe('loading', () => { it('should have loading icon while loading a collapsed diffs', async () => { const { load_collapsed_diff_url } = store.state.diffs.diffFiles[0]; - axiosMock.onGet(load_collapsed_diff_url).reply(httpStatus.OK, getReadableFile()); + axiosMock.onGet(load_collapsed_diff_url).reply(HTTP_STATUS_OK, getReadableFile()); makeFileAutomaticallyCollapsed(store); wrapper.vm.requestDiff(); @@ -517,7 +517,7 @@ describe('DiffFile', () => { viewer: { name: 'collapsed', automaticallyCollapsed: true }, }; - axiosMock.onGet(file.load_collapsed_diff_url).reply(httpStatus.OK, getReadableFile()); + axiosMock.onGet(file.load_collapsed_diff_url).reply(HTTP_STATUS_OK, getReadableFile()); ({ wrapper, store } = createComponent({ file, props: { viewDiffsFileByFile: true } })); 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 9493dc8855e..bd0e3455872 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -101,7 +101,8 @@ describe('DiffLineNoteForm', () => { }); it('should init autosave', () => { - expect(Autosave).toHaveBeenCalledWith({}, [ + // we're using shallow mount here so there's no element to pass to Autosave + expect(Autosave).toHaveBeenCalledWith(undefined, [ 'Note', 'Issue', 98, diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index 0fe70bac6b7..0f7926ccbf9 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -7,7 +7,7 @@ import { TEST_HOST } from 'spec/test_constants'; import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; import dropzoneInput from '~/dropzone_input'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; const TEST_FILE = new File([], 'somefile.jpg'); TEST_FILE.upload = {}; @@ -92,7 +92,7 @@ describe('dropzone_input', () => { ], }); - axiosMock.onPost().reply(httpStatusCodes.OK, { link: { markdown: 'foo' } }); + axiosMock.onPost().reply(HTTP_STATUS_OK, { link: { markdown: 'foo' } }); await waitForPromises(); expect(axiosMock.history.post[0].data.get('file').name).toHaveLength(246); }); @@ -131,7 +131,7 @@ describe('dropzone_input', () => { }, ], }); - axiosMock.onPost().reply(httpStatusCodes.OK, { link: { markdown: 'foo' } }); + axiosMock.onPost().reply(HTTP_STATUS_OK, { link: { markdown: 'foo' } }); await waitForPromises(); expect(axiosMock.history.post[0].data.get('file').name).toEqual('test.png'); }); diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json index 666a4852957..17a1b4474b6 100644 --- a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json @@ -107,7 +107,6 @@ "container_scanning": "scan2.json", "dast": "dast.json", "license_management": "license.json", - "performance": "performance.json", "metrics": "metrics.txt" } }, @@ -160,7 +159,6 @@ "container_scanning": ["scan2.json"], "dast": ["dast.json"], "license_management": ["license.json"], - "performance": ["performance.json"], "metrics": ["metrics.txt"] } }, diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml index 29f4a0cd76d..996a48f7bc6 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml @@ -1,5 +1,30 @@ -# invalid artifact:reports:cyclonedx +# invalid artifact:reports:browser_performance +browser_performance no paths: + artifacts: + reports: + browser_performance: + +## Lists (or globs) are not allowed! +browser_performance list of string paths: + artifacts: + reports: + browser_performance: + - foo + - ./bar/baz + +browser_performance mixed list of string paths and globs: + artifacts: + reports: + browser_performance: + - ./foo + - "bar/*.baz" + +browser_performance string array: + artifacts: + reports: + browser_performance: ["foo", "blah"] +# invalid artifact:reports:cyclonedx cyclonedx no paths: artifacts: reports: @@ -17,6 +42,19 @@ cyclonedx not an array or string: - foo - bar +# invalid artifacts:reports:coverage_report +coverage-report-is-string: + artifacts: + reports: + coverage_report: cobertura + +# invalid artifact:reports:performance +# Superceded by: artifact:reports:browser_performance +performance string path: + artifacts: + reports: + performance: foo + # invalid artifacts:when artifacts-when-unknown: artifacts: diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/rules.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/rules.yml index d74a681b23b..f4a08492574 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/rules.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/rules.yml @@ -12,3 +12,8 @@ wrong path declaration: rules: - changes: paths: { file: 'DOCKER' } + +# invalid rules:if +rules-if-empty: + rules: + - if:
\ No newline at end of file diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml index a5c9153ee13..70761a09b58 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml @@ -1,5 +1,10 @@ -# valid artifact:reports:cyclonedx +# valid artifact:reports:browser_performance +browser_performance string path: + artifacts: + reports: + browser_performance: foo +# valid artifact:reports:cyclonedx cyclonedx string path: artifacts: reports: @@ -24,6 +29,19 @@ cylonedx mixed list of string paths and globs: - ./foo - "bar/*.baz" +# valid artifacts:reports:coverage_report +coverage-report-cobertura: + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage/cobertura-coverage.xml + +coverage-report-null: + artifacts: + reports: + coverage_report: null + # valid artifacts:when artifacts-when-on-failure: artifacts: diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml index ef604f707b5..5dfaf323b22 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml @@ -28,3 +28,7 @@ workflow: variables: IS_A_FEATURE: 'true' when: always + +# valid rules:null +rules-null: + rules: null diff --git a/spec/frontend/environments/environment_details/deployment_job_spec.js b/spec/frontend/environments/environment_details/deployment_job_spec.js new file mode 100644 index 00000000000..9bb61abb293 --- /dev/null +++ b/spec/frontend/environments/environment_details/deployment_job_spec.js @@ -0,0 +1,49 @@ +import { GlTruncate, GlLink, GlBadge } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import DeploymentJob from '~/environments/environment_details/components/deployment_job.vue'; + +describe('app/assets/javascripts/environments/environment_details/components/deployment_job.vue', () => { + const jobData = { + webPath: 'http://example.com', + label: 'example job', + }; + let wrapper; + + const createWrapper = ({ job }) => { + return mountExtended(DeploymentJob, { + propsData: { + job, + }, + }); + }; + + describe('when the job data exists', () => { + beforeEach(() => { + wrapper = createWrapper({ job: jobData }); + }); + + it('should render a link with a correct href', () => { + const jobLink = wrapper.findComponent(GlLink); + expect(jobLink.exists()).toBe(true); + expect(jobLink.attributes().href).toBe(jobData.webPath); + }); + it('should render a truncated label', () => { + const truncatedLabel = wrapper.findComponent(GlTruncate); + expect(truncatedLabel.exists()).toBe(true); + expect(truncatedLabel.props().text).toBe(jobData.label); + }); + }); + + describe('when the job data does not exist', () => { + beforeEach(() => { + wrapper = createWrapper({ job: null }); + }); + + it('should render a badge with the text "API"', () => { + const badge = wrapper.findComponent(GlBadge); + expect(badge.exists()).toBe(true); + expect(badge.props().variant).toBe('info'); + expect(badge.text()).toBe('API'); + }); + }); +}); diff --git a/spec/frontend/environments/environment_details/deployment_status_link_spec.js b/spec/frontend/environments/environment_details/deployment_status_link_spec.js new file mode 100644 index 00000000000..5db7740423a --- /dev/null +++ b/spec/frontend/environments/environment_details/deployment_status_link_spec.js @@ -0,0 +1,57 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import DeploymentStatusLink from '~/environments/environment_details/components/deployment_status_link.vue'; +import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue'; + +describe('app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue', () => { + const testData = { + webPath: 'http://example.com', + status: 'success', + }; + let wrapper; + + const createWrapper = (props) => { + return mountExtended(DeploymentStatusLink, { + propsData: props, + }); + }; + + describe('when the job link exists', () => { + beforeEach(() => { + wrapper = createWrapper({ + deploymentJob: { webPath: testData.webPath }, + status: testData.status, + }); + }); + + it('should render a link with a correct href', () => { + const jobLink = wrapper.findByTestId('deployment-status-job-link'); + expect(jobLink.exists()).toBe(true); + expect(jobLink.attributes().href).toBe(testData.webPath); + }); + + it('should render a status badge', () => { + const statusBadge = wrapper.findComponent(DeploymentStatusBadge); + expect(statusBadge.exists()).toBe(true); + expect(statusBadge.props().status).toBe(testData.status); + }); + }); + + describe('when no deployment job is provided', () => { + beforeEach(() => { + wrapper = createWrapper({ + status: testData.status, + }); + }); + + it('should render a link with a correct href', () => { + const jobLink = wrapper.findByTestId('deployment-status-job-link'); + expect(jobLink.exists()).toBe(false); + }); + + it('should render only a status badge', () => { + const statusBadge = wrapper.findComponent(DeploymentStatusBadge); + expect(statusBadge.exists()).toBe(true); + expect(statusBadge.props().status).toBe(testData.status); + }); + }); +}); diff --git a/spec/frontend/environments/environment_details/deployment_triggerer_spec.js b/spec/frontend/environments/environment_details/deployment_triggerer_spec.js new file mode 100644 index 00000000000..48af82661bf --- /dev/null +++ b/spec/frontend/environments/environment_details/deployment_triggerer_spec.js @@ -0,0 +1,51 @@ +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import DeploymentTriggerer from '~/environments/environment_details/components/deployment_triggerer.vue'; + +describe('app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue', () => { + const triggererData = { + id: 'gid://gitlab/User/1', + webUrl: 'http://gdk.test:3000/root', + name: 'Administrator', + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + let wrapper; + + const createWrapper = ({ triggerer }) => { + return mountExtended(DeploymentTriggerer, { + propsData: { + triggerer, + }, + }); + }; + + describe('when the triggerer data exists', () => { + beforeEach(() => { + wrapper = createWrapper({ triggerer: triggererData }); + }); + + it('should render an avatar link with a correct href', () => { + const triggererAvatarLink = wrapper.findComponent(GlAvatarLink); + expect(triggererAvatarLink.exists()).toBe(true); + expect(triggererAvatarLink.attributes().href).toBe(triggererData.webUrl); + }); + + it('should render an avatar', () => { + const triggererAvatar = wrapper.findComponent(GlAvatar); + expect(triggererAvatar.exists()).toBe(true); + expect(triggererAvatar.attributes().title).toBe(triggererData.name); + expect(triggererAvatar.props().src).toBe(triggererData.avatarUrl); + }); + }); + + describe('when the triggerer data does not exist', () => { + beforeEach(() => { + wrapper = createWrapper({ triggerer: null }); + }); + + it('should render nothing', () => { + const avatarLink = wrapper.findComponent(GlAvatarLink); + expect(avatarLink.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/environments/environment_details/empty_state_spec.js b/spec/frontend/environments/environment_details/empty_state_spec.js new file mode 100644 index 00000000000..aaf597d68ed --- /dev/null +++ b/spec/frontend/environments/environment_details/empty_state_spec.js @@ -0,0 +1,39 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import EmptyState from '~/environments/environment_details/empty_state.vue'; +import { + translations, + environmentsHelpPagePath, + codeBlockPlaceholders, +} from '~/environments/environment_details/constants'; + +describe('~/environments/environment_details/empty_state.vue', () => { + let wrapper; + + const createWrapper = () => { + return mountExtended(EmptyState); + }; + + describe('when Empty State is rendered for environment details page', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('should render the proper title', () => { + expect(wrapper.text()).toContain(translations.emptyStateTitle); + }); + + it('should render GlEmptyState component with correct props', () => { + const glEmptyStateComponent = wrapper.findComponent(GlEmptyState); + expect(glEmptyStateComponent.props().primaryButtonText).toBe( + translations.emptyStatePrimaryButton, + ); + expect(glEmptyStateComponent.props().primaryButtonLink).toBe(environmentsHelpPagePath); + }); + + it('should render formatted description', () => { + expect(wrapper.text()).not.toContain(codeBlockPlaceholders.code[0]); + expect(wrapper.text()).not.toContain(codeBlockPlaceholders.code[1]); + }); + }); +}); diff --git a/spec/frontend/environments/environment_details/page_spec.js b/spec/frontend/environments/environment_details/page_spec.js new file mode 100644 index 00000000000..3a1a3238abe --- /dev/null +++ b/spec/frontend/environments/environment_details/page_spec.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon, GlTableLite } from '@gitlab/ui'; +import resolvedEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.json'; +import emptyEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.empty.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import EnvironmentsDetailPage from '~/environments/environment_details/index.vue'; +import EmptyState from '~/environments/environment_details/empty_state.vue'; +import getEnvironmentDetails from '~/environments/graphql/queries/environment_details.query.graphql'; +import createMockApollo from '../../__helpers__/mock_apollo_helper'; +import waitForPromises from '../../__helpers__/wait_for_promises'; + +describe('~/environments/environment_details/page.vue', () => { + Vue.use(VueApollo); + + let wrapper; + + const defaultWrapperParameters = { + resolvedData: resolvedEnvironmentDetails, + }; + + const createWrapper = ({ resolvedData } = defaultWrapperParameters) => { + const mockApollo = createMockApollo([ + [getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedData)], + ]); + + return mountExtended(EnvironmentsDetailPage, { + apolloProvider: mockApollo, + propsData: { + projectFullPath: 'gitlab-group/test-project', + environmentName: 'test-environment-name', + }, + }); + }; + + describe('when fetching data', () => { + it('should show a loading indicator', () => { + wrapper = createWrapper(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlTableLite).exists()).not.toBe(true); + }); + }); + + describe('when data is fetched', () => { + describe('and there are deployments', () => { + beforeEach(async () => { + wrapper = createWrapper(); + await waitForPromises(); + }); + it('should render a table when query is loaded', async () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).not.toBe(true); + expect(wrapper.findComponent(GlTableLite).exists()).toBe(true); + }); + }); + + describe('and there are no deployments', () => { + beforeEach(async () => { + wrapper = createWrapper({ resolvedData: emptyEnvironmentDetails }); + await waitForPromises(); + }); + + it('should render empty state component', async () => { + expect(wrapper.findComponent(GlTableLite).exists()).toBe(false); + expect(wrapper.findComponent(EmptyState).exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/environments/environment_details/pagination_spec.js b/spec/frontend/environments/environment_details/pagination_spec.js new file mode 100644 index 00000000000..107f3c3dd5e --- /dev/null +++ b/spec/frontend/environments/environment_details/pagination_spec.js @@ -0,0 +1,157 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import Pagination from '~/environments/environment_details/pagination.vue'; + +describe('~/environments/environment_details/pagniation.vue', () => { + const mockRouter = { + push: jest.fn(), + }; + + const pageInfo = { + startCursor: 'eyJpZCI6IjE2In0', + endCursor: 'eyJpZCI6IjIifQ', + hasNextPage: true, + hasPreviousPage: true, + }; + let wrapper; + + const createWrapper = (pageInfoProp) => { + return mountExtended(Pagination, { + propsData: { + pageInfo: pageInfoProp, + }, + mocks: { + $router: mockRouter, + }, + }); + }; + + describe('when neither next nor previous page exists', () => { + beforeEach(() => { + const emptyPageInfo = { ...pageInfo, hasPreviousPage: false, hasNextPage: false }; + wrapper = createWrapper(emptyPageInfo); + }); + + it('should not render pagination component', () => { + expect(wrapper.html()).toBe(''); + }); + }); + + describe('when Pagination is rendered for environment details page', () => { + beforeEach(() => { + wrapper = createWrapper(pageInfo); + }); + + it('should pass correct props to keyset pagination', () => { + const glPagination = wrapper.findComponent(GlKeysetPagination); + expect(glPagination.exists()).toBe(true); + expect(glPagination.props()).toEqual(expect.objectContaining(pageInfo)); + }); + + describe.each([ + { + testPageInfo: pageInfo, + expectedAfter: `after=${pageInfo.endCursor}`, + expectedBefore: `before=${pageInfo.startCursor}`, + }, + { + testPageInfo: { ...pageInfo, hasNextPage: true, hasPreviousPage: false }, + expectedAfter: `after=${pageInfo.endCursor}`, + expectedBefore: '', + }, + { + testPageInfo: { ...pageInfo, hasNextPage: false, hasPreviousPage: true }, + expectedAfter: '', + expectedBefore: `before=${pageInfo.startCursor}`, + }, + ])( + 'button links generation for $testPageInfo', + ({ testPageInfo, expectedAfter, expectedBefore }) => { + beforeEach(() => { + wrapper = createWrapper(testPageInfo); + }); + + it(`should have button links defined as ${expectedAfter || 'empty'} and + ${expectedBefore || 'empty'}`, () => { + const glPagination = wrapper.findComponent(GlKeysetPagination); + expect(glPagination.props().prevButtonLink).toContain(expectedBefore); + expect(glPagination.props().nextButtonLink).toContain(expectedAfter); + }); + }, + ); + + describe.each([ + { + clickEvent: { + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + }, + isDefaultPrevented: true, + }, + { + clickEvent: { + shiftKey: true, + ctrlKey: false, + altKey: false, + metaKey: false, + }, + isDefaultPrevented: false, + }, + { + clickEvent: { + shiftKey: false, + ctrlKey: true, + altKey: false, + metaKey: false, + }, + isDefaultPrevented: false, + }, + { + clickEvent: { + shiftKey: false, + ctrlKey: false, + altKey: true, + metaKey: false, + }, + isDefaultPrevented: false, + }, + { + clickEvent: { + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: true, + }, + isDefaultPrevented: false, + }, + ])( + 'when a pagination button is clicked with $clickEvent', + ({ clickEvent, isDefaultPrevented }) => { + let clickEventMock; + beforeEach(() => { + clickEventMock = { ...clickEvent, preventDefault: jest.fn() }; + }); + + it(`should ${isDefaultPrevented ? '' : 'not '}prevent default event`, () => { + const pagination = wrapper.findComponent(GlKeysetPagination); + pagination.vm.$emit('click', clickEventMock); + expect(clickEventMock.preventDefault).toHaveBeenCalledTimes(isDefaultPrevented ? 1 : 0); + }); + }, + ); + + it('should navigate to a correct previous page', () => { + const pagination = wrapper.findComponent(GlKeysetPagination); + pagination.vm.$emit('prev', pageInfo.startCursor); + expect(mockRouter.push).toHaveBeenCalledWith({ query: { before: pageInfo.startCursor } }); + }); + + it('should navigate to a correct next page', () => { + const pagination = wrapper.findComponent(GlKeysetPagination); + pagination.vm.$emit('next', pageInfo.endCursor); + expect(mockRouter.push).toHaveBeenCalledWith({ query: { after: pageInfo.endCursor } }); + }); + }); +}); diff --git a/spec/frontend/environments/environment_details_page_spec.js b/spec/frontend/environments/environment_details_page_spec.js deleted file mode 100644 index 5a02b34250f..00000000000 --- a/spec/frontend/environments/environment_details_page_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { GlLoadingIcon, GlTableLite } from '@gitlab/ui'; -import resolvedEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.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 EnvironmentsDetailPage from '../../../app/assets/javascripts/environments/environment_details/index.vue'; -import getEnvironmentDetails from '../../../app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql'; - -describe('~/environments/environment_details/page.vue', () => { - Vue.use(VueApollo); - - let wrapper; - - const createWrapper = () => { - const mockApollo = createMockApollo([ - [getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedEnvironmentDetails)], - ]); - - return mountExtended(EnvironmentsDetailPage, { - apolloProvider: mockApollo, - propsData: { - projectFullPath: resolvedEnvironmentDetails.data.project.fullPath, - environmentName: resolvedEnvironmentDetails.data.project.environment.name, - }, - }); - }; - - describe('when fetching data', () => { - it('should show a loading indicator', () => { - wrapper = createWrapper(); - - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.findComponent(GlTableLite).exists()).not.toBe(true); - }); - }); - - describe('when data is fetched', () => { - beforeEach(async () => { - wrapper = createWrapper(); - await waitForPromises(); - }); - - it('should render a table when query is loaded', async () => { - expect(wrapper.findComponent(GlLoadingIcon).exists()).not.toBe(true); - expect(wrapper.findComponent(GlTableLite).exists()).toBe(true); - }); - }); -}); 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 adb2eaaf04e..31473899145 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -364,7 +364,23 @@ describe('ErrorTrackingList', () => { }); it('shows empty state', () => { - expect(wrapper.findComponent(GlEmptyState).isVisible()).toBe(true); + const emptyStateComponent = wrapper.findComponent(GlEmptyState); + const emptyStatePrimaryDescription = emptyStateComponent.find('span', { + exactText: 'Monitor your errors directly in GitLab.', + }); + const emptyStateSecondaryDescription = emptyStateComponent.find('span', { + exactText: 'Error tracking is currently in', + }); + const emptyStateLinks = emptyStateComponent.findAll('a'); + expect(emptyStateComponent.isVisible()).toBe(true); + expect(emptyStatePrimaryDescription.exists()).toBe(true); + expect(emptyStateSecondaryDescription.exists()).toBe(true); + expect(emptyStateLinks.at(0).attributes('href')).toBe( + '/help/operations/error_tracking.html#integrated-error-tracking', + ); + expect(emptyStateLinks.at(1).attributes('href')).toBe( + 'https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta', + ); }); }); diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 2809bbe834e..590983bd93d 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -4,7 +4,7 @@ import * as actions from '~/error_tracking/store/list/actions'; import * as types from '~/error_tracking/store/list/mutation_types'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; jest.mock('~/flash.js'); @@ -23,7 +23,7 @@ describe('error tracking actions', () => { it('should start polling for data', () => { const payload = { errors: [{ id: 1 }, { id: 2 }] }; - mock.onGet().reply(httpStatusCodes.OK, payload); + mock.onGet().reply(HTTP_STATUS_OK, payload); return testAction( actions.startPolling, {}, @@ -39,7 +39,7 @@ describe('error tracking actions', () => { }); it('should show flash on API error', async () => { - mock.onGet().reply(httpStatusCodes.BAD_REQUEST); + mock.onGet().reply(HTTP_STATUS_BAD_REQUEST); await testAction( actions.startPolling, diff --git a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js index c9095441d41..8653ebac20d 100644 --- a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js +++ b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import { pick, clone } from 'lodash'; @@ -42,7 +42,7 @@ describe('error tracking settings project dropdown', () => { describe('empty project list', () => { it('renders the dropdown', () => { expect(wrapper.find('#project-dropdown').exists()).toBe(true); - expect(wrapper.findComponent(GlDropdown).exists()).toBe(true); + expect(wrapper.findComponent(GlCollapsibleListbox).exists()).toBe(true); }); it('shows helper text', () => { @@ -57,8 +57,10 @@ describe('error tracking settings project dropdown', () => { }); it('does not contain any dropdown items', () => { - expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(false); - expect(wrapper.findComponent(GlDropdown).props('text')).toBe('No projects available'); + expect(wrapper.findComponent(GlCollapsibleListbox).props('items')).toEqual([]); + expect(wrapper.findComponent(GlCollapsibleListbox).props('toggleText')).toBe( + 'No projects available', + ); }); }); @@ -71,12 +73,12 @@ describe('error tracking settings project dropdown', () => { it('renders the dropdown', () => { expect(wrapper.find('#project-dropdown').exists()).toBe(true); - expect(wrapper.findComponent(GlDropdown).exists()).toBe(true); + expect(wrapper.findComponent(GlCollapsibleListbox).exists()).toBe(true); }); it('contains a number of dropdown items', () => { - expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true); - expect(wrapper.findAllComponents(GlDropdownItem).length).toBe(2); + expect(wrapper.findComponent(GlCollapsibleListbox).exists()).toBe(true); + expect(wrapper.findComponent(GlCollapsibleListbox).props('items').length).toBe(2); }); }); diff --git a/spec/frontend/error_tracking_settings/mock.js b/spec/frontend/error_tracking_settings/mock.js index b2d7a912518..96d93540ba5 100644 --- a/spec/frontend/error_tracking_settings/mock.js +++ b/spec/frontend/error_tracking_settings/mock.js @@ -5,12 +5,14 @@ const defaultStore = createStore(); export const projectList = [ { + id: '1', name: 'name', slug: 'slug', organizationName: 'organizationName', organizationSlug: 'organizationSlug', }, { + id: '2', name: 'name2', slug: 'slug2', organizationName: 'organizationName2', @@ -19,6 +21,7 @@ export const projectList = [ ]; export const staleProject = { + id: '3', name: 'staleName', slug: 'staleSlug', organizationName: 'staleOrganizationName', @@ -26,6 +29,7 @@ export const staleProject = { }; export const normalizedProject = { + id: '5', name: 'name', slug: 'slug', organizationName: 'organization_name', @@ -33,6 +37,7 @@ export const normalizedProject = { }; export const sampleBackendProject = { + id: '5', name: normalizedProject.name, slug: normalizedProject.slug, organization_name: normalizedProject.organizationName, @@ -45,6 +50,7 @@ export const sampleFrontendSettings = { integrated: false, token: 'token', selectedProject: { + id: '5', slug: normalizedProject.slug, name: normalizedProject.name, organizationName: normalizedProject.organizationName, @@ -58,6 +64,7 @@ export const transformedSettings = { integrated: false, token: 'token', project: { + sentry_project_id: '5', slug: normalizedProject.slug, name: normalizedProject.name, organization_name: normalizedProject.organizationName, diff --git a/spec/frontend/feature_flags/components/environments_dropdown_spec.js b/spec/frontend/feature_flags/components/environments_dropdown_spec.js index 2b9710c9085..a4738fed37e 100644 --- a/spec/frontend/feature_flags/components/environments_dropdown_spec.js +++ b/spec/frontend/feature_flags/components/environments_dropdown_spec.js @@ -6,7 +6,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; describe('Feature flags > Environments dropdown', () => { let wrapper; @@ -51,7 +51,7 @@ describe('Feature flags > Environments dropdown', () => { describe('on focus', () => { it('sets results with the received data', async () => { - mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(HTTP_STATUS_OK, results); factory(); findEnvironmentSearchInput().vm.$emit('focus'); await waitForPromises(); @@ -63,7 +63,7 @@ describe('Feature flags > Environments dropdown', () => { describe('on keyup', () => { it('sets results with the received data', async () => { - mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(HTTP_STATUS_OK, results); factory(); findEnvironmentSearchInput().vm.$emit('keyup'); await waitForPromises(); @@ -76,7 +76,7 @@ describe('Feature flags > Environments dropdown', () => { describe('on input change', () => { describe('on success', () => { beforeEach(async () => { - mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(HTTP_STATUS_OK, results); factory(); findEnvironmentSearchInput().vm.$emit('focus'); findEnvironmentSearchInput().vm.$emit('input', 'production'); @@ -128,7 +128,7 @@ describe('Feature flags > Environments dropdown', () => { describe('on click create button', () => { beforeEach(async () => { - mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, []); + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(HTTP_STATUS_OK, []); factory(); findEnvironmentSearchInput().vm.$emit('focus'); findEnvironmentSearchInput().vm.$emit('input', 'production'); diff --git a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js index 1c0c444c296..b71cdf78207 100644 --- a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js +++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js @@ -4,7 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; const TEST_HOST = '/test'; const TEST_SEARCH = 'production'; @@ -74,7 +74,7 @@ describe('New Environments Dropdown', () => { describe('with results', () => { let items; beforeEach(() => { - axiosMock.onGet(TEST_HOST).reply(httpStatusCodes.OK, ['prod', 'production']); + axiosMock.onGet(TEST_HOST).reply(HTTP_STATUS_OK, ['prod', 'production']); wrapper.findComponent(GlSearchBoxByType).vm.$emit('focus'); wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'prod'); return axios.waitForAll().then(() => { diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js index d82081041d9..4d5cb26810e 100644 --- a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js +++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import { dismiss } from '~/feature_highlight/feature_highlight_helper'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes, { HTTP_STATUS_CREATED } from '~/lib/utils/http_status'; +import { HTTP_STATUS_CREATED, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; jest.mock('~/flash'); @@ -11,7 +11,6 @@ describe('feature highlight helper', () => { let mockAxios; const endpoint = '/-/callouts/dismiss'; const highlightId = '123'; - const { INTERNAL_SERVER_ERROR } = httpStatusCodes; beforeEach(() => { mockAxios = new MockAdapter(axios); @@ -28,7 +27,9 @@ describe('feature highlight helper', () => { }); it('triggers flash when dismiss request fails', async () => { - mockAxios.onPost(endpoint, { feature_name: highlightId }).replyOnce(INTERNAL_SERVER_ERROR); + mockAxios + .onPost(endpoint, { feature_name: highlightId }) + .replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); await dismiss(endpoint, highlightId); diff --git a/spec/frontend/fixtures/environments.rb b/spec/frontend/fixtures/environments.rb index 3ca5b50ac9c..77e2a96b328 100644 --- a/spec/frontend/fixtures/environments.rb +++ b/spec/frontend/fixtures/environments.rb @@ -18,36 +18,55 @@ RSpec.describe 'Environments (JavaScript fixtures)', feature_category: :environm let(:user) { create(:user) } let(:role) { :developer } - let_it_be(:deployment) do - create(:deployment, :success, environment: environment, deployable: nil) - end - let_it_be(:deployment_success) do - create(:deployment, :success, environment: environment, deployable: build) - end + describe GraphQL::Query, type: :request do + environment_details_query_path = 'environments/graphql/queries/environment_details.query.graphql' - let_it_be(:deployment_failed) do - create(:deployment, :failed, environment: environment, deployable: build) - end + context 'with no deployments' do + it "graphql/#{environment_details_query_path}.empty.json" do + query = get_graphql_query_as_string(environment_details_query_path) + puts project.full_path + puts environment.name + post_graphql(query, current_user: admin, + variables: + { + projectFullPath: project.full_path, + environmentName: environment.name, + pageSize: 10 + }) + expect_graphql_errors_to_be_empty + end + end - let_it_be(:deployment_running) do - create(:deployment, :running, environment: environment, deployable: build) - end + context 'with deployments' do + let_it_be(:deployment) do + create(:deployment, :success, environment: environment, deployable: nil) + end - describe GraphQL::Query, type: :request do - environment_details_query_path = 'environments/graphql/queries/environment_details.query.graphql' + let_it_be(:deployment_success) do + create(:deployment, :success, environment: environment, deployable: build) + end + + let_it_be(:deployment_failed) do + create(:deployment, :failed, environment: environment, deployable: build) + end + + let_it_be(:deployment_running) do + create(:deployment, :running, environment: environment, deployable: build) + end + + it "graphql/#{environment_details_query_path}.json" do + query = get_graphql_query_as_string(environment_details_query_path) - it "graphql/#{environment_details_query_path}.json" do - query = get_graphql_query_as_string(environment_details_query_path) - - post_graphql(query, current_user: admin, - variables: - { - projectFullPath: project.full_path, - environmentName: environment.name, - pageSize: 10 - }) - expect_graphql_errors_to_be_empty + post_graphql(query, current_user: admin, + variables: + { + projectFullPath: project.full_path, + environmentName: environment.name, + pageSize: 10 + }) + expect_graphql_errors_to_be_empty + end end end end diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index bc5ece20032..1e6baf30a76 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller do +RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', :with_license, type: :controller do include JavaScriptFixturesHelpers let(:user) { create(:user, feed_token: 'feedtoken:coldfeed') } diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index 101ba203a57..2ccf2c0392f 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -66,4 +66,36 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do end end end + + describe 'Storage', feature_category: :subscription_cost_management do + describe GraphQL::Query, type: :request do + include GraphqlHelpers + context 'project storage statistics query' do + before do + project.statistics.update!( + repository_size: 3_900_000, + lfs_objects_size: 4_800_000, + build_artifacts_size: 400_000, + pipeline_artifacts_size: 400_000, + container_registry_size: 3_900_000, + wiki_size: 300_000, + packages_size: 3_800_000, + uploads_size: 900_000 + ) + end + + base_input_path = 'usage_quotas/storage/queries/' + base_output_path = 'graphql/usage_quotas/storage/' + query_name = 'project_storage.query.graphql' + + it "#{base_output_path}#{query_name}.json" do + query = get_graphql_query_as_string("#{base_input_path}#{query_name}") + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path }) + + expect_graphql_errors_to_be_empty + end + end + end + end end diff --git a/spec/frontend/fixtures/runner_instructions.rb b/spec/frontend/fixtures/runner_instructions.rb index 90a01c37479..5659b8023e9 100644 --- a/spec/frontend/fixtures/runner_instructions.rb +++ b/spec/frontend/fixtures/runner_instructions.rb @@ -7,7 +7,7 @@ RSpec.describe 'Runner Instructions (JavaScript fixtures)', feature_category: :r include JavaScriptFixturesHelpers include GraphqlHelpers - query_path = 'vue_shared/components/runner_instructions/graphql/queries' + query_path = 'vue_shared/components/runner_instructions/graphql' describe GraphQL::Query do describe 'get_runner_platforms.query.graphql', type: :request do diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index ade36cd1637..2f0a52a9884 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,9 +1,8 @@ import * as Sentry from '@sentry/browser'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import createFlash, { +import { hideFlash, addDismissFlashClickListener, - FLASH_TYPES, FLASH_CLOSED_EVENT, createAlert, VARIANT_WARNING, @@ -340,207 +339,6 @@ describe('Flash', () => { }); }); - describe('createFlash', () => { - const message = 'test'; - const fadeTransition = false; - const addBodyClass = true; - const defaultParams = { - message, - actionConfig: null, - fadeTransition, - addBodyClass, - }; - - describe('no flash-container', () => { - it('does not add to the DOM', () => { - const flashEl = createFlash({ message }); - - expect(flashEl).toBeNull(); - - expect(document.querySelector('.flash-alert')).toBeNull(); - }); - }); - - describe('with flash-container', () => { - beforeEach(() => { - setHTMLFixture( - '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>', - ); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('adds flash alert element into the document by default', () => { - createFlash({ ...defaultParams }); - - expect(document.querySelector('.flash-container .flash-alert')).not.toBeNull(); - expect(document.body.className).toContain('flash-shown'); - }); - - it('adds flash of a warning type', () => { - createFlash({ ...defaultParams, type: FLASH_TYPES.WARNING }); - - expect(document.querySelector('.flash-container .flash-warning')).not.toBeNull(); - expect(document.body.className).toContain('flash-shown'); - }); - - it('escapes text', () => { - createFlash({ ...defaultParams, message: '<script>alert("a")</script>' }); - - const html = document.querySelector('.flash-text').innerHTML; - - expect(html).toContain('<script>alert("a")</script>'); - expect(html).not.toContain('<script>alert("a")</script>'); - }); - - it('adds flash into specified parent', () => { - createFlash({ ...defaultParams, parent: document.querySelector('.content-wrapper') }); - - expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull(); - expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message); - }); - - it('adds container classes when inside content-wrapper', () => { - createFlash(defaultParams); - - expect(document.querySelector('.flash-text').className).toBe('flash-text'); - expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message); - }); - - it('does not add container when outside of content-wrapper', () => { - document.querySelector('.content-wrapper').className = 'js-content-wrapper'; - createFlash(defaultParams); - - expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text'); - }); - - it('removes element after clicking', () => { - createFlash({ ...defaultParams }); - - document.querySelector('.flash-alert .js-close-icon').click(); - - expect(document.querySelector('.flash-alert')).toBeNull(); - - expect(document.body.className).not.toContain('flash-shown'); - }); - - it('does not capture error using Sentry', () => { - createFlash({ ...defaultParams, captureError: false, error: new Error('Error!') }); - - expect(Sentry.captureException).not.toHaveBeenCalled(); - }); - - it('captures error using Sentry', () => { - createFlash({ ...defaultParams, captureError: true, error: new Error('Error!') }); - - expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); - expect(Sentry.captureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Error!', - }), - ); - }); - - describe('with actionConfig', () => { - const findFlashAction = () => document.querySelector('.flash-container .flash-action'); - - it('adds action link', () => { - createFlash({ - ...defaultParams, - actionConfig: { - title: 'test', - }, - }); - - expect(findFlashAction()).not.toBeNull(); - }); - - it('creates link with href', () => { - createFlash({ - ...defaultParams, - actionConfig: { - href: 'testing', - title: 'test', - }, - }); - - const action = findFlashAction(); - - expect(action.href).toBe(`${window.location}testing`); - expect(action.textContent.trim()).toBe('test'); - }); - - it('uses hash as href when no href is present', () => { - createFlash({ - ...defaultParams, - actionConfig: { - title: 'test', - }, - }); - - expect(findFlashAction().href).toBe(`${window.location}#`); - }); - - it('adds role when no href is present', () => { - createFlash({ - ...defaultParams, - actionConfig: { - title: 'test', - }, - }); - - expect(findFlashAction().getAttribute('role')).toBe('button'); - }); - - it('escapes the title text', () => { - createFlash({ - ...defaultParams, - actionConfig: { - title: '<script>alert("a")</script>', - }, - }); - - const html = findFlashAction().innerHTML; - - expect(html).toContain('<script>alert("a")</script>'); - expect(html).not.toContain('<script>alert("a")</script>'); - }); - - it('calls actionConfig clickHandler on click', () => { - const clickHandler = jest.fn(); - - createFlash({ - ...defaultParams, - actionConfig: { - title: 'test', - clickHandler, - }, - }); - - findFlashAction().click(); - - expect(clickHandler).toHaveBeenCalled(); - }); - }); - - describe('additional behavior', () => { - describe('close', () => { - it('clicks the close icon', () => { - const flash = createFlash({ ...defaultParams }); - const close = document.querySelector('.flash-alert .js-close-icon'); - - jest.spyOn(close, 'click'); - flash.close(); - - expect(close.click.mock.calls.length).toBe(1); - }); - }); - }); - }); - }); - describe('addDismissFlashClickListener', () => { let el; diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js index c201bbf4af2..b1e87aca63d 100644 --- a/spec/frontend/frequent_items/components/app_spec.js +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -1,3 +1,4 @@ +import { GlButton, GlIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; @@ -103,6 +104,7 @@ describe('Frequent Items App Component', () => { expect(loading.exists()).toBe(true); expect(loading.find('[aria-label="Loading projects"]').exists()).toBe(true); + expect(findSectionHeader().exists()).toBe(false); }); it('should render frequent projects list header', () => { @@ -112,25 +114,6 @@ describe('Frequent Items App Component', () => { expect(sectionHeader.text()).toBe('Frequently visited'); }); - it('should render frequent projects list', async () => { - const expectedResult = getTopFrequentItems(mockFrequentProjects); - localStorage.setItem(TEST_STORAGE_KEY, JSON.stringify(mockFrequentProjects)); - - expect(findFrequentItems().length).toBe(1); - - triggerDropdownOpen(); - await nextTick(); - - expect(findFrequentItems().length).toBe(expectedResult.length); - expect(findFrequentItemsList().props()).toEqual({ - items: expectedResult, - namespace: TEST_NAMESPACE, - hasSearchQuery: false, - isFetchFailed: false, - matcher: '', - }); - }); - it('should render searched projects list', async () => { mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects.data); @@ -164,6 +147,47 @@ describe('Frequent Items App Component', () => { }), ); }); + + describe('with frequent items list', () => { + const expectedResult = getTopFrequentItems(mockFrequentProjects); + + beforeEach(async () => { + localStorage.setItem(TEST_STORAGE_KEY, JSON.stringify(mockFrequentProjects)); + triggerDropdownOpen(); + await nextTick(); + }); + + it('should render edit button within header', () => { + const itemEditButton = findSectionHeader().findComponent(GlButton); + + expect(itemEditButton.exists()).toBe(true); + expect(itemEditButton.attributes('title')).toBe('Toggle edit mode'); + expect(itemEditButton.findComponent(GlIcon).props('name')).toBe('pencil'); + }); + + it('should render frequent projects list', () => { + expect(findFrequentItems().length).toBe(expectedResult.length); + expect(findFrequentItemsList().props()).toEqual({ + items: expectedResult, + namespace: TEST_NAMESPACE, + hasSearchQuery: false, + isFetchFailed: false, + isItemRemovalFailed: false, + matcher: '', + }); + }); + + it('dispatches action `toggleItemsListEditablity` when edit button is clicked', async () => { + const itemEditButton = findSectionHeader().findComponent(GlButton); + itemEditButton.vm.$emit('click'); + + await nextTick(); + + expect(store.dispatch).toHaveBeenCalledWith( + `${TEST_VUEX_MODULE}/toggleItemsListEditablity`, + ); + }); + }); }); describe('with searchClass', () => { diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js index e6673fa78ec..4f2badf869d 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js @@ -1,5 +1,5 @@ -import { GlButton } from '@gitlab/ui'; -import Vue from 'vue'; +import { GlIcon } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { trimText } from 'helpers/text_helper'; @@ -12,6 +12,7 @@ import { mockProject } from '../mock_data'; Vue.use(Vuex); describe('FrequentItemsListItemComponent', () => { + const TEST_VUEX_MODULE = 'frequentProjects'; let wrapper; let trackingSpy; let store; @@ -20,11 +21,18 @@ describe('FrequentItemsListItemComponent', () => { const findAvatar = () => wrapper.findComponent(ProjectAvatar); const findAllTitles = () => wrapper.findAllByTestId('frequent-items-item-title'); const findNamespace = () => wrapper.findByTestId('frequent-items-item-namespace'); - const findAllButtons = () => wrapper.findAllComponents(GlButton); + const findAllFrequentItems = () => wrapper.findAllByTestId('frequent-item-link'); const findAllNamespace = () => wrapper.findAllByTestId('frequent-items-item-namespace'); const findAllAvatars = () => wrapper.findAllComponents(ProjectAvatar); const findAllMetadataContainers = () => wrapper.findAllByTestId('frequent-items-item-metadata-container'); + const findRemoveButton = () => wrapper.findByTestId('item-remove'); + + const toggleItemsListEditablity = async () => { + store.dispatch(`${TEST_VUEX_MODULE}/toggleItemsListEditablity`); + + await nextTick(); + }; const createComponent = (props = {}) => { wrapper = shallowMountExtended(frequentItemsListItemComponent, { @@ -38,7 +46,7 @@ describe('FrequentItemsListItemComponent', () => { ...props, }, provide: { - vuexModule: 'frequentProjects', + vuexModule: TEST_VUEX_MODULE, }, }); }; @@ -102,7 +110,7 @@ describe('FrequentItemsListItemComponent', () => { it.each` name | selector | expected - ${'button'} | ${findAllButtons} | ${1} + ${'list item'} | ${findAllFrequentItems} | ${1} ${'avatar container'} | ${findAllAvatars} | ${1} ${'metadata container'} | ${findAllMetadataContainers} | ${1} ${'title'} | ${findAllTitles} | ${1} @@ -111,8 +119,37 @@ describe('FrequentItemsListItemComponent', () => { expect(selector()).toHaveLength(expected); }); + it('renders remove button within item when `isItemsListEditable` is true', async () => { + await toggleItemsListEditablity(); + + const removeButton = findRemoveButton(); + expect(removeButton.exists()).toBe(true); + expect(removeButton.attributes('title')).toBe('Remove'); + expect(removeButton.findComponent(GlIcon).props('name')).toBe('close'); + }); + + it('dispatches action `removeFrequentItem` when remove button is clicked', async () => { + await toggleItemsListEditablity(); + + jest.spyOn(store, 'dispatch'); + + const removeButton = findRemoveButton(); + removeButton.vm.$emit( + 'click', + { stopPropagation: jest.fn(), preventDefault: jest.fn() }, + mockProject.id, + ); + + await nextTick(); + + expect(store.dispatch).toHaveBeenCalledWith( + `${TEST_VUEX_MODULE}/removeFrequentItem`, + mockProject.id, + ); + }); + it('tracks when item link is clicked', () => { - const link = wrapper.findComponent(GlButton); + const link = wrapper.findByTestId('frequent-item-link'); link.vm.$emit('click'); 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 9f08a432a3d..d024925f62b 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_spec.js @@ -18,6 +18,7 @@ describe('FrequentItemsListComponent', () => { namespace: 'projects', items: mockFrequentProjects, isFetchFailed: false, + isItemRemovalFailed: false, hasSearchQuery: false, matcher: 'lab', ...props, @@ -51,22 +52,34 @@ describe('FrequentItemsListComponent', () => { }); describe('fetched item messages', () => { - it('should return appropriate empty list message based on value of `localStorageFailed` prop with projects', async () => { + it('should show default empty list message', async () => { createComponent({ - isFetchFailed: true, + items: [], }); - expect(wrapper.vm.listEmptyMessage).toBe( - 'This feature requires browser localStorage support', + expect(wrapper.findByTestId('frequent-items-list-empty').text()).toContain( + 'Projects you visit often will appear here', ); - - wrapper.setProps({ - isFetchFailed: false, - }); - await nextTick(); - - expect(wrapper.vm.listEmptyMessage).toBe('Projects you visit often will appear here'); }); + + it.each` + isFetchFailed | isItemRemovalFailed + ${true} | ${false} + ${false} | ${true} + `( + 'should show failure message when `isFetchFailed` is $isFetchFailed or `isItemRemovalFailed` is $isItemRemovalFailed', + ({ isFetchFailed, isItemRemovalFailed }) => { + createComponent({ + items: [], + isFetchFailed, + isItemRemovalFailed, + }); + + expect(wrapper.findByTestId('frequent-items-list-empty').text()).toContain( + 'This feature requires browser localStorage support', + ); + }, + ); }); describe('searched item messages', () => { diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js index 3fc3eaf52a2..4f998cc26da 100644 --- a/spec/frontend/frequent_items/store/actions_spec.js +++ b/spec/frontend/frequent_items/store/actions_spec.js @@ -5,6 +5,7 @@ import * as types from '~/frequent_items/store/mutation_types'; import state from '~/frequent_items/store/state'; import AccessorUtilities from '~/lib/utils/accessor'; import axios from '~/lib/utils/axios_utils'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { mockNamespace, mockStorageKey, @@ -13,6 +14,7 @@ import { } from '../mock_data'; describe('Frequent Items Dropdown Store Actions', () => { + useLocalStorageSpy(); let mockedState; let mock; @@ -52,6 +54,18 @@ describe('Frequent Items Dropdown Store Actions', () => { }); }); + describe('toggleItemsListEditablity', () => { + it('should toggle items list editablity', () => { + return testAction( + actions.toggleItemsListEditablity, + null, + mockedState, + [{ type: types.TOGGLE_ITEMS_LIST_EDITABILITY }], + [], + ); + }); + }); + describe('requestFrequentItems', () => { it('should request frequent items', () => { return testAction( @@ -211,4 +225,77 @@ describe('Frequent Items Dropdown Store Actions', () => { ); }); }); + + describe('removeFrequentItemSuccess', () => { + it('should remove frequent item on success', () => { + return testAction( + actions.removeFrequentItemSuccess, + { itemId: 1 }, + mockedState, + [ + { + type: types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS, + payload: { itemId: 1 }, + }, + ], + [], + ); + }); + }); + + describe('removeFrequentItemError', () => { + it('should should not remove frequent item on failure', () => { + return testAction( + actions.removeFrequentItemError, + null, + mockedState, + [{ type: types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR }], + [], + ); + }); + }); + + describe('removeFrequentItem', () => { + beforeEach(() => { + mockedState.items = [...mockFrequentProjects]; + window.localStorage.setItem(mockStorageKey, JSON.stringify(mockFrequentProjects)); + }); + + it('should remove provided itemId from localStorage', () => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); + + actions.removeFrequentItem( + { commit: jest.fn(), dispatch: jest.fn(), state: mockedState }, + mockFrequentProjects[0].id, + ); + + expect(window.localStorage.getItem(mockStorageKey)).toBe( + JSON.stringify(mockFrequentProjects.slice(1)), // First item was removed + ); + }); + + it('should dispatch `removeFrequentItemSuccess` on localStorage update success', () => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); + + return testAction( + actions.removeFrequentItem, + mockFrequentProjects[0].id, + mockedState, + [], + [{ type: 'removeFrequentItemSuccess', payload: mockFrequentProjects[0].id }], + ); + }); + + it('should dispatch `removeFrequentItemError` on localStorage update failure', () => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); + + return testAction( + actions.removeFrequentItem, + mockFrequentProjects[0].id, + mockedState, + [], + [{ type: 'removeFrequentItemError' }], + ); + }); + }); }); diff --git a/spec/frontend/frequent_items/store/mutations_spec.js b/spec/frontend/frequent_items/store/mutations_spec.js index e593c9fae58..1e1878c3377 100644 --- a/spec/frontend/frequent_items/store/mutations_spec.js +++ b/spec/frontend/frequent_items/store/mutations_spec.js @@ -44,6 +44,18 @@ describe('Frequent Items dropdown mutations', () => { }); }); + describe('TOGGLE_ITEMS_LIST_EDITABILITY', () => { + it('should toggle items list editablity', () => { + mutations[types.TOGGLE_ITEMS_LIST_EDITABILITY](stateCopy); + + expect(stateCopy.isItemsListEditable).toEqual(true); + + mutations[types.TOGGLE_ITEMS_LIST_EDITABILITY](stateCopy); + + expect(stateCopy.isItemsListEditable).toEqual(false); + }); + }); + describe('REQUEST_FREQUENT_ITEMS', () => { it('should set view states when requesting frequent items', () => { mutations[types.REQUEST_FREQUENT_ITEMS](stateCopy); @@ -114,4 +126,27 @@ describe('Frequent Items dropdown mutations', () => { expect(stateCopy.isFetchFailed).toEqual(true); }); }); + + describe('RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS', () => { + it('should remove item with provided itemId from the items', () => { + stateCopy.isItemRemovalFailed = true; + stateCopy.items = mockFrequentProjects; + + mutations[types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS](stateCopy, mockFrequentProjects[0].id); + + expect(stateCopy.items).toHaveLength(mockFrequentProjects.length - 1); + expect(stateCopy.items).toEqual([...mockFrequentProjects.slice(1)]); + expect(stateCopy.isItemRemovalFailed).toBe(false); + }); + }); + + describe('RECEIVE_REMOVE_FREQUENT_ITEM_ERROR', () => { + it('should remove item with provided itemId from the items', () => { + stateCopy.isItemRemovalFailed = false; + + mutations[types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR](stateCopy); + + expect(stateCopy.isItemRemovalFailed).toBe(true); + }); + }); }); diff --git a/spec/frontend/gfm_auto_complete/mock_data.js b/spec/frontend/gfm_auto_complete/mock_data.js index 9c5a9d7ef3d..d58ccaf0f39 100644 --- a/spec/frontend/gfm_auto_complete/mock_data.js +++ b/spec/frontend/gfm_auto_complete/mock_data.js @@ -37,8 +37,8 @@ export const crmContactsMock = [ { id: 1, email: 'contact.1@email.com', - firstName: 'Contact', - lastName: 'One', + first_name: 'Contact', + last_name: 'One', search: 'contact.1@email.com', state: 'active', set: false, @@ -46,8 +46,8 @@ export const crmContactsMock = [ { id: 2, email: 'contact.2@email.com', - firstName: 'Contact', - lastName: 'Two', + first_name: 'Contact', + last_name: 'Two', search: 'contact.2@email.com', state: 'active', set: false, @@ -55,8 +55,8 @@ export const crmContactsMock = [ { id: 3, email: 'contact.3@email.com', - firstName: 'Contact', - lastName: 'Three', + first_name: 'Contact', + last_name: 'Three', search: 'contact.3@email.com', state: 'inactive', set: false, @@ -64,8 +64,8 @@ export const crmContactsMock = [ { id: 4, email: 'contact.4@email.com', - firstName: 'Contact', - lastName: 'Four', + first_name: 'Contact', + last_name: 'Four', search: 'contact.4@email.com', state: 'inactive', set: true, @@ -73,8 +73,8 @@ export const crmContactsMock = [ { id: 5, email: 'contact.5@email.com', - firstName: 'Contact', - lastName: 'Five', + first_name: 'Contact', + last_name: 'Five', search: 'contact.5@email.com', state: 'active', set: true, @@ -82,8 +82,8 @@ export const crmContactsMock = [ { id: 5, email: 'contact.6@email.com', - firstName: 'Contact', - lastName: 'Six', + first_name: 'Contact', + last_name: 'Six', search: 'contact.6@email.com', state: 'active', set: undefined, // On purpose diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index eeef92d4183..cc2dc084e47 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import GfmAutoComplete, { + escape, membersBeforeSave, highlighter, CONTACT_STATE_ACTIVE, @@ -21,6 +22,20 @@ import { crmContactsMock, } from 'ee_else_ce_jest/gfm_auto_complete/mock_data'; +describe('escape', () => { + it.each` + xssPayload | escapedPayload + ${'<script>alert(1)</script>'} | ${'<script>alert(1)</script>'} + ${'%3Cscript%3E alert(1) %3C%2Fscript%3E'} | ${'<script> alert(1) </script>'} + ${'%253Cscript%253E alert(1) %253C%252Fscript%253E'} | ${'<script> alert(1) </script>'} + `( + 'escapes the input string correctly accounting for multiple encoding', + ({ xssPayload, escapedPayload }) => { + expect(escape(xssPayload)).toBe(escapedPayload); + }, + ); +}); + describe('GfmAutoComplete', () => { const fetchDataMock = { fetchData: jest.fn() }; let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); @@ -590,7 +605,7 @@ describe('GfmAutoComplete', () => { id: 5, title: '${search}<script>oh no $', // eslint-disable-line no-template-curly-in-string }), - ).toBe('<li><small>5</small> ${search}<script>oh no $</li>'); + ).toBe('<li><small>5</small> &dollar;{search}<script>oh no &dollar;</li>'); }); }); @@ -636,7 +651,7 @@ describe('GfmAutoComplete', () => { availabilityStatus: '', }), ).toBe( - '<li>IMG my-group <small>${search}<script>oh no $</small> <i class="icon"/></li>', + '<li>IMG my-group <small>&dollar;{search}<script>oh no &dollar;</small> <i class="icon"/></li>', ); }); @@ -813,7 +828,7 @@ describe('GfmAutoComplete', () => { const title = '${search}<script>oh no $'; // eslint-disable-line no-template-curly-in-string expect(GfmAutoComplete.Labels.templateFunction(color, title)).toBe( - '<li><span class="dropdown-label-box" style="background: #123456"></span> ${search}<script>oh no $</li>', + '<li><span class="dropdown-label-box" style="background: #123456"></span> &dollar;{search}<script>oh no &dollar;</li>', ); }); }); @@ -868,7 +883,7 @@ describe('GfmAutoComplete', () => { const title = '${search}<script>oh no $'; // eslint-disable-line no-template-curly-in-string expect(GfmAutoComplete.Milestones.templateFunction(title, expired)).toBe( - '<li>${search}<script>oh no $</li>', + '<li>&dollar;{search}<script>oh no &dollar;</li>', ); }); }); @@ -925,7 +940,9 @@ describe('GfmAutoComplete', () => { const expectContacts = ({ input, output }) => { triggerDropdown(input); - expect(getDropdownItems()).toEqual(output.map((contact) => contact.email)); + expect(getDropdownItems()).toEqual( + output.map((contact) => `${contact.first_name} ${contact.last_name} ${contact.email}`), + ); }; describe('with no contacts assigned', () => { diff --git a/spec/frontend/group_settings/components/shared_runners_form_spec.js b/spec/frontend/group_settings/components/shared_runners_form_spec.js index 5282c0ed839..85475c749b0 100644 --- a/spec/frontend/group_settings/components/shared_runners_form_spec.js +++ b/spec/frontend/group_settings/components/shared_runners_form_spec.js @@ -10,7 +10,7 @@ jest.mock('~/api/groups_api'); const GROUP_ID = '99'; const RUNNER_ENABLED_VALUE = 'enabled'; const RUNNER_DISABLED_VALUE = 'disabled_and_unoverridable'; -const RUNNER_ALLOW_OVERRIDE_VALUE = 'disabled_with_override'; +const RUNNER_ALLOW_OVERRIDE_VALUE = 'disabled_and_overridable'; describe('group_settings/components/shared_runners_form', () => { let wrapper; diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 211fee31a9c..9092d73571b 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -122,7 +122,7 @@ describe('RepoEditor', () => { vm.$once('editorSetup', resolve); }); - const createComponent = async ({ state = {}, activeFile = dummyFile.text, flags = {} } = {}) => { + const createComponent = async ({ state = {}, activeFile = dummyFile.text } = {}) => { const store = prepareStore(state, activeFile); wrapper = shallowMount(RepoEditor, { store, @@ -132,9 +132,6 @@ describe('RepoEditor', () => { mocks: { ContentViewer, }, - provide: { - glFeatures: flags, - }, }); await waitForPromises(); vm = wrapper.vm; @@ -196,12 +193,8 @@ describe('RepoEditor', () => { }); describe('schema registration for .gitlab-ci.yml', () => { - const setup = async (activeFile, flagIsOn = true) => { - await createComponent({ - flags: { - schemaLinting: flagIsOn, - }, - }); + const setup = async (activeFile) => { + await createComponent(); vm.editor.registerCiSchema = jest.fn(); if (activeFile) { wrapper.setProps({ file: activeFile }); @@ -210,15 +203,13 @@ describe('RepoEditor', () => { await nextTick(); }; it.each` - flagIsOn | activeFile | shouldUseExtension | desc - ${false} | ${dummyFile.markdown} | ${false} | ${`file is not CI config; should NOT`} - ${true} | ${dummyFile.markdown} | ${false} | ${`file is not CI config; should NOT`} - ${false} | ${dummyFile.ciConfig} | ${false} | ${`file is CI config; should NOT`} - ${true} | ${dummyFile.ciConfig} | ${true} | ${`file is CI config; should`} + activeFile | shouldUseExtension | desc + ${dummyFile.markdown} | ${false} | ${`file is not CI config; should NOT`} + ${dummyFile.ciConfig} | ${true} | ${`file is CI config; should`} `( - 'when the flag is "$flagIsOn", $desc use extension', - async ({ flagIsOn, activeFile, shouldUseExtension }) => { - await setup(activeFile, flagIsOn); + 'when the activeFile is "$activeFile", $desc use extension', + async ({ activeFile, shouldUseExtension }) => { + await setup(activeFile); if (shouldUseExtension) { expect(applyExtensionSpy).toHaveBeenCalledWith({ diff --git a/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js index 4b4e96f3b41..ed67a0948e4 100644 --- a/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js +++ b/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js @@ -3,20 +3,32 @@ import { TEST_HOST } from 'helpers/test_constants'; const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/gitlab-web-ide/public/path'; const TEST_GITLAB_URL = 'https://gdk.test/'; +const TEST_RELATIVE_URL_ROOT = '/gl_rel_root'; describe('~/ide/lib/gitlab_web_ide/get_base_config', () => { - it('returns base properties for @gitlab/web-ide config', () => { + beforeEach(() => { // why: add trailing "/" to test that it gets removed process.env.GITLAB_WEB_IDE_PUBLIC_PATH = `${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}/`; window.gon.gitlab_url = TEST_GITLAB_URL; + window.gon.relative_url_root = ''; + }); - // act + it('with default, returns base properties for @gitlab/web-ide config', () => { const actual = getBaseConfig(); - // asset expect(actual).toEqual({ baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`, gitlabUrl: TEST_GITLAB_URL, }); }); + + it('with relative_url_root, returns baseUrl with relative url root', () => { + window.gon.relative_url_root = TEST_RELATIVE_URL_ROOT; + + const actual = getBaseConfig(); + + expect(actual).toMatchObject({ + baseUrl: `${TEST_HOST}${TEST_RELATIVE_URL_ROOT}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`, + }); + }); }); diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js index 4e8467de759..8601e13f7ca 100644 --- a/spec/frontend/ide/stores/modules/commit/actions_spec.js +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -366,17 +366,38 @@ describe('IDE commit module actions', () => { }); describe('merge request', () => { - it('redirects to new merge request page', async () => { - jest.spyOn(eventHub, '$on').mockImplementation(); + it.each` + branchName | targetBranchName | branchNameInURL | targetBranchInURL + ${'foo'} | ${'main'} | ${'foo'} | ${'main'} + ${'foo#bar'} | ${'main'} | ${'foo%23bar'} | ${'main'} + ${'foo#bar'} | ${'not#so#main'} | ${'foo%23bar'} | ${'not%23so%23main'} + `( + 'redirects to the correct new MR page when new branch is "$branchName" and target branch is "$targetBranchName"', + async ({ branchName, targetBranchName, branchNameInURL, targetBranchInURL }) => { + Object.assign(store.state.projects.abcproject, { + branches: { + [targetBranchName]: { + name: targetBranchName, + workingReference: '1', + commit: { + id: TEST_COMMIT_SHA, + }, + can_push: true, + }, + }, + }); + store.state.currentBranchId = targetBranchName; + store.state.commit.newBranchName = branchName; - store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH; - store.state.commit.shouldCreateMR = true; + store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH; + store.state.commit.shouldCreateMR = true; - await store.dispatch('commit/commitChanges'); - expect(visitUrl).toHaveBeenCalledWith( - `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=main&nav_source=webide`, - ); - }); + await store.dispatch('commit/commitChanges'); + expect(visitUrl).toHaveBeenCalledWith( + `webUrl/-/merge_requests/new?merge_request[source_branch]=${branchNameInURL}&merge_request[target_branch]=${targetBranchInURL}&nav_source=webide`, + ); + }, + ); it('does not redirect to new merge request page when shouldCreateMR is not checked', async () => { jest.spyOn(eventHub, '$on').mockImplementation(); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js index 8d21088bcaf..09be1e333b3 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js @@ -10,7 +10,11 @@ import { import * as messages from '~/ide/stores/modules/terminal/messages'; import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; import axios from '~/lib/utils/axios_utils'; -import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { + HTTP_STATUS_FORBIDDEN, + HTTP_STATUS_NOT_FOUND, + HTTP_STATUS_UNPROCESSABLE_ENTITY, +} from '~/lib/utils/http_status'; const TEST_PROJECT_PATH = 'lorem/root'; const TEST_BRANCH_ID = 'main'; @@ -102,7 +106,7 @@ describe('IDE store terminal check actions', () => { ); }); - [httpStatus.FORBIDDEN, httpStatus.NOT_FOUND].forEach((status) => { + [HTTP_STATUS_FORBIDDEN, HTTP_STATUS_NOT_FOUND].forEach((status) => { it(`hides tab, when status is ${status}`, () => { const payload = { response: { status } }; diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js index df365442c67..9fd5f1a38d7 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js @@ -6,7 +6,7 @@ import { STARTING, PENDING, STOPPING, STOPPED } from '~/ide/stores/modules/termi import * as messages from '~/ide/stores/modules/terminal/messages'; import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; import axios from '~/lib/utils/axios_utils'; -import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; jest.mock('~/flash'); @@ -285,7 +285,7 @@ describe('IDE store terminal session controls actions', () => { ); }); - [httpStatus.NOT_FOUND, HTTP_STATUS_UNPROCESSABLE_ENTITY].forEach((status) => { + [HTTP_STATUS_NOT_FOUND, HTTP_STATUS_UNPROCESSABLE_ENTITY].forEach((status) => { it(`dispatches request and startSession on ${status}`, () => { mock .onPost(state.session.retryPath, { branch: rootState.currentBranchId, format: 'json' }) diff --git a/spec/frontend/ide/stores/modules/terminal/messages_spec.js b/spec/frontend/ide/stores/modules/terminal/messages_spec.js index 2a802d6b4af..f99496a4b98 100644 --- a/spec/frontend/ide/stores/modules/terminal/messages_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/messages_spec.js @@ -1,7 +1,11 @@ import { escape } from 'lodash'; import { TEST_HOST } from 'spec/test_constants'; import * as messages from '~/ide/stores/modules/terminal/messages'; -import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { + HTTP_STATUS_FORBIDDEN, + HTTP_STATUS_NOT_FOUND, + HTTP_STATUS_UNPROCESSABLE_ENTITY, +} from '~/lib/utils/http_status'; import { sprintf } from '~/locale'; const TEST_HELP_URL = `${TEST_HOST}/help`; @@ -26,13 +30,13 @@ describe('IDE store terminal messages', () => { }); it('returns permission error, with status FORBIDDEN', () => { - const result = messages.configCheckError(httpStatus.FORBIDDEN, TEST_HELP_URL); + const result = messages.configCheckError(HTTP_STATUS_FORBIDDEN, TEST_HELP_URL); expect(result).toBe(messages.ERROR_PERMISSION); }); it('returns unexpected error, with unexpected status', () => { - const result = messages.configCheckError(httpStatus.NOT_FOUND, TEST_HELP_URL); + const result = messages.configCheckError(HTTP_STATUS_NOT_FOUND, TEST_HELP_URL); expect(result).toBe(messages.UNEXPECTED_ERROR_CONFIG); }); diff --git a/spec/frontend/import_entities/components/import_status_spec.js b/spec/frontend/import_entities/components/import_status_spec.js index 686a21e3923..56c4ed827d7 100644 --- a/spec/frontend/import_entities/components/import_status_spec.js +++ b/spec/frontend/import_entities/components/import_status_spec.js @@ -18,6 +18,7 @@ describe('Import entities status component', () => { describe('success status', () => { const getStatusText = () => wrapper.findComponent(GlBadge).text(); + const getStatusIcon = () => wrapper.findComponent(GlBadge).props('icon'); it('displays finished status as complete when no stats are provided', () => { createComponent({ @@ -38,6 +39,7 @@ describe('Import entities status component', () => { }); expect(getStatusText()).toBe('Complete'); + expect(getStatusIcon()).toBe('status-success'); }); it('displays finished status as partial when all stats items were processed', () => { @@ -52,6 +54,7 @@ describe('Import entities status component', () => { }); expect(getStatusText()).toBe('Partial import'); + expect(getStatusIcon()).toBe('status-alert'); }); }); @@ -105,9 +108,9 @@ describe('Import entities status component', () => { const getStatusIcon = () => wrapper.findComponent(GlAccordionItem).findComponent(GlIcon).props().name; - const createComponentWithStats = ({ fetched, imported }) => { + const createComponentWithStats = ({ fetched, imported, status = 'created' }) => { createComponent({ - status: 'created', + status, stats: { fetched: { label: fetched }, imported: { label: imported }, @@ -124,7 +127,7 @@ describe('Import entities status component', () => { expect(getStatusIcon()).toBe('status-scheduled'); }); - it('displays running status when imported is not equal to fetched', () => { + it('displays running status when imported is not equal to fetched and import is not finished', () => { createComponentWithStats({ fetched: 100, imported: 10, @@ -133,6 +136,16 @@ describe('Import entities status component', () => { expect(getStatusIcon()).toBe('status-running'); }); + it('displays alert status when imported is not equal to fetched and import is finished', () => { + createComponentWithStats({ + fetched: 100, + imported: 10, + status: STATUSES.FINISHED, + }); + + expect(getStatusIcon()).toBe('status-alert'); + }); + it('displays success status when imported is equal to fetched', () => { createComponentWithStats({ fetched: 100, 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 cd56f573011..da7fb4e060d 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 @@ -1,4 +1,4 @@ -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue'; @@ -8,6 +8,7 @@ describe('import actions cell', () => { const createComponent = (props) => { wrapper = shallowMount(ImportActionsCell, { propsData: { + isProjectsImportEnabled: false, isFinished: false, isAvailableForImport: false, isInvalid: false, @@ -78,4 +79,39 @@ describe('import actions cell', () => { expect(wrapper.emitted('import-group')).toHaveLength(1); }); + + describe.each` + isFinished | expectedAction + ${false} | ${'Import'} + ${true} | ${'Re-import'} + `( + 'when import projects is enabled, group is available for import and finish status is $status', + ({ isFinished, expectedAction }) => { + beforeEach(() => { + createComponent({ isProjectsImportEnabled: true, isAvailableForImport: true, isFinished }); + }); + + it('render import dropdown', () => { + const dropdown = wrapper.findComponent(GlDropdown); + expect(dropdown.props('text')).toBe(`${expectedAction} with projects`); + expect(dropdown.findComponent(GlDropdownItem).text()).toBe( + `${expectedAction} without projects`, + ); + }); + + it('request migrate projects by default', async () => { + 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 () => { + const dropdown = wrapper.findComponent(GlDropdown); + dropdown.findComponent(GlDropdownItem).vm.$emit('click'); + + expect(wrapper.emitted('import-group')[0]).toStrictEqual([{ migrateProjects: false }]); + }); + }, + ); }); 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 f7a97f22d44..bd79e20e698 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 @@ -7,7 +7,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createAlert } from '~/flash'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_BAD_REQUEST } from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import { STATUSES } from '~/import_entities/constants'; import { i18n, ROOT_NAMESPACE } from '~/import_entities/import_groups/constants'; @@ -49,6 +49,8 @@ describe('import table', () => { const findImportSelectedButton = () => wrapper.findAll('button').wrappers.find((w) => w.text() === 'Import selected'); + const findImportSelectedDropdown = () => + wrapper.findAll('.gl-dropdown').wrappers.find((w) => w.text().includes('Import with projects')); const findImportButtons = () => wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import'); const findPaginationDropdown = () => wrapper.find('[data-testid="page-size"]'); @@ -64,7 +66,12 @@ describe('import table', () => { const selectRow = (idx) => wrapper.findAll('tbody td input[type=checkbox]').at(idx).setChecked(true); - const createComponent = ({ bulkImportSourceGroups, importGroups, defaultTargetNamespace }) => { + const createComponent = ({ + bulkImportSourceGroups, + importGroups, + defaultTargetNamespace, + glFeatures = {}, + }) => { apolloProvider = createMockApollo( [ [ @@ -93,6 +100,9 @@ describe('import table', () => { directives: { GlTooltip: createMockDirective(), }, + provide: { + glFeatures, + }, apolloProvider, }); }; @@ -258,7 +268,7 @@ describe('import table', () => { }, }); - axiosMock.onPost('/import/bulk_imports.json').reply(httpStatus.BAD_REQUEST); + axiosMock.onPost('/import/bulk_imports.json').reply(HTTP_STATUS_BAD_REQUEST); await waitForPromises(); await findImportButtons()[0].trigger('click'); @@ -530,16 +540,16 @@ describe('import table', () => { mutation: importGroupsMutation, variables: { importRequests: [ - { + expect.objectContaining({ targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, newName: NEW_GROUPS[0].lastImportTarget.newName, sourceGroupId: NEW_GROUPS[0].id, - }, - { + }), + expect.objectContaining({ targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, newName: NEW_GROUPS[1].lastImportTarget.newName, sourceGroupId: NEW_GROUPS[1].id, - }, + }), ], }, }); @@ -610,4 +620,83 @@ describe('import table', () => { expect(wrapper.findComponent(GlAlert).exists()).toBe(false); }); }); + + describe('when import projects is enabled', () => { + const NEW_GROUPS = [ + generateFakeEntry({ id: 1, status: STATUSES.NONE }), + generateFakeEntry({ id: 2, status: STATUSES.NONE }), + generateFakeEntry({ id: 3, status: STATUSES.FINISHED }), + ]; + + beforeEach(() => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: NEW_GROUPS, + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + glFeatures: { + bulkImportProjects: true, + }, + }); + jest.spyOn(apolloProvider.defaultClient, 'mutate'); + return waitForPromises(); + }); + + it('renders import all dropdown', async () => { + expect(findImportSelectedDropdown().exists()).toBe(true); + }); + + it('includes migrateProjects: true when dropdown is clicked', async () => { + await selectRow(0); + await selectRow(1); + await nextTick(); + await findImportSelectedDropdown().find('button').trigger('click'); + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: importGroupsMutation, + variables: { + importRequests: [ + expect.objectContaining({ + targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, + newName: NEW_GROUPS[0].lastImportTarget.newName, + sourceGroupId: NEW_GROUPS[0].id, + migrateProjects: true, + }), + expect.objectContaining({ + targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, + newName: NEW_GROUPS[1].lastImportTarget.newName, + sourceGroupId: NEW_GROUPS[1].id, + migrateProjects: true, + }), + ], + }, + }); + }); + + it('includes migrateProjects: false when dropdown item is clicked', async () => { + await selectRow(0); + await selectRow(1); + await nextTick(); + await findImportSelectedDropdown().find('.gl-dropdown-item button').trigger('click'); + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: importGroupsMutation, + variables: { + importRequests: [ + expect.objectContaining({ + targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, + newName: NEW_GROUPS[0].lastImportTarget.newName, + sourceGroupId: NEW_GROUPS[0].id, + migrateProjects: false, + }), + expect.objectContaining({ + targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, + newName: NEW_GROUPS[1].lastImportTarget.newName, + sourceGroupId: NEW_GROUPS[1].id, + migrateProjects: false, + }), + ], + }, + }); + }); + }); }); 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 adc4ebcffb8..ce111a0c10c 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 @@ -13,7 +13,7 @@ import updateImportStatusMutation from '~/import_entities/import_groups/graphql/ import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { statusEndpointFixture } from './fixtures'; jest.mock('~/flash'); @@ -52,7 +52,7 @@ describe('Bulk import resolvers', () => { axiosMockAdapter = new MockAdapter(axios); client = createClient(); - axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture); + axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(HTTP_STATUS_OK, statusEndpointFixture); client.watchQuery({ query: bulkImportSourceGroupsQuery }).subscribe(({ data }) => { results = data.bulkImportSourceGroups.nodes; }); @@ -143,7 +143,7 @@ describe('Bulk import resolvers', () => { it('sets import status to CREATED for successful groups when request completes', async () => { axiosMockAdapter .onPost(FAKE_ENDPOINTS.createBulkImport) - .reply(httpStatus.OK, [{ success: true, id: 1 }]); + .reply(HTTP_STATUS_OK, [{ success: true, id: 1 }]); await client.mutate({ mutation: importGroupsMutation, @@ -163,7 +163,7 @@ describe('Bulk import resolvers', () => { }); it('sets import status to CREATED for successful groups when request completes with legacy response', async () => { - axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(HTTP_STATUS_OK, { id: 1 }); await client.mutate({ mutation: importGroupsMutation, @@ -186,7 +186,7 @@ describe('Bulk import resolvers', () => { const FAKE_ERROR_MESSAGE = 'foo'; axiosMockAdapter .onPost(FAKE_ENDPOINTS.createBulkImport) - .reply(httpStatus.OK, [{ success: false, id: 1, message: FAKE_ERROR_MESSAGE }]); + .reply(HTTP_STATUS_OK, [{ success: false, id: 1, message: FAKE_ERROR_MESSAGE }]); await client.mutate({ mutation: importGroupsMutation, @@ -210,7 +210,7 @@ describe('Bulk import resolvers', () => { it('updateImportStatus updates status', async () => { axiosMockAdapter .onPost(FAKE_ENDPOINTS.createBulkImport) - .reply(httpStatus.OK, [{ success: true, id: 1 }]); + .reply(HTTP_STATUS_OK, [{ success: true, id: 1 }]); const NEW_STATUS = 'dummy'; await client.mutate({ 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 08c407cc4b4..1d1b285c1b6 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js @@ -3,7 +3,7 @@ import { createAlert } from '~/flash'; import { ERROR_MSG } from '~/incidents_settings/constants'; import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; jest.mock('~/flash'); @@ -26,7 +26,7 @@ describe('IncidentsSettingsService', () => { describe('updateSettings', () => { it('should refresh the page on successful update', () => { - mock.onPatch().reply(httpStatusCodes.OK); + mock.onPatch().reply(HTTP_STATUS_OK); return service.updateSettings({}).then(() => { expect(refreshCurrentPage).toHaveBeenCalled(); @@ -34,7 +34,7 @@ describe('IncidentsSettingsService', () => { }); it('should display a flash message on update error', () => { - mock.onPatch().reply(httpStatusCodes.BAD_REQUEST); + mock.onPatch().reply(HTTP_STATUS_BAD_REQUEST); return service.updateSettings({}).then(() => { expect(createAlert).toHaveBeenCalledWith({ @@ -47,7 +47,7 @@ describe('IncidentsSettingsService', () => { describe('resetWebhookUrl', () => { it('should make a call for webhook update', () => { jest.spyOn(axios, 'post'); - mock.onPost().reply(httpStatusCodes.OK); + mock.onPost().reply(HTTP_STATUS_OK); return service.resetWebhookUrl().then(() => { expect(axios.post).toHaveBeenCalledWith(webhookUpdateEndpoint); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index 4b49e492880..383dfb36aa5 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlBadge, GlForm } from '@gitlab/ui'; +import { GlAlert, GlForm } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; @@ -11,18 +11,16 @@ import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; import IntegrationForm from '~/integrations/edit/components/integration_form.vue'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; -import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue'; import IntegrationFormActions from '~/integrations/edit/components/integration_form_actions.vue'; +import IntegrationFormSection from '~/integrations/edit/components/integration_forms/section.vue'; import { I18N_SUCCESSFUL_CONNECTION_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, INTEGRATION_FORM_TYPE_SLACK, - billingPlans, - billingPlanNames, } from '~/integrations/constants'; import { createStore } from '~/integrations/edit/store'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { mockIntegrationProps, @@ -73,15 +71,11 @@ describe('IntegrationForm', () => { const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox); const findTriggerFields = () => wrapper.findComponent(TriggerFields); const findAlert = () => wrapper.findComponent(GlAlert); - const findGlBadge = () => wrapper.findComponent(GlBadge); const findGlForm = () => wrapper.findComponent(GlForm); const findRedirectToField = () => wrapper.findByTestId('redirect-to-field'); const findDynamicField = () => wrapper.findComponent(DynamicField); const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField); - const findAllSections = () => wrapper.findAllByTestId('integration-section'); - const findConnectionSection = () => findAllSections().at(0); - const findConnectionSectionComponent = () => - findConnectionSection().findComponent(IntegrationSectionConnection); + const findAllSections = () => wrapper.findAllComponents(IntegrationFormSection); const findHelpHtml = () => wrapper.findByTestId('help-html'); const findFormActions = () => wrapper.findComponent(IntegrationFormActions); @@ -215,54 +209,13 @@ describe('IntegrationForm', () => { beforeEach(() => { createComponent({ customStateProps: { - sections: [mockSectionConnection], - }, - }); - }); - - it('renders the expected number of sections', () => { - expect(findAllSections().length).toBe(1); - }); - - it('renders title, description and the correct dynamic component', () => { - const connectionSection = findConnectionSection(); - - expect(connectionSection.find('h4').text()).toBe(mockSectionConnection.title); - expect(connectionSection.find('p').text()).toBe(mockSectionConnection.description); - expect(findGlBadge().exists()).toBe(false); - expect(findConnectionSectionComponent().exists()).toBe(true); - }); - - it('renders GlBadge when `plan` is present', () => { - createComponent({ - customStateProps: { sections: [mockSectionConnection, mockSectionJiraIssues], }, }); - - expect(findGlBadge().exists()).toBe(true); - expect(findGlBadge().text()).toMatchInterpolatedText(billingPlanNames[billingPlans.PREMIUM]); }); - it('passes only fields with section type', () => { - const sectionFields = [ - { name: 'username', type: 'text', section: mockSectionConnection.type }, - { name: 'API token', type: 'password', section: mockSectionConnection.type }, - ]; - - const nonSectionFields = [ - { name: 'branch', type: 'text' }, - { name: 'labels', type: 'select' }, - ]; - - createComponent({ - customStateProps: { - sections: [mockSectionConnection], - fields: [...sectionFields, ...nonSectionFields], - }, - }); - - expect(findConnectionSectionComponent().props('fields')).toEqual(sectionFields); + it('renders the expected number of sections', () => { + expect(findAllSections()).toHaveLength(2); }); describe.each` @@ -281,7 +234,8 @@ describe('IntegrationForm', () => { }, }); - findConnectionSectionComponent().vm.$emit('toggle-integration-active', formActive); + const section = findAllSections().at(0); + section.vm.$emit('toggle-integration-active', formActive); }); it(`sets noValidate to ${novalidate}`, () => { @@ -290,7 +244,7 @@ describe('IntegrationForm', () => { }, ); - describe('when IntegrationSectionConnection emits `request-jira-issue-types` event', () => { + describe('when section emits `request-jira-issue-types` event', () => { beforeEach(() => { jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form')); @@ -302,7 +256,8 @@ describe('IntegrationForm', () => { mountFn: mountExtended, }); - findConnectionSectionComponent().vm.$emit('request-jira-issue-types'); + const section = findAllSections().at(0); + section.vm.$emit('request-jira-issue-types'); }); it('dispatches `requestJiraIssueTypes` action', () => { @@ -456,11 +411,11 @@ describe('IntegrationForm', () => { }); describe.each` - scenario | replyStatus | errorMessage | serviceResponse | expectToast | expectSentry - ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true} - ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${undefined} | ${'an error'} | ${false} - ${'when "test settings" returns an error with details'} | ${httpStatus.OK} | ${'an error.'} | ${'extra info'} | ${'an error. extra info'} | ${false} - ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false} + scenario | replyStatus | errorMessage | serviceResponse | expectToast | expectSentry + ${'when "test settings" request fails'} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} | ${undefined} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true} + ${'when "test settings" returns an error'} | ${HTTP_STATUS_OK} | ${'an error'} | ${undefined} | ${'an error'} | ${false} + ${'when "test settings" returns an error with details'} | ${HTTP_STATUS_OK} | ${'an error.'} | ${'extra info'} | ${'an error. extra info'} | ${false} + ${'when "test settings" succeeds'} | ${HTTP_STATUS_OK} | ${undefined} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false} `( '$scenario', ({ replyStatus, errorMessage, serviceResponse, expectToast, expectSentry }) => { @@ -491,7 +446,7 @@ describe('IntegrationForm', () => { const mockResetPath = '/reset'; beforeEach(async () => { - mockAxios.onPost(mockResetPath).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + mockAxios.onPost(mockResetPath).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); createComponent({ customStateProps: { resetPath: mockResetPath, @@ -526,7 +481,7 @@ describe('IntegrationForm', () => { describe('when "reset settings" succeeds', () => { beforeEach(async () => { - mockAxios.onPost(mockResetPath).replyOnce(httpStatus.OK); + mockAxios.onPost(mockResetPath).replyOnce(HTTP_STATUS_OK); createComponent({ customStateProps: { resetPath: mockResetPath, diff --git a/spec/frontend/integrations/edit/components/integration_forms/section_spec.js b/spec/frontend/integrations/edit/components/integration_forms/section_spec.js new file mode 100644 index 00000000000..5f82941778e --- /dev/null +++ b/spec/frontend/integrations/edit/components/integration_forms/section_spec.js @@ -0,0 +1,109 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { billingPlans, billingPlanNames } from '~/integrations/constants'; +import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; +import IntegrationFormSection from '~/integrations/edit/components/integration_forms/section.vue'; +import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue'; +import { createStore } from '~/integrations/edit/store'; +import { + mockIntegrationProps, + mockSectionConnection, + mockSectionJiraIssues, +} from '../../mock_data'; + +describe('Integration Form Section', () => { + let wrapper; + + const defaultProps = { + section: mockSectionConnection, + isValidated: false, + }; + + const createComponent = ({ + customStateProps = {}, + props = {}, + mountFn = shallowMountExtended, + } = {}) => { + const store = createStore({ + customState: { + ...mockIntegrationProps, + ...customStateProps, + }, + }); + + wrapper = mountFn(IntegrationFormSection, { + store, + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + IntegrationSectionConnection, + }, + }); + }; + + const findGlBadge = () => wrapper.findComponent(GlBadge); + const findFieldsComponent = () => wrapper.findComponent(IntegrationSectionConnection); + const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField); + + beforeEach(() => { + createComponent(); + }); + + it('renders title, description and the correct dynamic component', () => { + expect(wrapper.findByText(mockSectionConnection.title).exists()).toBe(true); + expect(wrapper.findByText(mockSectionConnection.description).exists()).toBe(true); + expect(findGlBadge().exists()).toBe(false); + }); + + it('renders GlBadge when `plan` is present', () => { + createComponent({ + props: { + section: mockSectionJiraIssues, + }, + }); + + expect(findGlBadge().exists()).toBe(true); + expect(findGlBadge().text()).toMatchInterpolatedText(billingPlanNames[billingPlans.PREMIUM]); + }); + + it('renders only fields for this section type', () => { + const sectionFields = [ + { name: 'username', type: 'text', section: mockSectionConnection.type }, + { name: 'API token', type: 'password', section: mockSectionConnection.type }, + ]; + + const nonSectionFields = [{ name: 'branch', type: 'text' }]; + + createComponent({ + customStateProps: { + fields: [...sectionFields, ...nonSectionFields], + }, + }); + + expect(findAllDynamicFields()).toHaveLength(2); + sectionFields.forEach((field, index) => { + expect(findAllDynamicFields().at(index).props('name')).toBe(field.name); + }); + }); + + describe('events proxy from the section', () => { + let section; + const dummyPayload = 'foo'; + + beforeEach(() => { + section = findFieldsComponent(); + }); + + it('toggle-integration-active', () => { + section.vm.$emit('toggle-integration-active', dummyPayload); + expect(wrapper.emitted('toggle-integration-active')).toEqual([[dummyPayload]]); + }); + + it('request-jira-issue-types', () => { + section.vm.$emit('request-jira-issue-types', dummyPayload); + expect(wrapper.emitted('request-jira-issue-types')).toEqual([[dummyPayload]]); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/trigger_field_spec.js b/spec/frontend/integrations/edit/components/trigger_field_spec.js index 6a68337813e..ed0b3324708 100644 --- a/spec/frontend/integrations/edit/components/trigger_field_spec.js +++ b/spec/frontend/integrations/edit/components/trigger_field_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; -import { GlFormCheckbox } from '@gitlab/ui'; +import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import TriggerField from '~/integrations/edit/components/trigger_field.vue'; import { integrationTriggerEventTitles } from '~/integrations/constants'; @@ -10,7 +10,9 @@ describe('TriggerField', () => { const defaultProps = { event: { name: 'push_events' }, + type: 'gitlab_slack_application', }; + const mockField = { name: 'push_channel' }; const createComponent = ({ props = {}, isInheriting = false } = {}) => { wrapper = shallowMount(TriggerField, { @@ -26,6 +28,7 @@ describe('TriggerField', () => { }); const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findGlFormInput = () => wrapper.findComponent(GlFormInput); const findHiddenInput = () => wrapper.find('input[type="hidden"]'); describe('template', () => { @@ -55,6 +58,32 @@ describe('TriggerField', () => { expect(findHiddenInput().attributes('value')).toBe('false'); }); + it('renders hidden GlFormInput', () => { + createComponent({ + props: { + event: { name: 'push_events', field: mockField }, + }, + }); + + expect(findGlFormInput().exists()).toBe(true); + expect(findGlFormInput().isVisible()).toBe(false); + }); + + describe('checkbox is selected', () => { + it('renders visible GlFormInput', async () => { + createComponent({ + props: { + event: { name: 'push_events', field: mockField }, + }, + }); + + await findGlFormCheckbox().vm.$emit('input', true); + + expect(findGlFormInput().exists()).toBe(true); + expect(findGlFormInput().isVisible()).toBe(true); + }); + }); + it('toggles value of hidden input on checkbox input', async () => { createComponent({ props: { event: { name: 'push_events', value: true } }, diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js index fd60d7f817f..fdb728281b5 100644 --- a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js +++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js @@ -8,7 +8,7 @@ import IntegrationOverrides from '~/integrations/overrides/components/integratio import IntegrationTabs from '~/integrations/overrides/components/integration_tabs.vue'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; @@ -39,7 +39,7 @@ describe('IntegrationOverrides', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, mockOverrides, { + mockAxios.onGet(defaultProps.overridesPath).reply(HTTP_STATUS_OK, mockOverrides, { 'X-TOTAL': mockOverrides.length, 'X-PAGE': 1, }); @@ -125,7 +125,7 @@ describe('IntegrationOverrides', () => { describe('when request fails', () => { beforeEach(async () => { jest.spyOn(Sentry, 'captureException'); - mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.INTERNAL_SERVER_ERROR); + mockAxios.onGet(defaultProps.overridesPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); createComponent(); await waitForPromises(); @@ -150,7 +150,7 @@ describe('IntegrationOverrides', () => { describe('pagination', () => { describe('when total items does not exceed the page limit', () => { it('does not render', async () => { - mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], { + mockAxios.onGet(defaultProps.overridesPath).reply(HTTP_STATUS_OK, [mockOverrides[0]], { 'X-TOTAL': DEFAULT_PER_PAGE - 1, 'X-PAGE': 1, }); @@ -169,7 +169,7 @@ describe('IntegrationOverrides', () => { beforeEach(async () => { createComponent({ stubs: { UrlSync } }); - mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], { + mockAxios.onGet(defaultProps.overridesPath).reply(HTTP_STATUS_OK, [mockOverrides[0]], { 'X-TOTAL': DEFAULT_PER_PAGE * 2, 'X-PAGE': mockPage, }); 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 22fcedb2eaf..b6b34e1063b 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -24,7 +24,11 @@ import { import eventHub from '~/invite_members/event_hub'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; import axios from '~/lib/utils/axios_utils'; -import httpStatus, { HTTP_STATUS_CREATED } from '~/lib/utils/http_status'; +import { + HTTP_STATUS_BAD_REQUEST, + HTTP_STATUS_CREATED, + HTTP_STATUS_INTERNAL_SERVER_ERROR, +} from '~/lib/utils/http_status'; import { getParameterValues } from '~/lib/utils/url_utility'; import { displaySuccessfulInvitationAlert, @@ -361,7 +365,7 @@ describe('InviteMembersModal', () => { describe('rendering the user limit notification', () => { it('shows the user limit notification alert when reached limit', () => { - const usersLimitDataset = { reachedLimit: true }; + const usersLimitDataset = { alertVariant: 'reached' }; createInviteMembersToProjectWrapper(usersLimitDataset); @@ -369,7 +373,15 @@ describe('InviteMembersModal', () => { }); it('shows the user limit notification alert when close to dashboard limit', () => { - const usersLimitDataset = { closeToDashboardLimit: true }; + const usersLimitDataset = { alertVariant: 'close' }; + + createInviteMembersToProjectWrapper(usersLimitDataset); + + expect(findUserLimitAlert().exists()).toBe(true); + }); + + it('shows the user limit notification alert when :preview_free_user_cap is enabled', () => { + const usersLimitDataset = { alertVariant: 'notification' }; createInviteMembersToProjectWrapper(usersLimitDataset); @@ -549,7 +561,7 @@ describe('InviteMembersModal', () => { it('displays the generic error for http server error', async () => { mockInvitationsApi( - httpStatus.INTERNAL_SERVER_ERROR, + HTTP_STATUS_INTERNAL_SERVER_ERROR, 'Request failed with status code 500', ); @@ -648,7 +660,7 @@ describe('InviteMembersModal', () => { }); it('displays the api error for invalid email syntax', async () => { - mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); + mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); clickInviteButton(); @@ -660,7 +672,7 @@ describe('InviteMembersModal', () => { }); it('clears the error when the modal is hidden', async () => { - mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); + mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); clickInviteButton(); @@ -715,7 +727,7 @@ describe('InviteMembersModal', () => { }); it('displays the invalid syntax error for bad request', async () => { - mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID); + mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID); clickInviteButton(); @@ -739,7 +751,7 @@ describe('InviteMembersModal', () => { createInviteMembersToGroupWrapper(); await triggerMembersTokenSelect([user3, user4]); - mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID); + mockInvitationsApi(HTTP_STATUS_BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID); clickInviteButton(); diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js index 2a780490468..490b2e8bc7c 100644 --- a/spec/frontend/invite_members/components/user_limit_notification_spec.js +++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js @@ -1,9 +1,14 @@ import { GlAlert, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue'; -import { REACHED_LIMIT_VARIANT, CLOSE_TO_LIMIT_VARIANT } from '~/invite_members/constants'; +import { + NOTIFICATION_LIMIT_VARIANT, + REACHED_LIMIT_VARIANT, + CLOSE_TO_LIMIT_VARIANT, +} from '~/invite_members/constants'; import { freeUsersLimit, remainingSeats } from '../mock_data/member_modal'; +const INFO_ALERT_TITLE = 'Your top-level group name is over the 5 user limit.'; const WARNING_ALERT_TITLE = 'You only have space for 2 more members in name'; describe('UserLimitNotification', () => { @@ -31,6 +36,17 @@ describe('UserLimitNotification', () => { }); }; + describe('when previewing free user cap', () => { + it("renders user's preview limit notification", () => { + createComponent(NOTIFICATION_LIMIT_VARIANT); + + const alert = findAlert(); + + expect(alert.attributes('title')).toEqual(INFO_ALERT_TITLE); + expect(alert.text()).toContain('GitLab will enforce this limit in the future.'); + }); + }); + describe('when close to limit within a group', () => { it("renders user's limit notification", () => { createComponent(CLOSE_TO_LIMIT_VARIANT); @@ -51,7 +67,7 @@ describe('UserLimitNotification', () => { expect(alert.attributes('title')).toEqual("You've reached your 5 members limit for name"); expect(alert.text()).toContain( - 'To invite new users to this namespace, you must remove existing users.', + 'To invite new users to this top-level group, you must remove existing users.', ); }); }); diff --git a/spec/frontend/issuable/components/issuable_by_email_spec.js b/spec/frontend/issuable/components/issuable_by_email_spec.js index 01abf239e57..b04a6c0b8fd 100644 --- a/spec/frontend/issuable/components/issuable_by_email_spec.js +++ b/spec/frontend/issuable/components/issuable_by_email_spec.js @@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; const initialEmail = 'user@gitlab.com'; @@ -130,7 +130,7 @@ describe('IssuableByEmail', () => { }); it('should update the email when the request succeeds', async () => { - mockAxios.onPut(resetPath).reply(httpStatus.OK, { new_address: 'foo@bar.com' }); + mockAxios.onPut(resetPath).reply(HTTP_STATUS_OK, { new_address: 'foo@bar.com' }); wrapper = createComponent({ issuableType: 'issue', @@ -144,7 +144,7 @@ describe('IssuableByEmail', () => { }); it('should show a toast message when the request fails', async () => { - mockAxios.onPut(resetPath).reply(httpStatus.NOT_FOUND, {}); + mockAxios.onPut(resetPath).reply(HTTP_STATUS_NOT_FOUND, {}); wrapper = createComponent({ issuableType: 'issue', diff --git a/spec/frontend/issuable/components/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js index e3a36dc8820..99aa6778e1e 100644 --- a/spec/frontend/issuable/components/issuable_header_warnings_spec.js +++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js @@ -7,7 +7,7 @@ import createIssueStore from '~/notes/stores'; import IssuableHeaderWarnings from '~/issuable/components/issuable_header_warnings.vue'; const ISSUABLE_TYPE_ISSUE = 'issue'; -const ISSUABLE_TYPE_MR = 'merge request'; +const ISSUABLE_TYPE_MR = 'merge_request'; Vue.use(Vuex); @@ -57,6 +57,7 @@ describe('IssuableHeaderWarnings', () => { beforeEach(() => { store.getters.getNoteableData.confidential = confidentialStatus; store.getters.getNoteableData.discussion_locked = lockStatus; + store.getters.getNoteableData.targetType = issuableType; createComponent({ store, provide: { hidden: hiddenStatus } }); }); @@ -84,7 +85,7 @@ describe('IssuableHeaderWarnings', () => { if (hiddenStatus) { expect(hiddenIcon.attributes('title')).toBe( - 'This issue is hidden because its author has been banned', + `This ${issuableType.replace('_', ' ')} is hidden because its author has been banned`, ); expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined(); } diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index 5e67ea42b87..28ec0e22d8b 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -35,8 +35,8 @@ describe('IssuableForm', () => { let $description; beforeEach(() => { - $title = $form.find('input[name*="[title]"]'); - $description = $form.find('textarea[name*="[description]"]'); + $title = $form.find('input[name*="[title]"]').get(0); + $description = $form.find('textarea[name*="[description]"]').get(0); }); afterEach(() => { @@ -103,7 +103,11 @@ describe('IssuableForm', () => { createIssuable($form); expect(Autosave).toHaveBeenCalledTimes(totalAutosaveFormFields); - expect(Autosave).toHaveBeenLastCalledWith($input, ['/', '', id], `autosave///=${id}`); + expect(Autosave).toHaveBeenLastCalledWith( + $input.get(0), + ['/', '', id], + `autosave///=${id}`, + ); }); }); diff --git a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js index 3f40772f7fc..841cea28ffc 100644 --- a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js +++ b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js @@ -27,6 +27,9 @@ import { scrollUp } from '~/lib/utils/scroll_utils'; import { TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableStates } from '~/vue_shared/issuable/list/constants'; @@ -42,8 +45,12 @@ describe('IssuesDashboardApp component', () => { Vue.use(VueApollo); const defaultProvide = { + autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', calendarPath: 'calendar/path', - emptyStateSvgPath: 'empty-state.svg', + dashboardLabelsPath: 'dashboard/labels/path', + dashboardMilestonesPath: 'dashboard/milestones/path', + emptyStateWithFilterSvgPath: 'empty/state/with/filter/svg/path.svg', + emptyStateWithoutFilterSvgPath: 'empty/state/with/filter/svg/path.svg', hasBlockedIssuesFeature: true, hasIssuableHealthStatusFeature: true, hasIssueWeightsFeature: true, @@ -97,74 +104,122 @@ describe('IssuesDashboardApp component', () => { axiosMock.reset(); }); - it('renders IssuableList component', async () => { - mountComponent(); - jest.runOnlyPendingTimers(); - await waitForPromises(); - - expect(findIssuableList().props()).toMatchObject({ - currentTab: IssuableStates.Opened, - hasNextPage: true, - hasPreviousPage: false, - hasScopedLabelsFeature: defaultProvide.hasScopedLabelsFeature, - initialSortBy: CREATED_DESC, - issuables: issuesQueryResponse.data.issues.nodes, - issuablesLoading: false, - namespace: 'dashboard', - recentSearchesStorageKey: 'issues', - searchInputPlaceholder: IssuesDashboardApp.i18n.searchInputPlaceholder, - showPaginationControls: true, - sortOptions: getSortOptions({ - hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature, - hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature, - hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature, - }), - tabs: IssuesDashboardApp.IssuableListTabs, - urlParams: { - sort: urlSortParams[CREATED_DESC], - state: IssuableStates.Opened, - }, - useKeysetPagination: true, + describe('UI components', () => { + beforeEach(() => { + setWindowLocation(locationSearch); + mountComponent(); + jest.runOnlyPendingTimers(); + return waitForPromises(); }); - }); - it('renders RSS button link', () => { - mountComponent(); + it('renders IssuableList component', () => { + expect(findIssuableList().props()).toMatchObject({ + currentTab: IssuableStates.Opened, + hasNextPage: true, + hasPreviousPage: false, + hasScopedLabelsFeature: defaultProvide.hasScopedLabelsFeature, + initialSortBy: CREATED_DESC, + issuables: issuesQueryResponse.data.issues.nodes, + issuablesLoading: false, + namespace: 'dashboard', + recentSearchesStorageKey: 'issues', + searchInputPlaceholder: IssuesDashboardApp.i18n.searchInputPlaceholder, + showPaginationControls: true, + sortOptions: getSortOptions({ + hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature, + }), + tabs: IssuesDashboardApp.IssuableListTabs, + urlParams: { + sort: urlSortParams[CREATED_DESC], + state: IssuableStates.Opened, + }, + useKeysetPagination: true, + }); + }); - expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath); - expect(findRssButton().props('icon')).toBe('rss'); - }); + it('renders RSS button link', () => { + expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath); + }); - it('renders calendar button link', () => { - mountComponent(); + it('renders calendar button link', () => { + expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath); + }); + + it('renders issue time information', () => { + expect(findIssueCardTimeInfo().exists()).toBe(true); + }); - expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath); - expect(findCalendarButton().props('icon')).toBe('calendar'); + it('renders issue statistics', () => { + expect(findIssueCardStatistics().exists()).toBe(true); + }); }); - it('renders issue time information', async () => { - mountComponent(); - jest.runOnlyPendingTimers(); - await waitForPromises(); + describe('fetching issues', () => { + describe('with a search query', () => { + describe('when there are issues returned', () => { + beforeEach(() => { + setWindowLocation(locationSearch); + mountComponent(); + jest.runOnlyPendingTimers(); + return waitForPromises(); + }); - expect(findIssueCardTimeInfo().exists()).toBe(true); - }); + it('renders the issues', () => { + expect(findIssuableList().props('issuables')).toEqual( + defaultQueryResponse.data.issues.nodes, + ); + }); - it('renders issue statistics', async () => { - mountComponent(); - jest.runOnlyPendingTimers(); - await waitForPromises(); + it('does not render empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + }); - expect(findIssueCardStatistics().exists()).toBe(true); - }); + describe('when there are no issues returned', () => { + beforeEach(() => { + setWindowLocation(locationSearch); + mountComponent({ + issuesQueryHandler: jest.fn().mockResolvedValue(emptyIssuesQueryResponse), + }); + return waitForPromises(); + }); + + it('renders no issues', () => { + expect(findIssuableList().props('issuables')).toEqual([]); + }); + + it('renders empty state', () => { + expect(findEmptyState().props()).toMatchObject({ + description: IssuesDashboardApp.i18n.emptyStateWithFilterDescription, + svgPath: defaultProvide.emptyStateWithFilterSvgPath, + title: IssuesDashboardApp.i18n.emptyStateWithFilterTitle, + }); + }); + }); + }); + + describe('with no search query', () => { + let issuesQueryHandler; + + beforeEach(() => { + issuesQueryHandler = jest.fn().mockResolvedValue(defaultQueryResponse); + mountComponent({ issuesQueryHandler }); + return waitForPromises(); + }); - it('renders empty state', async () => { - mountComponent({ issuesQueryHandler: jest.fn().mockResolvedValue(emptyIssuesQueryResponse) }); - await waitForPromises(); + it('does not call issues query', () => { + expect(issuesQueryHandler).not.toHaveBeenCalled(); + }); - expect(findEmptyState().props()).toMatchObject({ - svgPath: defaultProvide.emptyStateSvgPath, - title: IssuesDashboardApp.i18n.emptyStateTitle, + it('renders empty state', () => { + expect(findEmptyState().props()).toMatchObject({ + description: null, + svgPath: defaultProvide.emptyStateWithoutFilterSvgPath, + title: IssuesDashboardApp.i18n.emptyStateWithoutFilterTitle, + }); + }); }); }); @@ -233,6 +288,7 @@ describe('IssuesDashboardApp component', () => { describe('when there is an error fetching issues', () => { beforeEach(() => { + setWindowLocation(locationSearch); mountComponent({ issuesQueryHandler: jest.fn().mockRejectedValue(new Error('ERROR')) }); jest.runOnlyPendingTimers(); return waitForPromises(); @@ -281,6 +337,9 @@ describe('IssuesDashboardApp component', () => { expect(findIssuableList().props('searchTokens')).toMatchObject([ { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, { type: TOKEN_TYPE_AUTHOR, preloadedUsers }, + { type: TOKEN_TYPE_LABEL }, + { type: TOKEN_TYPE_MILESTONE }, + { type: TOKEN_TYPE_MY_REACTION }, ]); }); }); diff --git a/spec/frontend/issues/dashboard/utils_spec.js b/spec/frontend/issues/dashboard/utils_spec.js new file mode 100644 index 00000000000..08d00eee3e3 --- /dev/null +++ b/spec/frontend/issues/dashboard/utils_spec.js @@ -0,0 +1,88 @@ +import AxiosMockAdapter from 'axios-mock-adapter'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { AutocompleteCache } from '~/issues/dashboard/utils'; +import { MAX_LIST_SIZE } from '~/issues/list/constants'; +import axios from '~/lib/utils/axios_utils'; + +describe('AutocompleteCache', () => { + let autocompleteCache; + let axiosMock; + const cacheName = 'name'; + const searchProperty = 'property'; + const url = 'url'; + + const data = [ + { [searchProperty]: 'one' }, + { [searchProperty]: 'two' }, + { [searchProperty]: 'three' }, + { [searchProperty]: 'four' }, + { [searchProperty]: 'five' }, + { [searchProperty]: 'six' }, + { [searchProperty]: 'seven' }, + { [searchProperty]: 'eight' }, + { [searchProperty]: 'nine' }, + { [searchProperty]: 'ten' }, + { [searchProperty]: 'eleven' }, + { [searchProperty]: 'twelve' }, + { [searchProperty]: 'thirteen' }, + { [searchProperty]: 'fourteen' }, + { [searchProperty]: 'fifteen' }, + ]; + + beforeEach(() => { + autocompleteCache = new AutocompleteCache(); + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + describe('when there is no cached data', () => { + let response; + + beforeEach(async () => { + axiosMock.onGet(url).replyOnce(200, data); + response = await autocompleteCache.fetch({ url, cacheName, searchProperty }); + }); + + it('fetches items via the API', () => { + expect(axiosMock.history.get[0].url).toBe(url); + }); + + it('returns a maximum of 10 items', () => { + expect(response).toHaveLength(MAX_LIST_SIZE); + }); + }); + + describe('when there is cached data', () => { + let response; + + beforeEach(async () => { + axiosMock.onGet(url).replyOnce(200, data); + jest.spyOn(fuzzaldrinPlus, 'filter'); + // Populate cache + await autocompleteCache.fetch({ url, cacheName, searchProperty }); + // Execute filtering on cache data + response = await autocompleteCache.fetch({ url, cacheName, searchProperty, search: 'een' }); + }); + + it('returns filtered items based on search characters', () => { + expect(response).toEqual([ + { [searchProperty]: 'fifteen' }, + { [searchProperty]: 'thirteen' }, + { [searchProperty]: 'fourteen' }, + { [searchProperty]: 'eleven' }, + { [searchProperty]: 'seven' }, + ]); + }); + + it('filters using fuzzaldrinPlus', () => { + expect(fuzzaldrinPlus.filter).toHaveBeenCalled(); + }); + + it('does not call the API', () => { + expect(axiosMock.history.get[1]).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index 0690501dee9..70b1521ff70 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -16,6 +16,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, + TOKEN_TYPE_HEALTH, } from '~/vue_shared/components/filtered_search_bar/constants'; export const getIssuesQueryResponse = { @@ -149,6 +150,8 @@ export const locationSearch = [ 'label_name[]=tv', 'not[label_name][]=live action', 'not[label_name][]=drama', + 'or[label_name][]=comedy', + 'or[label_name][]=sitcom', 'release_tag=v3', 'release_tag=v4', 'not[release_tag]=v20', @@ -170,6 +173,8 @@ export const locationSearch = [ 'not[weight]=3', 'crm_contact_id=123', 'crm_organization_id=456', + 'health_status=atRisk', + 'not[health_status]=onTrack', ].join('&'); export const locationSearchWithSpecialValues = [ @@ -182,6 +187,7 @@ export const locationSearchWithSpecialValues = [ 'milestone_title=Upcoming', 'epic_id=None', 'weight=None', + 'health_status=None', ].join('&'); export const filteredTokens = [ @@ -204,6 +210,8 @@ export const filteredTokens = [ { type: TOKEN_TYPE_LABEL, value: { data: 'tv', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_LABEL, value: { data: 'live action', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_LABEL, value: { data: 'drama', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'comedy', operator: OPERATOR_OR } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'sitcom', operator: OPERATOR_OR } }, { type: TOKEN_TYPE_RELEASE, value: { data: 'v3', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_RELEASE, value: { data: 'v4', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_RELEASE, value: { data: 'v20', operator: OPERATOR_NOT } }, @@ -225,6 +233,8 @@ export const filteredTokens = [ { type: TOKEN_TYPE_WEIGHT, value: { data: '3', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_CONTACT, value: { data: '123', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ORGANIZATION, value: { data: '456', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_HEALTH, value: { data: 'atRisk', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: OPERATOR_NOT } }, { type: FILTERED_SEARCH_TERM, value: { data: 'find' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'issues' } }, ]; @@ -239,6 +249,7 @@ export const filteredTokensWithSpecialValues = [ { type: TOKEN_TYPE_MILESTONE, value: { data: 'Upcoming', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_EPIC, value: { data: 'None', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_WEIGHT, value: { data: 'None', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_HEALTH, value: { data: 'None', operator: OPERATOR_IS } }, ]; export const apiParams = { @@ -255,6 +266,7 @@ export const apiParams = { weight: '1', crmContactId: '123', crmOrganizationId: '456', + healthStatusFilter: 'atRisk', not: { authorUsername: 'marge', assigneeUsernames: ['patty', 'selma'], @@ -266,10 +278,12 @@ export const apiParams = { iterationId: ['20', '42'], epicId: '34', weight: '3', + healthStatusFilter: 'onTrack', }, or: { authorUsernames: ['burns', 'smithers'], assigneeUsernames: ['carl', 'lenny'], + labelNames: ['comedy', 'sitcom'], }, }; @@ -283,6 +297,7 @@ export const apiParamsWithSpecialValues = { milestoneWildcardId: 'UPCOMING', epicId: 'None', weight: 'None', + healthStatusFilter: 'NONE', }; export const urlParams = { @@ -296,6 +311,7 @@ export const urlParams = { 'not[milestone_title]': ['season 20', 'season 30'], 'label_name[]': ['cartoon', 'tv'], 'not[label_name][]': ['live action', 'drama'], + 'or[label_name][]': ['comedy', 'sitcom'], release_tag: ['v3', 'v4'], 'not[release_tag]': ['v20', 'v30'], 'type[]': ['issue', 'feature'], @@ -311,6 +327,8 @@ export const urlParams = { 'not[weight]': '3', crm_contact_id: '123', crm_organization_id: '456', + health_status: 'atRisk', + 'not[health_status]': 'onTrack', }; export const urlParamsWithSpecialValues = { @@ -323,6 +341,7 @@ export const urlParamsWithSpecialValues = { milestone_title: 'Upcoming', epic_id: 'None', weight: 'None', + health_status: 'None', }; export const project1 = { diff --git a/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js index d30a8c081cc..8413b8463c1 100644 --- a/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js +++ b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import mockData from 'test_fixtures/issues/related_merge_requests.json'; import axios from '~/lib/utils/axios_utils'; @@ -20,7 +20,7 @@ describe('RelatedMergeRequests', () => { mock = new MockAdapter(axios); mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 }); - wrapper = mount(RelatedMergeRequests, { + wrapper = shallowMount(RelatedMergeRequests, { store: createStore(), propsData: { endpoint: API_ENDPOINT, @@ -49,7 +49,7 @@ describe('RelatedMergeRequests', () => { }); }); - it('should return an array with single assingee', () => { + it('should return an array with single assignee', () => { const mr = { assignee: assignees[0] }; expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]); diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index 7d6ca44e679..aaf228ae181 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -6,6 +6,7 @@ import { mockTracking } from 'helpers/tracking_helper'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { IssuableStatus, IssueType } from '~/issues/constants'; import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import HeaderActions from '~/issues/show/components/header_actions.vue'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql'; @@ -38,8 +39,9 @@ describe('HeaderActions component', () => { issueType: IssueType.Issue, newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', projectPath: 'gitlab-org/gitlab-test', - reportAbusePath: - '-/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%2Fgitlab-org%2Fgitlab-test%2F-%2Fissues%2F32&user_id=1', + reportAbusePath: '-/abuse_reports/add_category', + reportedUserId: '1', + reportedFromUrl: 'http://localhost:/gitlab-org/-/issues/32', submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam', }; @@ -401,4 +403,31 @@ describe('HeaderActions component', () => { }); }); }); + + describe('abuse category selector', () => { + const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); + + beforeEach(() => { + wrapper = mountComponent({ props: { isIssueAuthor: false } }); + }); + + it('renders', () => { + expect(findAbuseCategorySelector().exists()).toBe(true); + expect(findAbuseCategorySelector().props('showDrawer')).toEqual(false); + }); + + it('opens the drawer', async () => { + findDesktopDropdownItems().at(2).vm.$emit('click'); + + await nextTick(); + + expect(findAbuseCategorySelector().props('showDrawer')).toEqual(true); + }); + + it('closes the drawer', async () => { + await findAbuseCategorySelector().vm.$emit('close-drawer'); + + expect(findAbuseCategorySelector().props('showDrawer')).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 1286617d64a..6c923cae0cc 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 @@ -1,6 +1,6 @@ import VueApollo from 'vue-apollo'; import Vue from 'vue'; -import { GlDatepicker } from '@gitlab/ui'; +import { GlDatepicker, GlListboxItem } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CreateTimelineEvent from '~/issues/show/components/incidents/create_timeline_event.vue'; @@ -27,6 +27,7 @@ const mockInputData = { incidentId: 'gid://gitlab/Issue/1', note: 'test', occurredAt: '2020-07-08T00:00:00.000Z', + timelineEventTagNames: ['Start time'], }; describe('Create Timeline events', () => { @@ -51,9 +52,14 @@ describe('Create Timeline events', () => { findHourInput().setValue(inputDate.getHours()); findMinuteInput().setValue(inputDate.getMinutes()); }; + const findListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const setEventTags = () => { + findListboxItems().at(0).vm.$emit('select', true); + }; const fillForm = () => { setDatetime(); setNoteInput(); + setEventTags(); }; function createMockApolloProvider() { @@ -80,6 +86,7 @@ describe('Create Timeline events', () => { provide: { fullPath: 'group/project', issuableId: '1', + glFeatures: { incidentEventTags: true }, }, apolloProvider, }); diff --git a/spec/frontend/issues/show/components/incidents/mock_data.js b/spec/frontend/issues/show/components/incidents/mock_data.js index 9accfcea791..6606bed1567 100644 --- a/spec/frontend/issues/show/components/incidents/mock_data.js +++ b/spec/frontend/issues/show/components/incidents/mock_data.js @@ -74,6 +74,7 @@ const mockUpdatedEvent = { action: 'comment', occurredAt: '2022-07-01T12:47:00Z', createdAt: '2022-07-20T12:47:40Z', + timelineEventTags: [], }; export const timelineEventsQueryListResponse = { 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 d5b199cc790..f06d968a4c5 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 @@ -1,11 +1,15 @@ import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; -import { GlDatepicker } from '@gitlab/ui'; -import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import { GlDatepicker, GlListbox } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import TimelineEventsForm from '~/issues/show/components/incidents/timeline_events_form.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { timelineFormI18n } from '~/issues/show/components/incidents/constants'; +import { + timelineFormI18n, + TIMELINE_EVENT_TAGS, + timelineEventTagsI18n, +} from '~/issues/show/components/incidents/constants'; import { createAlert } from '~/flash'; import { useFakeDate } from 'helpers/fake_date'; @@ -17,17 +21,23 @@ const fakeDate = '2020-07-08T00:00:00.000Z'; const mockInputDate = new Date('2021-08-12'); +const mockTags = TIMELINE_EVENT_TAGS; + describe('Timeline events form', () => { // July 8 2020 useFakeDate(fakeDate); let wrapper; - const mountComponent = ({ mountMethod = shallowMountExtended } = {}, props = {}) => { + const mountComponent = ({ mountMethod = mountExtended } = {}, props = {}, glFeatures = {}) => { wrapper = mountMethod(TimelineEventsForm, { + provide: { + glFeatures, + }, propsData: { showSaveAndAdd: true, isEventProcessed: false, ...props, + tags: mockTags, }, stubs: { GlButton: true, @@ -35,6 +45,10 @@ describe('Timeline events form', () => { }); }; + beforeEach(() => { + mountComponent(); + }); + afterEach(() => { createAlert.mockReset(); wrapper.destroy(); @@ -48,16 +62,26 @@ describe('Timeline events form', () => { const findDatePicker = () => wrapper.findComponent(GlDatepicker); const findHourInput = () => wrapper.findByTestId('input-hours'); const findMinuteInput = () => wrapper.findByTestId('input-minutes'); - const setDatetime = () => { - findDatePicker().vm.$emit('input', mockInputDate); - findHourInput().setValue(5); - findMinuteInput().setValue(45); - }; + const findTagDropdown = () => wrapper.findComponent(GlListbox); const findTextarea = () => wrapper.findByTestId('input-note'); + const findTextareaValue = () => findTextarea().element.value; const findCountNumeric = (count) => wrapper.findByText(count); const findCountVerbose = (count) => wrapper.findByText(`${count} characters remaining`); const findCountHint = () => wrapper.findByText(timelineFormI18n.hint); + const setDatetime = () => { + findDatePicker().vm.$emit('input', mockInputDate); + findHourInput().setValue(5); + findMinuteInput().setValue(45); + }; + const selectTags = async (tags) => { + findTagDropdown().vm.$emit( + 'select', + tags.map((x) => x.value), + ); + await nextTick(); + }; + const selectOneTag = () => selectTags([mockTags[0]]); const submitForm = async () => { findSubmitButton().vm.$emit('click'); await waitForPromises(); @@ -90,23 +114,97 @@ describe('Timeline events form', () => { ]); }); - describe('form button behaviour', () => { + describe('with incident_event_tag feature flag enabled', () => { beforeEach(() => { - mountComponent({ mountMethod: mountExtended }); + mountComponent( + {}, + {}, + { + incidentEventTags: true, + }, + ); + }); + + describe('event tag dropdown', () => { + it('should render option list from provided array', () => { + expect(findTagDropdown().props('items')).toEqual(mockTags); + }); + + it('should allow to choose multiple tags', async () => { + await selectTags(mockTags); + + expect(findTagDropdown().props('selected')).toEqual(mockTags.map((x) => x.value)); + }); + + it('should show default option, when none is chosen', () => { + expect(findTagDropdown().props('toggleText')).toBe(timelineFormI18n.selectTags); + }); + + it('should show the tag, when one is selected', async () => { + await selectOneTag(); + + expect(findTagDropdown().props('toggleText')).toBe(timelineEventTagsI18n.startTime); + }); + + it('should show the number of selected tags, when more than one is selected', async () => { + await selectTags(mockTags); + + expect(findTagDropdown().props('toggleText')).toBe('2 tags'); + }); + + it('should be cleared when clear is triggered', async () => { + await selectTags(mockTags); + + // This component expects the parent to call `clear`, so this is the only way to trigger this + wrapper.vm.clear(); + await nextTick(); + + expect(findTagDropdown().props('toggleText')).toBe(timelineFormI18n.selectTags); + expect(findTagDropdown().props('selected')).toEqual([]); + }); + + it('should populate incident note with tags if a note was empty', async () => { + await selectTags(mockTags); + + expect(findTextareaValue()).toBe( + `${timelineFormI18n.areaDefaultMessage} ${mockTags + .map((x) => x.value.toLowerCase()) + .join(', ')}`, + ); + }); + + it('should populate incident note with tag but allow to customise it', async () => { + await selectOneTag(); + + await findTextarea().setValue('my customised event note'); + + await nextTick(); + + expect(findTextareaValue()).toBe('my customised event note'); + }); + + it('should not populate incident note with tag if it had a note', async () => { + await findTextarea().setValue('hello'); + await selectOneTag(); + + expect(findTextareaValue()).toBe('hello'); + }); }); + }); + describe('form button behaviour', () => { it('should save event on submit', async () => { await submitForm(); expect(wrapper.emitted()).toEqual({ - 'save-event': [[{ note: '', occurredAt: fakeDate }, false]], + 'save-event': [[{ note: '', occurredAt: fakeDate, timelineEventTags: [] }, false]], }); }); it('should save event on "submit and add another"', async () => { await submitFormAndAddAnother(); expect(wrapper.emitted()).toEqual({ - 'save-event': [[{ note: '', occurredAt: fakeDate }, true]], + 'save-event': [[{ note: '', occurredAt: fakeDate, timelineEventTags: [] }, true]], }); }); @@ -145,10 +243,6 @@ describe('Timeline events form', () => { }); describe('form character limit', () => { - beforeEach(() => { - mountComponent({ mountMethod: mountExtended }); - }); - it('sets a character limit hint', () => { expect(findCountHint().exists()).toBe(true); }); @@ -172,32 +266,32 @@ describe('Timeline events form', () => { }); describe('Delete button', () => { - it('does not show the delete button if showDelete prop is false', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: false }); + it('does not show the delete button if isEditing prop is false', () => { + mountComponent({ mountMethod: mountExtended }, { isEditing: false }); expect(findDeleteButton().exists()).toBe(false); }); - it('shows the delete button if showDelete prop is true', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: true }); + it('shows the delete button if isEditing prop is true', () => { + mountComponent({ mountMethod: mountExtended }, { isEditing: true }); expect(findDeleteButton().exists()).toBe(true); }); it('disables the delete button if isEventProcessed prop is true', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: true }); + mountComponent({ mountMethod: mountExtended }, { isEditing: true, isEventProcessed: true }); expect(findDeleteButton().props('disabled')).toBe(true); }); it('does not disable the delete button if isEventProcessed prop is false', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: false }); + mountComponent({ mountMethod: mountExtended }, { isEditing: true, isEventProcessed: false }); expect(findDeleteButton().props('disabled')).toBe(false); }); it('emits delete event on click', () => { - mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: true }); + mountComponent({ mountMethod: mountExtended }, { isEditing: true, isEventProcessed: true }); deleteForm(); diff --git a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js index b0218a9df12..944854faab3 100644 --- a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js +++ b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js @@ -1,10 +1,4 @@ -import { - GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, -} from '@gitlab/ui'; +import { GlAvatarLabeled, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -36,12 +30,8 @@ const mockQueryLoading = jest.fn().mockReturnValue(new Promise(() => {})); describe('ProjectDropdown', () => { let wrapper; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findDropdownItemByProjectId = (projectId) => - wrapper.find(`[data-testid="test-project-${projectId}"]`); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findAllGlListboxItems = () => wrapper.findAllComponents(GlListboxItem); function createMockApolloProvider({ mockGetProjectsQuery = mockGetProjectsQuerySuccess } = {}) { Vue.use(VueApollo); @@ -55,6 +45,7 @@ describe('ProjectDropdown', () => { wrapper = mountFn(ProjectDropdown, { apolloProvider: mockApollo || createMockApolloProvider(), propsData: props, + stubs: { GlCollapsibleListbox }, }); } @@ -72,16 +63,11 @@ describe('ProjectDropdown', () => { it('sets dropdown `loading` prop to `true`', () => { expect(findDropdown().props('loading')).toBe(true); }); - - it('renders loading icon in dropdown', () => { - expect(findLoadingIcon().isVisible()).toBe(true); - }); }); describe('when projects query succeeds', () => { beforeEach(async () => { createComponent(); - await waitForPromises(); await nextTick(); }); @@ -90,12 +76,19 @@ describe('ProjectDropdown', () => { }); it('renders dropdown items with correct props', () => { - const dropdownItems = findAllDropdownItems(); - const avatars = dropdownItems.wrappers.map((item) => item.findComponent(GlAvatarLabeled)); + const dropdownItems = findDropdown().props('items'); + expect(dropdownItems).toHaveLength(mockProjects.length); + expect(dropdownItems).toMatchObject(mockProjects); + }); + + it('renders dropdown items with correct template', () => { + expect(findAllGlListboxItems()).toHaveLength(mockProjects.length); + const avatars = findAllGlListboxItems().wrappers.map((item) => + item.findComponent(GlAvatarLabeled), + ); const avatarAttributes = avatars.map((avatar) => avatar.attributes()); const avatarProps = avatars.map((avatar) => avatar.props()); - expect(dropdownItems.wrappers).toHaveLength(mockProjects.length); expect(avatarProps).toMatchObject( mockProjects.map((project) => ({ label: project.name, @@ -113,8 +106,7 @@ describe('ProjectDropdown', () => { describe('when selecting a dropdown item', () => { it('emits `change` event with the selected project', async () => { const mockProject = mockProjects[0]; - const itemToSelect = findDropdownItemByProjectId(mockProject.id); - await itemToSelect.vm.$emit('click'); + await findDropdown().vm.$emit('select', mockProject.id); expect(wrapper.emitted('change')[0]).toEqual([mockProject]); }); @@ -124,17 +116,11 @@ describe('ProjectDropdown', () => { const mockProject = mockProjects[0]; beforeEach(() => { - wrapper.setProps({ - selectedProject: mockProject, - }); - }); - - it('sets `isChecked` prop of the corresponding dropdown item to `true`', () => { - expect(findDropdownItemByProjectId(mockProject.id).props('isChecked')).toBe(true); + createComponent({ props: { selectedProject: mockProject } }); }); - it('sets dropdown text to `selectedBranchName` value', () => { - expect(findDropdown().props('text')).toBe(mockProject.nameWithNamespace); + it('selects the specified item', () => { + expect(findDropdown().props('selected')).toBe(mockProject.id); }); }); }); @@ -155,11 +141,10 @@ describe('ProjectDropdown', () => { describe('when searching branches', () => { it('triggers a refetch', async () => { createComponent({ mountFn: mount }); - await waitForPromises(); jest.clearAllMocks(); const mockSearchTerm = 'gitl'; - await findSearchBox().vm.$emit('input', mockSearchTerm); + await findDropdown().vm.$emit('search', mockSearchTerm); expect(mockGetProjectsQuerySuccess).toHaveBeenCalledWith({ after: '', diff --git a/spec/frontend/jira_connect/subscriptions/api_spec.js b/spec/frontend/jira_connect/subscriptions/api_spec.js index cf496d5836a..21636017f10 100644 --- a/spec/frontend/jira_connect/subscriptions/api_spec.js +++ b/spec/frontend/jira_connect/subscriptions/api_spec.js @@ -9,7 +9,7 @@ import { updateInstallation, } from '~/jira_connect/subscriptions/api'; import { getJwt } from '~/jira_connect/subscriptions/utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; jest.mock('~/jira_connect/subscriptions/utils', () => ({ getJwt: jest.fn().mockResolvedValue('jwt'), @@ -49,7 +49,7 @@ describe('JiraConnect API', () => { jwt: mockJwt, namespace_path: mockNamespace, }) - .replyOnce(httpStatus.OK, mockResponse); + .replyOnce(HTTP_STATUS_OK, mockResponse); response = await makeRequest(); @@ -67,7 +67,7 @@ describe('JiraConnect API', () => { it('returns success response', async () => { jest.spyOn(axiosInstance, 'delete'); - axiosMock.onDelete(mockRemovePath).replyOnce(httpStatus.OK, mockResponse); + axiosMock.onDelete(mockRemovePath).replyOnce(HTTP_STATUS_OK, mockResponse); response = await makeRequest(); @@ -99,7 +99,7 @@ describe('JiraConnect API', () => { page: mockPage, per_page: mockPerPage, }) - .replyOnce(httpStatus.OK, mockResponse); + .replyOnce(HTTP_STATUS_OK, mockResponse); response = await makeRequest(); @@ -121,7 +121,7 @@ describe('JiraConnect API', () => { jest.spyOn(axiosInstance, 'get'); - axiosMock.onGet(expectedUrl).replyOnce(httpStatus.OK, mockResponse); + axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, mockResponse); response = await makeRequest(); @@ -139,7 +139,7 @@ describe('JiraConnect API', () => { jest.spyOn(axiosInstance, 'post'); - axiosMock.onPost(expectedUrl).replyOnce(httpStatus.OK, mockResponse); + axiosMock.onPost(expectedUrl).replyOnce(HTTP_STATUS_OK, mockResponse); response = await makeRequest(); @@ -175,7 +175,7 @@ describe('JiraConnect API', () => { instance_url: expectedInstanceUrl, }, }) - .replyOnce(httpStatus.OK, mockResponse); + .replyOnce(HTTP_STATUS_OK, mockResponse); response = await makeRequest(); diff --git a/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js b/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js deleted file mode 100644 index 5f38a0acb9d..00000000000 --- a/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js +++ /dev/null @@ -1,56 +0,0 @@ -import { GlAlert, GlLink } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; -import CompatibilityAlert from '~/jira_connect/subscriptions/components/compatibility_alert.vue'; - -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; - -describe('CompatibilityAlert', () => { - let wrapper; - - const createComponent = ({ mountFn = shallowMount } = {}) => { - wrapper = mountFn(CompatibilityAlert); - }; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findLink = () => wrapper.findComponent(GlLink); - const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); - - afterEach(() => { - wrapper.destroy(); - }); - - it('displays an alert', () => { - createComponent(); - - expect(findAlert().exists()).toBe(true); - }); - - it('renders help link with target="_blank" and rel="noopener noreferrer"', () => { - createComponent({ mountFn: mount }); - expect(findLink().attributes()).toMatchObject({ - target: '_blank', - rel: 'noopener', - }); - }); - - it('`local-storage-sync` value prop is initially false', () => { - createComponent(); - - expect(findLocalStorageSync().props('value')).toBe(false); - }); - - describe('when dismissed', () => { - beforeEach(async () => { - createComponent(); - await findAlert().vm.$emit('dismiss'); - }); - - it('hides alert', () => { - expect(findAlert().exists()).toBe(false); - }); - - it('updates value prop of `local-storage-sync`', () => { - expect(findLocalStorageSync().props('value')).toBe(true); - }); - }); -}); diff --git a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js index 01317eb5dba..e20c4b62e77 100644 --- a/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js +++ b/spec/frontend/jira_connect/subscriptions/components/sign_in_oauth_button_spec.js @@ -4,6 +4,7 @@ import { nextTick } from 'vue'; import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue'; import { + GITLAB_COM_BASE_PATH, I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, OAUTH_WINDOW_OPTIONS, } from '~/jira_connect/subscriptions/constants'; @@ -36,6 +37,9 @@ describe('SignInOauthButton', () => { }, state: 'good-state', }; + const defaultProps = { + gitlabBasePath: GITLAB_COM_BASE_PATH, + }; const createComponent = ({ slots, props } = {}) => { store = createStore(); @@ -48,7 +52,7 @@ describe('SignInOauthButton', () => { provide: { oauthMetadata: mockOauthMetadata, }, - propsData: props, + propsData: { ...defaultProps, ...props }, }); }; @@ -57,16 +61,17 @@ describe('SignInOauthButton', () => { }); const findButton = () => wrapper.findComponent(GlButton); + describe('when `gitlabBasePath` is GitLab.com', () => { + it('displays a button', () => { + createComponent(); - it('displays a button', () => { - createComponent(); - - expect(findButton().exists()).toBe(true); - expect(findButton().text()).toBe(I18N_DEFAULT_SIGN_IN_BUTTON_TEXT); - expect(findButton().props('category')).toBe('primary'); + expect(findButton().exists()).toBe(true); + expect(findButton().text()).toBe(I18N_DEFAULT_SIGN_IN_BUTTON_TEXT); + expect(findButton().props('category')).toBe('primary'); + }); }); - describe('when `gitlabBasePath` is passed', () => { + describe('when `gitlabBasePath` is self-managed', () => { const mockBasePath = 'https://gitlab.mycompany.com'; it('uses custom text for button', () => { diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index 748e151f31b..40e627262db 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -150,18 +150,12 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <input aria-label="Search" - class="gl-form-input gl-search-box-by-type-input form-control" + class="gl-form-input form-control gl-search-box-by-type-input" placeholder="Search" type="search" /> - <div - class="gl-search-box-by-type-right-icons" - > - <!----> - - <!----> - </div> + <!----> </div> <li @@ -281,18 +275,12 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <input aria-label="Search" - class="gl-form-input gl-search-box-by-type-input form-control" + class="gl-form-input form-control gl-search-box-by-type-input" placeholder="Search" type="search" /> - <div - class="gl-search-box-by-type-right-icons" - > - <!----> - - <!----> - </div> + <!----> </div> <li 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 45a1e9dca76..3040570df19 100644 --- a/spec/frontend/jobs/components/job/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/job/manual_variables_form_spec.js @@ -212,9 +212,30 @@ describe('Manual Variables Form', () => { expect(findDeleteVarBtn().exists()).toBe(true); }); + }); + + describe('variable delete button placeholder', () => { + beforeEach(async () => { + getJobQueryResponse.mockResolvedValue(mockJobResponse); + await createComponentWithApollo(); + }); it('delete variable button placeholder should only exist when a user cannot remove', async () => { expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); }); + + it('does not show the placeholder button', () => { + expect(findDeleteVarBtnPlaceholder().classes('gl-opacity-0')).toBe(true); + }); + + it('placeholder button will not delete the row on click', async () => { + expect(findAllCiVariableKeys()).toHaveLength(1); + expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); + + await findDeleteVarBtnPlaceholder().trigger('click'); + + expect(findAllCiVariableKeys()).toHaveLength(1); + expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/jobs/components/job/sidebar_spec.js b/spec/frontend/jobs/components/job/sidebar_spec.js index 27911eb76eb..aa9ca932023 100644 --- a/spec/frontend/jobs/components/job/sidebar_spec.js +++ b/spec/frontend/jobs/components/job/sidebar_spec.js @@ -3,7 +3,7 @@ import { nextTick } from 'vue'; import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import ArtifactsBlock from '~/jobs/components/job/sidebar/artifacts_block.vue'; import JobRetryForwardDeploymentModal from '~/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue'; import JobsContainer from '~/jobs/components/job/sidebar/jobs_container.vue'; @@ -43,7 +43,7 @@ describe('Sidebar details block', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet().reply(httpStatus.OK, { + mock.onGet().reply(HTTP_STATUS_OK, { name: job.stage, }); }); diff --git a/spec/frontend/jobs/components/table/jobs_table_spec.js b/spec/frontend/jobs/components/table/jobs_table_spec.js index 803df3df37f..3c4f2d624fe 100644 --- a/spec/frontend/jobs/components/table/jobs_table_spec.js +++ b/spec/frontend/jobs/components/table/jobs_table_spec.js @@ -2,14 +2,14 @@ import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JobsTable from '~/jobs/components/table/jobs_table.vue'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { mockJobsNodes } from '../../mock_data'; describe('Jobs Table', () => { let wrapper; const findTable = () => wrapper.findComponent(GlTable); - const findStatusBadge = () => wrapper.findComponent(CiBadge); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findTableRows = () => wrapper.findAllByTestId('jobs-table-row'); const findJobStage = () => wrapper.findByTestId('job-stage-name'); const findJobName = () => wrapper.findByTestId('job-name'); @@ -43,7 +43,7 @@ describe('Jobs Table', () => { }); it('displays job status', () => { - expect(findStatusBadge().exists()).toBe(true); + expect(findCiBadgeLink().exists()).toBe(true); }); it('displays the job stage and name', () => { diff --git a/spec/frontend/language_switcher/components/app_spec.js b/spec/frontend/language_switcher/components/app_spec.js index 6a1b94cd813..effb71c2775 100644 --- a/spec/frontend/language_switcher/components/app_spec.js +++ b/spec/frontend/language_switcher/components/app_spec.js @@ -1,3 +1,4 @@ +import { GlLink } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import LanguageSwitcherApp from '~/language_switcher/components/app.vue'; import { PREFERRED_LANGUAGE_COOKIE_KEY } from '~/language_switcher/constants'; @@ -29,6 +30,7 @@ describe('<LanguageSwitcher />', () => { const getPreferredLanguage = () => wrapper.find('.gl-dropdown-button-text').text(); const findLanguageDropdownItem = (code) => wrapper.findByTestId(`language_switcher_lang_${code}`); + const findFooter = () => wrapper.findByTestId('footer'); it('preferred language', () => { expect(getPreferredLanguage()).toBe(EN.text); @@ -59,4 +61,12 @@ describe('<LanguageSwitcher />', () => { expect(utils.setCookie).toHaveBeenCalledWith(PREFERRED_LANGUAGE_COOKIE_KEY, ES.value); window.location = originalLocation; }); + + it('renders footer link', () => { + const link = findFooter().findComponent(GlLink); + + // Assert against actual value so we can implicitly test `helpPagePath` call + expect(link.attributes('href')).toBe('/help/development/i18n/translation.md'); + expect(link.text()).toBe(LanguageSwitcherApp.HELP_TRANSLATE_MSG); + }); }); diff --git a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js index 055d57d6ada..8d6ace165ab 100644 --- a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js @@ -3,7 +3,9 @@ import { newDateAsLocaleTime, nSecondsAfter, nSecondsBefore, + isToday, } from '~/lib/utils/datetime/date_calculation_utility'; +import { useFakeDate } from 'helpers/fake_date'; describe('newDateAsLocaleTime', () => { it.each` @@ -66,3 +68,19 @@ describe('nSecondsBefore', () => { expect(nSecondsBefore(date, seconds)).toEqual(expected); }); }); + +describe('isToday', () => { + useFakeDate(2022, 11, 5); + + describe('when date is today', () => { + it('returns `true`', () => { + expect(isToday(new Date(2022, 11, 5))).toBe(true); + }); + }); + + describe('when date is not today', () => { + it('returns `false`', () => { + expect(isToday(new Date(2022, 11, 6))).toBe(false); + }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index 2e0bb6a8dcd..a83b0ed9fbe 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -149,17 +149,17 @@ describe('durationTimeFormatted', () => { describe('formatUtcOffset', () => { it.each` offset | expected - ${-32400} | ${'- 9'} - ${'-12600'} | ${'- 3.5'} - ${0} | ${'0'} - ${'10800'} | ${'+ 3'} - ${19800} | ${'+ 5.5'} - ${0} | ${'0'} - ${[]} | ${'0'} - ${{}} | ${'0'} - ${true} | ${'0'} - ${null} | ${'0'} - ${undefined} | ${'0'} + ${-32400} | ${'-9'} + ${'-12600'} | ${'-3.5'} + ${0} | ${' 0'} + ${'10800'} | ${'+3'} + ${19800} | ${'+5.5'} + ${0} | ${' 0'} + ${[]} | ${' 0'} + ${{}} | ${' 0'} + ${true} | ${' 0'} + ${null} | ${' 0'} + ${undefined} | ${' 0'} `('returns $expected given $offset', ({ offset, expected }) => { expect(utils.formatUtcOffset(offset)).toEqual(expected); }); diff --git a/spec/frontend/lib/utils/poll_until_complete_spec.js b/spec/frontend/lib/utils/poll_until_complete_spec.js index 3ce17ecfc8c..309e0cc540b 100644 --- a/spec/frontend/lib/utils/poll_until_complete_spec.js +++ b/spec/frontend/lib/utils/poll_until_complete_spec.js @@ -1,7 +1,11 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes, { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; +import { + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_NOT_FOUND, + HTTP_STATUS_OK, +} from '~/lib/utils/http_status'; import pollUntilComplete from '~/lib/utils/poll_until_complete'; const endpoint = `${TEST_HOST}/foo`; @@ -24,7 +28,7 @@ describe('pollUntilComplete', () => { describe('given an immediate success response', () => { beforeEach(() => { - mock.onGet(endpoint).replyOnce(httpStatusCodes.OK, mockData); + mock.onGet(endpoint).replyOnce(HTTP_STATUS_OK, mockData); }); it('resolves with the response', () => @@ -39,7 +43,7 @@ describe('pollUntilComplete', () => { .onGet(endpoint) .replyOnce(HTTP_STATUS_NO_CONTENT, undefined, pollIntervalHeader) .onGet(endpoint) - .replyOnce(httpStatusCodes.OK, mockData); + .replyOnce(HTTP_STATUS_OK, mockData); }); it('calls the endpoint until it succeeds, and resolves with the response', () => @@ -66,7 +70,7 @@ describe('pollUntilComplete', () => { const errorMessage = 'error message'; beforeEach(() => { - mock.onGet(endpoint).replyOnce(httpStatusCodes.NOT_FOUND, errorMessage); + mock.onGet(endpoint).replyOnce(HTTP_STATUS_NOT_FOUND, errorMessage); }); it('rejects with the error response', () => @@ -78,7 +82,7 @@ describe('pollUntilComplete', () => { describe('given params', () => { const params = { foo: 'bar' }; beforeEach(() => { - mock.onGet(endpoint, { params }).replyOnce(httpStatusCodes.OK, mockData); + mock.onGet(endpoint, { params }).replyOnce(HTTP_STATUS_OK, mockData); }); it('requests the expected URL', () => diff --git a/spec/frontend/locale/ensure_single_line_spec.js b/spec/frontend/locale/ensure_single_line_spec.js index 20b04cab9c8..ca3d57015af 100644 --- a/spec/frontend/locale/ensure_single_line_spec.js +++ b/spec/frontend/locale/ensure_single_line_spec.js @@ -1,4 +1,4 @@ -import ensureSingleLine from '~/locale/ensure_single_line'; +import ensureSingleLine from '~/locale/ensure_single_line.cjs'; describe('locale', () => { describe('ensureSingleLine', () => { diff --git a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js index df5c884f42e..b94964dc482 100644 --- a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js @@ -38,7 +38,6 @@ describe('AccessRequestActionButtons', () => { title: 'Deny access', isAccessRequest: true, isInvite: false, - icon: 'close', }); }); diff --git a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js index ea819b4fb83..68009708c99 100644 --- a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js @@ -39,12 +39,10 @@ describe('InviteActionButtons', () => { it('sets props correctly', () => { expect(findRemoveMemberButton().props()).toMatchObject({ memberId: member.id, - memberType: null, message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.fullName}"`, title: 'Revoke invite', isAccessRequest: false, isInvite: true, - icon: 'remove', }); }); }); diff --git a/spec/frontend/members/components/action_buttons/leave_button_spec.js b/spec/frontend/members/components/action_buttons/leave_button_spec.js deleted file mode 100644 index ecfbf4460a6..00000000000 --- a/spec/frontend/members/components/action_buttons/leave_button_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; -import LeaveModal from '~/members/components/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '~/members/constants'; -import { member } from '../../mock_data'; - -describe('LeaveButton', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(LeaveButton, { - propsData: { - member, - ...propsData, - }, - directives: { - GlTooltip: createMockDirective(), - GlModal: createMockDirective(), - }, - }); - }; - - const findButton = () => wrapper.findComponent(GlButton); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('displays a tooltip', () => { - const button = findButton(); - - expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); - expect(button.attributes('title')).toBe('Leave'); - }); - - it('sets `aria-label` attribute', () => { - expect(findButton().attributes('aria-label')).toBe('Leave'); - }); - - it('renders leave modal', () => { - const leaveModal = wrapper.findComponent(LeaveModal); - - expect(leaveModal.exists()).toBe(true); - expect(leaveModal.props('member')).toEqual(member); - }); - - it('triggers leave modal', () => { - const binding = getBinding(findButton().element, 'gl-modal'); - - expect(binding).not.toBeUndefined(); - expect(binding.value).toBe(LEAVE_MODAL_ID); - }); -}); diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js index 0e5b667eb9b..cca340169b7 100644 --- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -39,7 +39,6 @@ describe('RemoveMemberButton', () => { }, propsData: { memberId: 1, - memberType: 'GroupMember', message: 'Are you sure you want to remove John Smith?', title: 'Remove member', isAccessRequest: true, @@ -77,20 +76,9 @@ describe('RemoveMemberButton', () => { it('calls Vuex action to show `remove member` modal when clicked', () => { findButton().vm.$emit('click'); - expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), modalData); - }); - - describe('button optional properties', () => { - it('has default value for category and text', () => { - createComponent(); - expect(findButton().props('category')).toBe('secondary'); - expect(findButton().text()).toBe(''); - }); - - it('allow changing value of button category and text', () => { - createComponent({ buttonCategory: 'primary', buttonText: 'Decline request' }); - expect(findButton().props('category')).toBe('primary'); - expect(findButton().text()).toBe('Decline request'); + expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), { + ...modalData, + memberModelType: undefined, }); }); }); diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js deleted file mode 100644 index 6ac46619bc9..00000000000 --- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js +++ /dev/null @@ -1,161 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; -import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; -import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; -import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; -import { member, orphanedMember } from '../../mock_data'; - -describe('UserActionButtons', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(UserActionButtons, { - propsData: { - member, - isCurrentUser: false, - isInvitedUser: false, - ...propsData, - }, - }); - }; - - const findRemoveMemberButton = () => wrapper.findComponent(RemoveMemberButton); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when user has `canRemove` permissions', () => { - beforeEach(() => { - createComponent({ - permissions: { - canRemove: true, - }, - }); - }); - - it('renders remove member button', () => { - expect(findRemoveMemberButton().exists()).toBe(true); - }); - - it('sets props correctly', () => { - expect(findRemoveMemberButton().props()).toEqual({ - memberId: member.id, - memberType: 'GroupMember', - message: `Are you sure you want to remove ${member.user.name} from "${member.source.fullName}"?`, - title: null, - isAccessRequest: false, - isInvite: false, - icon: '', - buttonCategory: 'secondary', - buttonText: 'Remove member', - userDeletionObstacles: { - name: member.user.name, - obstacles: parseUserDeletionObstacles(member.user), - }, - }); - }); - - describe('when member is orphaned', () => { - it('sets `message` prop correctly', () => { - createComponent({ - member: orphanedMember, - permissions: { - canRemove: true, - }, - }); - - expect(findRemoveMemberButton().props('message')).toBe( - `Are you sure you want to remove this orphaned member from "${orphanedMember.source.fullName}"?`, - ); - }); - }); - - describe('when member is the current user', () => { - it('renders leave button', () => { - createComponent({ - isCurrentUser: true, - permissions: { - canRemove: true, - }, - }); - - expect(wrapper.findComponent(LeaveButton).exists()).toBe(true); - }); - }); - }); - - describe('when user does not have `canRemove` permissions', () => { - it('does not render remove member button', () => { - createComponent({ - permissions: { - canRemove: false, - }, - }); - - expect(findRemoveMemberButton().exists()).toBe(false); - }); - }); - - describe('when group member', () => { - beforeEach(() => { - createComponent({ - member: { - ...member, - type: 'GroupMember', - }, - permissions: { - canRemove: true, - }, - }); - }); - - it('sets member type correctly', () => { - expect(findRemoveMemberButton().props().memberType).toBe('GroupMember'); - }); - }); - - describe('when project member', () => { - beforeEach(() => { - createComponent({ - member: { - ...member, - type: 'ProjectMember', - }, - permissions: { - canRemove: true, - }, - }); - }); - - it('sets member type correctly', () => { - expect(findRemoveMemberButton().props().memberType).toBe('ProjectMember'); - }); - }); - - describe('isInvitedUser', () => { - it.each` - isInvitedUser | icon | buttonText | buttonCategory - ${true} | ${'remove'} | ${null} | ${'primary'} - ${false} | ${''} | ${'Remove member'} | ${'secondary'} - `( - 'passes the correct props to remove-member-button when isInvitedUser is $isInvitedUser', - ({ isInvitedUser, icon, buttonText, buttonCategory }) => { - createComponent({ - isInvitedUser, - permissions: { - canRemove: true, - }, - }); - - expect(findRemoveMemberButton().props()).toEqual( - expect.objectContaining({ - icon, - buttonText, - buttonCategory, - }), - ); - }, - ); - }); -}); diff --git a/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js new file mode 100644 index 00000000000..90f5b217007 --- /dev/null +++ b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js @@ -0,0 +1,54 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LeaveGroupDropdownItem from '~/members/components/action_dropdowns/leave_group_dropdown_item.vue'; +import LeaveModal from '~/members/components/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/members/constants'; +import { member, permissions } from '../../mock_data'; + +describe('LeaveGroupDropdownItem', () => { + let wrapper; + const text = 'dummy'; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(LeaveGroupDropdownItem, { + propsData: { + member, + permissions, + ...propsData, + }, + directives: { + GlModal: createMockDirective(), + }, + slots: { + default: text, + }, + }); + }; + + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a slot with red text', () => { + expect(findDropdownItem().html()).toContain(`<span class="gl-text-red-500">${text}</span>`); + }); + + it('contains LeaveModal component', () => { + const leaveModal = wrapper.findComponent(LeaveModal); + + expect(leaveModal.props()).toEqual({ member, permissions }); + }); + + it('binds to the LeaveModal component', () => { + const binding = getBinding(findDropdownItem().element, 'gl-modal'); + + expect(binding.value).toBe(LEAVE_MODAL_ID); + }); +}); diff --git a/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js new file mode 100644 index 00000000000..e1c498249d7 --- /dev/null +++ b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js @@ -0,0 +1,77 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { modalData } from 'jest/members/mock_data'; +import RemoveMemberDropdownItem from '~/members/components/action_dropdowns/remove_member_dropdown_item.vue'; +import { MEMBER_TYPES, MEMBER_MODEL_TYPE_GROUP_MEMBER } from '~/members/constants'; + +Vue.use(Vuex); + +describe('RemoveMemberDropdownItem', () => { + let wrapper; + const text = 'dummy'; + + const actions = { + showRemoveMemberModal: jest.fn(), + }; + + const createStore = (state = {}) => { + return new Vuex.Store({ + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + actions, + }, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(RemoveMemberDropdownItem, { + store: createStore(state), + provide: { + namespace: MEMBER_TYPES.user, + }, + propsData: { + memberId: 1, + memberModelType: MEMBER_MODEL_TYPE_GROUP_MEMBER, + modalMessage: 'Are you sure you want to remove John Smith?', + isAccessRequest: true, + isInvite: true, + userDeletionObstacles: { name: 'user', obstacles: [] }, + ...propsData, + }, + slots: { + default: text, + }, + }); + }; + + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a slot with red text', () => { + expect(findDropdownItem().html()).toContain(`<span class="gl-text-red-500">${text}</span>`); + }); + + it('calls Vuex action to show `remove member` modal when clicked', () => { + findDropdownItem().vm.$emit('click'); + + expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), { + ...modalData, + preventRemoval: false, + }); + }); +}); diff --git a/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js b/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js new file mode 100644 index 00000000000..5a2de1cac80 --- /dev/null +++ b/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js @@ -0,0 +1,220 @@ +import { shallowMount } from '@vue/test-utils'; +import { sprintf } from '~/locale'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LeaveGroupDropdownItem from '~/members/components/action_dropdowns/leave_group_dropdown_item.vue'; +import RemoveMemberDropdownItem from '~/members/components/action_dropdowns/remove_member_dropdown_item.vue'; +import UserActionDropdown from '~/members/components/action_dropdowns/user_action_dropdown.vue'; +import { I18N } from '~/members/components/action_dropdowns/constants'; +import { + MEMBER_MODEL_TYPE_GROUP_MEMBER, + MEMBER_MODEL_TYPE_PROJECT_MEMBER, +} from '~/members/constants'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; +import { member, orphanedMember } from '../../mock_data'; + +describe('UserActionDropdown', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(UserActionDropdown, { + propsData: { + member, + isCurrentUser: false, + isInvitedUser: false, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findRemoveMemberDropdownItem = () => wrapper.findComponent(RemoveMemberDropdownItem); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when user has `canRemove` permissions', () => { + beforeEach(() => { + createComponent({ + permissions: { + canRemove: true, + }, + }); + }); + + it('renders remove member dropdown with correct text', () => { + const removeMemberDropdownItem = findRemoveMemberDropdownItem(); + expect(removeMemberDropdownItem.exists()).toBe(true); + expect(removeMemberDropdownItem.html()).toContain(I18N.removeMember); + }); + + it('displays a tooltip', () => { + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).not.toBeUndefined(); + expect(tooltip.value).toBe(I18N.actions); + }); + + it('sets props correctly', () => { + expect(findRemoveMemberDropdownItem().props()).toEqual({ + memberId: member.id, + memberModelType: MEMBER_MODEL_TYPE_GROUP_MEMBER, + modalMessage: sprintf( + I18N.confirmNormalUserRemoval, + { + userName: member.user.name, + group: member.source.fullName, + }, + false, + ), + isAccessRequest: false, + isInvite: false, + userDeletionObstacles: { + name: member.user.name, + obstacles: parseUserDeletionObstacles(member.user), + }, + preventRemoval: false, + }); + }); + + describe('when member is orphaned', () => { + it('sets `message` prop correctly', () => { + createComponent({ + member: orphanedMember, + permissions: { + canRemove: true, + }, + }); + + expect(findRemoveMemberDropdownItem().props('modalMessage')).toBe( + sprintf(I18N.confirmOrphanedUserRemoval, { group: orphanedMember.source.fullName }), + ); + }); + }); + + describe('when member is the current user', () => { + it('renders leave dropdown with correct text', () => { + createComponent({ + isCurrentUser: true, + permissions: { + canRemove: true, + }, + }); + + const leaveGroupDropdownItem = wrapper.findComponent(LeaveGroupDropdownItem); + expect(leaveGroupDropdownItem.exists()).toBe(true); + expect(leaveGroupDropdownItem.html()).toContain(I18N.leaveGroup); + }); + }); + }); + + describe('when user does not have `canRemove` permissions', () => { + it('does not render remove member dropdown', () => { + createComponent({ + permissions: { + canRemove: false, + }, + }); + + expect(findRemoveMemberDropdownItem().exists()).toBe(false); + }); + }); + + describe('when user can remove but it is blocked by last owner', () => { + const permissions = { + canRemove: false, + canRemoveBlockedByLastOwner: true, + }; + + it('renders remove member dropdown', () => { + createComponent({ + permissions, + }); + + expect(findRemoveMemberDropdownItem().exists()).toBe(true); + }); + + describe('when member model type is `GroupMember`', () => { + it('passes correct message to the modal', () => { + createComponent({ + permissions, + }); + + expect(findRemoveMemberDropdownItem().props('modalMessage')).toBe( + I18N.lastGroupOwnerCannotBeRemoved, + ); + }); + }); + + describe('when member model type is `ProjectMember`', () => { + it('passes correct message to the modal', () => { + createComponent({ + member: { + ...member, + type: MEMBER_MODEL_TYPE_PROJECT_MEMBER, + }, + permissions, + }); + + expect(findRemoveMemberDropdownItem().props('modalMessage')).toBe( + I18N.personalProjectOwnerCannotBeRemoved, + ); + }); + }); + + describe('when member is the current user', () => { + it('renders leave dropdown with correct props', () => { + createComponent({ + isCurrentUser: true, + permissions, + }); + + expect(wrapper.findComponent(LeaveGroupDropdownItem).props()).toEqual({ + member, + permissions, + }); + }); + }); + }); + + describe('when group member', () => { + beforeEach(() => { + createComponent({ + member: { + ...member, + type: MEMBER_MODEL_TYPE_GROUP_MEMBER, + }, + permissions: { + canRemove: true, + }, + }); + }); + + it('sets member type correctly', () => { + expect(findRemoveMemberDropdownItem().props().memberModelType).toBe( + MEMBER_MODEL_TYPE_GROUP_MEMBER, + ); + }); + }); + + describe('when project member', () => { + beforeEach(() => { + createComponent({ + member: { + ...member, + type: MEMBER_MODEL_TYPE_PROJECT_MEMBER, + }, + permissions: { + canRemove: true, + }, + }); + }); + + it('sets member type correctly', () => { + expect(findRemoveMemberDropdownItem().props().memberModelType).toBe( + MEMBER_MODEL_TYPE_PROJECT_MEMBER, + ); + }); + }); +}); diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index cdbabb2f646..ba587c6f0b3 100644 --- a/spec/frontend/members/components/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -1,11 +1,14 @@ import { GlModal, GlForm } from '@gitlab/ui'; -import { within } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import LeaveModal from '~/members/components/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants'; +import { + LEAVE_MODAL_ID, + MEMBER_TYPES, + MEMBER_MODEL_TYPE_PROJECT_MEMBER, +} from '~/members/constants'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { member } from '../../mock_data'; @@ -31,14 +34,17 @@ describe('LeaveModal', () => { }); }; - const createComponent = (propsData = {}, state) => { - wrapper = mount(LeaveModal, { + const createComponent = async (propsData = {}, state) => { + wrapper = mountExtended(LeaveModal, { store: createStore(state), provide: { namespace: MEMBER_TYPES.user, }, propsData: { member, + permissions: { + canRemove: true, + }, ...propsData, }, attrs: { @@ -46,39 +52,98 @@ describe('LeaveModal', () => { visible: true, }, }); + + await nextTick(); }; - const findModal = () => wrapper.findComponent(GlModal); + const findModal = () => extendedWrapper(wrapper.findComponent(GlModal)); const findForm = () => findModal().findComponent(GlForm); const findUserDeletionObstaclesList = () => findModal().findComponent(UserDeletionObstaclesList); - const getByText = (text, options) => - createWrapper(within(findModal().element).getByText(text, options)); - - beforeEach(async () => { - createComponent(); - await nextTick(); - }); - afterEach(() => { wrapper.destroy(); }); - it('sets modal ID', () => { + it('sets modal ID', async () => { + await createComponent(); + expect(findModal().props('modalId')).toBe(LEAVE_MODAL_ID); }); - it('displays modal title', () => { - expect(getByText(`Leave "${member.source.fullName}"`).exists()).toBe(true); + describe('when leave is allowed', () => { + it('displays modal title', async () => { + await createComponent(); + + expect(findModal().findByText(`Leave "${member.source.fullName}"`).exists()).toBe(true); + }); + + it('displays modal body', async () => { + await createComponent(); + + expect( + findModal() + .findByText(`Are you sure you want to leave "${member.source.fullName}"?`) + .exists(), + ).toBe(true); + }); }); - it('displays modal body', () => { - expect(getByText(`Are you sure you want to leave "${member.source.fullName}"?`).exists()).toBe( - true, - ); + describe('when leave is blocked by last owner', () => { + const permissions = { + canRemove: false, + canRemoveBlockedByLastOwner: true, + }; + + it('does not show primary action button', async () => { + await createComponent({ + permissions, + }); + + expect(findModal().props('actionPrimary')).toBe(null); + }); + + it('displays modal title', async () => { + await createComponent({ + permissions, + }); + + expect(findModal().findByText(`Cannot leave "${member.source.fullName}"`).exists()).toBe( + true, + ); + }); + + describe('when member model type is `GroupMember`', () => { + it('displays modal body', async () => { + await createComponent({ + permissions, + }); + + expect( + findModal().findByText(LeaveModal.i18n.preventedBodyGroupMemberModelType).exists(), + ).toBe(true); + }); + }); + + describe('when member model type is `ProjectMember`', () => { + it('displays modal body', async () => { + await createComponent({ + member: { + ...member, + type: MEMBER_MODEL_TYPE_PROJECT_MEMBER, + }, + permissions, + }); + + expect( + findModal().findByText(LeaveModal.i18n.preventedBodyProjectMemberModelType).exists(), + ).toBe(true); + }); + }); }); - it('displays form with correct action and inputs', () => { + it('displays form with correct action and inputs', async () => { + await createComponent(); + const form = findForm(); expect(form.attributes('action')).toBe('/groups/foo-bar/-/group_members/leave'); @@ -89,7 +154,9 @@ describe('LeaveModal', () => { }); describe('User deletion obstacles list', () => { - it("displays obstacles list when member's user is part of on-call management", () => { + it("displays obstacles list when member's user is part of on-call management", async () => { + await createComponent(); + const obstaclesList = findUserDeletionObstaclesList(); expect(obstaclesList.exists()).toBe(true); expect(obstaclesList.props()).toMatchObject({ @@ -105,17 +172,18 @@ describe('LeaveModal', () => { delete memberWithoutOncall.user.oncallSchedules; delete memberWithoutOncall.user.escalationPolicies; - createComponent({ member: memberWithoutOncall }); - await nextTick(); + await createComponent({ member: memberWithoutOncall }); expect(findUserDeletionObstaclesList().exists()).toBe(false); }); }); - it('submits the form when "Leave" button is clicked', () => { + it('submits the form when "Leave" button is clicked', async () => { + await createComponent(); + const submitSpy = jest.spyOn(findForm().element, 'submit'); - getByText('Leave').trigger('click'); + findModal().findByText('Leave').trigger('click'); expect(submitSpy).toHaveBeenCalled(); diff --git a/spec/frontend/members/components/modals/remove_member_modal_spec.js b/spec/frontend/members/components/modals/remove_member_modal_spec.js index 59b112492b8..47a03b5083a 100644 --- a/spec/frontend/members/components/modals/remove_member_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_member_modal_spec.js @@ -3,7 +3,11 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue'; -import { MEMBER_TYPES } from '~/members/constants'; +import { + MEMBER_TYPES, + MEMBER_MODEL_TYPE_GROUP_MEMBER, + MEMBER_MODEL_TYPE_PROJECT_MEMBER, +} from '~/members/constants'; import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; @@ -55,16 +59,16 @@ describe('RemoveMemberModal', () => { }); describe.each` - state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall - ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} | ${false} - ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${true} - ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} | ${false} - ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${false} + state | memberModelType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall + ${'removing a group member'} | ${MEMBER_MODEL_TYPE_GROUP_MEMBER} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} | ${false} + ${'removing a project member'} | ${MEMBER_MODEL_TYPE_PROJECT_MEMBER} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${true} + ${'denying an access request'} | ${MEMBER_MODEL_TYPE_PROJECT_MEMBER} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} | ${false} + ${'revoking invite'} | ${MEMBER_MODEL_TYPE_PROJECT_MEMBER} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${false} `( 'when $state', ({ actionText, - memberType, + memberModelType, isAccessRequest, isInvite, message, @@ -79,7 +83,7 @@ describe('RemoveMemberModal', () => { isInvite, message, memberPath, - memberType, + memberModelType, userDeletionObstacles, }); }); @@ -133,4 +137,28 @@ describe('RemoveMemberModal', () => { }); }, ); + + describe('when removal is prevented', () => { + const message = + 'A group must have at least one owner. To remove the member, assign a new owner.'; + + beforeEach(() => { + createComponent({ + actionText: 'Remove member', + memberModelType: MEMBER_MODEL_TYPE_GROUP_MEMBER, + isAccessRequest: false, + isInvite: false, + message, + preventRemoval: true, + }); + }); + + it('does not show primary action button', () => { + expect(findGlModal().props('actionPrimary')).toBe(null); + }); + + it('only shows the message', () => { + expect(findGlModal().text()).toBe(message); + }); + }); }); diff --git a/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap b/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap new file mode 100644 index 00000000000..a0d9bae8a0b --- /dev/null +++ b/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MemberActivity with a member that does not have all of the fields renders \`User created\` field 1`] = ` +<div> + <!----> + + <div> + <strong> + Access granted: + </strong> + + <span> + + Aug 06, 2020 + + </span> + </div> + + <!----> +</div> +`; + +exports[`MemberActivity with a member that has all fields renders \`User created\`, \`Access granted\`, and \`Last activity\` fields 1`] = ` +<div> + <div> + <strong> + User created: + </strong> + + <span> + + Mar 10, 2022 + + </span> + </div> + + <div> + <strong> + Access granted: + </strong> + + <span> + + Jul 17, 2020 + + </span> + </div> + + <div> + <strong> + Last activity: + </strong> + + <span> + + Mar 15, 2022 + + </span> + </div> +</div> +`; diff --git a/spec/frontend/members/components/table/created_at_spec.js b/spec/frontend/members/components/table/created_at_spec.js index 793c122587d..fa31177564b 100644 --- a/spec/frontend/members/components/table/created_at_spec.js +++ b/spec/frontend/members/components/table/created_at_spec.js @@ -1,20 +1,18 @@ -import { within } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { useFakeDate } from 'helpers/fake_date'; import CreatedAt from '~/members/components/table/created_at.vue'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; describe('CreatedAt', () => { // March 15th, 2020 useFakeDate(2020, 2, 15); const date = '2020-03-01T00:00:00.000'; - const dateTimeAgo = '2 weeks ago'; + const formattedDate = 'Mar 01, 2020'; let wrapper; const createComponent = (propsData) => { - wrapper = mount(CreatedAt, { + wrapper = mountExtended(CreatedAt, { propsData: { date, ...propsData, @@ -22,9 +20,6 @@ describe('CreatedAt', () => { }); }; - const getByText = (text, options) => - createWrapper(within(wrapper.element).getByText(text, options)); - afterEach(() => { wrapper.destroy(); }); @@ -35,11 +30,7 @@ describe('CreatedAt', () => { }); it('displays created at text', () => { - expect(getByText(dateTimeAgo).exists()).toBe(true); - }); - - it('uses `TimeAgoTooltip` component to display tooltip', () => { - expect(wrapper.findComponent(TimeAgoTooltip).exists()).toBe(true); + expect(wrapper.findByText(formattedDate).exists()).toBe(true); }); }); @@ -52,7 +43,7 @@ describe('CreatedAt', () => { }, }); - const link = getByText('Administrator'); + const link = wrapper.findByRole('link', { name: 'Administrator' }); expect(link.exists()).toBe(true); expect(link.attributes('href')).toBe('https://gitlab.com/root'); diff --git a/spec/frontend/members/components/table/member_action_buttons_spec.js b/spec/frontend/members/components/table/member_action_buttons_spec.js index 03cfc6ca0f6..402a5e9db27 100644 --- a/spec/frontend/members/components/table/member_action_buttons_spec.js +++ b/spec/frontend/members/components/table/member_action_buttons_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import AccessRequestActionButtons from '~/members/components/action_buttons/access_request_action_buttons.vue'; import GroupActionButtons from '~/members/components/action_buttons/group_action_buttons.vue'; import InviteActionButtons from '~/members/components/action_buttons/invite_action_buttons.vue'; -import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import UserActionDropdown from '~/members/components/action_dropdowns/user_action_dropdown.vue'; import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { member as memberMock, group, invite, accessRequest } from '../../mock_data'; @@ -29,7 +29,7 @@ describe('MemberActionButtons', () => { it.each` memberType | member | expectedComponent | expectedComponentName - ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'} + ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionDropdown} | ${'UserActionDropdown'} ${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'} ${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'} ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'} diff --git a/spec/frontend/members/components/table/member_activity_spec.js b/spec/frontend/members/components/table/member_activity_spec.js new file mode 100644 index 00000000000..a372b40fd1f --- /dev/null +++ b/spec/frontend/members/components/table/member_activity_spec.js @@ -0,0 +1,40 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MemberActivity from '~/members/components/table/member_activity.vue'; +import { member as memberMock, group as groupLinkMock } from '../../mock_data'; + +describe('MemberActivity', () => { + let wrapper; + + const defaultPropsData = { + member: memberMock, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(MemberActivity, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + }; + + describe('with a member that has all fields', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders `User created`, `Access granted`, and `Last activity` fields', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('with a member that does not have all of the fields', () => { + beforeEach(() => { + createComponent({ propsData: { member: groupLinkMock } }); + }); + + it('renders `User created` field', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/members/components/table/member_source_spec.js b/spec/frontend/members/components/table/member_source_spec.js index 2cd888207b1..fbfd0ca7ae7 100644 --- a/spec/frontend/members/components/table/member_source_spec.js +++ b/spec/frontend/members/components/table/member_source_spec.js @@ -1,19 +1,25 @@ -import { getByText as getByTextHelper } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import MemberSource from '~/members/components/table/member_source.vue'; describe('MemberSource', () => { let wrapper; + const memberSource = { + id: 102, + fullName: 'Foo bar', + webUrl: 'https://gitlab.com/groups/foo-bar', + }; + + const createdBy = { + name: 'Administrator', + webUrl: 'https://gitlab.com/root', + }; + const createComponent = (propsData) => { - wrapper = mount(MemberSource, { + wrapper = mountExtended(MemberSource, { propsData: { - memberSource: { - id: 102, - fullName: 'Foo bar', - webUrl: 'https://gitlab.com/groups/foo-bar', - }, + memberSource, ...propsData, }, directives: { @@ -22,9 +28,6 @@ describe('MemberSource', () => { }); }; - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - const getTooltipDirective = (elementWrapper) => getBinding(elementWrapper.element, 'gl-tooltip'); afterEach(() => { @@ -32,40 +35,69 @@ describe('MemberSource', () => { }); describe('direct member', () => { - it('displays "Direct member"', () => { - createComponent({ - isDirectMember: true, + describe('when created by is available', () => { + it('displays "Direct member by <user name>"', () => { + createComponent({ + isDirectMember: true, + createdBy, + }); + + expect(wrapper.text()).toBe('Direct member by Administrator'); + expect(wrapper.findByRole('link', { name: createdBy.name }).attributes('href')).toBe( + createdBy.webUrl, + ); }); + }); - expect(getByText('Direct member').exists()).toBe(true); + describe('when created by is not available', () => { + it('displays "Direct member"', () => { + createComponent({ + isDirectMember: true, + }); + + expect(wrapper.text()).toBe('Direct member'); + }); }); }); describe('inherited member', () => { - let sourceGroupLink; - - beforeEach(() => { - createComponent({ - isDirectMember: false, + describe('when created by is available', () => { + beforeEach(() => { + createComponent({ + isDirectMember: false, + createdBy, + }); }); - sourceGroupLink = getByText('Foo bar'); + it('displays "<group name> by <user name>"', () => { + expect(wrapper.text()).toBe('Foo bar by Administrator'); + expect(wrapper.findByRole('link', { name: memberSource.fullName }).attributes('href')).toBe( + memberSource.webUrl, + ); + expect(wrapper.findByRole('link', { name: createdBy.name }).attributes('href')).toBe( + createdBy.webUrl, + ); + }); }); - it('displays a link to source group', () => { - createComponent({ - isDirectMember: false, + describe('when created by is not available', () => { + beforeEach(() => { + createComponent({ + isDirectMember: false, + }); }); - expect(sourceGroupLink.exists()).toBe(true); - expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar'); - }); + it('displays a link to source group', () => { + expect(wrapper.text()).toBe(memberSource.fullName); + expect(wrapper.attributes('href')).toBe(memberSource.webUrl); + }); - it('displays tooltip with "Inherited"', () => { - const tooltipDirective = getTooltipDirective(sourceGroupLink); + it('displays tooltip with "Inherited"', () => { + const tooltipDirective = getTooltipDirective(wrapper); - expect(tooltipDirective).not.toBeUndefined(); - expect(sourceGroupLink.attributes('title')).toBe('Inherited'); + expect(tooltipDirective).not.toBeUndefined(); + expect(tooltipDirective.value).toBe('Inherited'); + }); }); }); }); diff --git a/spec/frontend/members/components/table/members_table_cell_spec.js b/spec/frontend/members/components/table/members_table_cell_spec.js index 0b0140b0cdb..ac5d83d028d 100644 --- a/spec/frontend/members/components/table/members_table_cell_spec.js +++ b/spec/frontend/members/components/table/members_table_cell_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import MembersTableCell from '~/members/components/table/members_table_cell.vue'; import { MEMBER_TYPES } from '~/members/constants'; +import { canRemoveBlockedByLastOwner } from '~/members/utils'; import { member as memberMock, directMember, @@ -12,6 +13,11 @@ import { accessRequest, } from '../../mock_data'; +jest.mock('~/members/utils', () => ({ + ...jest.requireActual('~/members/utils'), + canRemoveBlockedByLastOwner: jest.fn().mockImplementation(() => true), +})); + describe('MembersTableCell', () => { const WrappedComponent = { props: { @@ -55,6 +61,7 @@ describe('MembersTableCell', () => { provide: { sourceId: 1, currentUserId: 1, + canManageMembers: true, }, scopedSlots: { default: ` @@ -179,6 +186,15 @@ describe('MembersTableCell', () => { }); }); + describe('canRemoveBlockedByLastOwner', () => { + it('calls util and returns value', () => { + createComponentWithDirectMember(); + + expect(canRemoveBlockedByLastOwner).toHaveBeenCalledWith(directMember, true); + expect(findWrappedComponent().props('permissions').canRemoveBlockedByLastOwner).toBe(true); + }); + }); + describe('canResend', () => { describe('when member type is `invite`', () => { it('returns `true` when `canResend` is `true`', () => { diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 0ed01396fcb..1d18026a410 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -8,9 +8,9 @@ import ExpirationDatepicker from '~/members/components/table/expiration_datepick import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; import MemberAvatar from '~/members/components/table/member_avatar.vue'; import MemberSource from '~/members/components/table/member_source.vue'; +import MemberActivity from '~/members/components/table/member_activity.vue'; import MembersTable from '~/members/components/table/members_table.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; -import UserDate from '~/vue_shared/components/user_date.vue'; import { MEMBER_TYPES, MEMBER_STATE_CREATED, @@ -63,6 +63,7 @@ describe('MembersTable', () => { provide: { sourceId: 1, currentUserId: 1, + canManageMembers: true, namespace: MEMBER_TYPES.invite, ...provide, }, @@ -106,16 +107,14 @@ describe('MembersTable', () => { }; it.each` - field | label | member | expectedComponent - ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} - ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} - ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} - ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} - ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} - ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} - ${'userCreatedAt'} | ${'Created on'} | ${memberMock} | ${UserDate} - ${'lastActivityOn'} | ${'Last activity'} | ${memberMock} | ${UserDate} + field | label | member | expectedComponent + ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} + ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} + ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} + ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} + ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} + ${'activity'} | ${'Activity'} | ${memberMock} | ${MemberActivity} `('renders the $label field', ({ field, label, member, expectedComponent }) => { createComponent({ members: [member], @@ -202,16 +201,23 @@ describe('MembersTable', () => { canRemove: true, }; + const memberCanRemoveBlockedLastOwner = { + ...directMember, + canRemove: false, + isLastOwner: true, + }; + const memberNoPermissions = { ...memberMock, id: 2, }; describe.each` - permission | members - ${'canUpdate'} | ${[memberNoPermissions, memberCanUpdate]} - ${'canRemove'} | ${[memberNoPermissions, memberCanRemove]} - ${'canResend'} | ${[memberNoPermissions, invite]} + permission | members + ${'canUpdate'} | ${[memberNoPermissions, memberCanUpdate]} + ${'canRemove'} | ${[memberNoPermissions, memberCanRemove]} + ${'canRemoveBlockedByLastOwner'} | ${[memberNoPermissions, memberCanRemoveBlockedLastOwner]} + ${'canResend'} | ${[memberNoPermissions, invite]} `('when one of the members has $permission permissions', ({ members }) => { it('renders the "Actions" field', () => { createComponent({ members, tableFields: ['actions'] }); @@ -230,10 +236,11 @@ describe('MembersTable', () => { }); describe.each` - permission | members - ${'canUpdate'} | ${[memberMock]} - ${'canRemove'} | ${[memberMock]} - ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]} + permission | members + ${'canUpdate'} | ${[memberMock]} + ${'canRemove'} | ${[memberMock]} + ${'canRemoveBlockedByLastOwner'} | ${[memberMock]} + ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]} `('when none of the members have $permission permissions', ({ members }) => { it('does not render the "Actions" field', () => { createComponent({ members, tableFields: ['actions'] }); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index b254cce4d72..a11f67be8f5 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -4,11 +4,14 @@ import { within } from '@testing-library/dom'; import { mount, createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; import { MEMBER_TYPES } from '~/members/constants'; +import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; import { member } from '../../mock_data'; Vue.use(Vuex); +jest.mock('ee_else_ce/members/guest_overage_confirm_action'); describe('RoleDropdown', () => { let wrapper; @@ -33,6 +36,10 @@ describe('RoleDropdown', () => { wrapper = mount(RoleDropdown, { provide: { namespace: MEMBER_TYPES.user, + group: { + name: 'groupname', + path: '/grouppath/', + }, }, propsData: { member, @@ -63,12 +70,21 @@ describe('RoleDropdown', () => { const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); const findDropdown = () => wrapper.findComponent(GlDropdown); + let originalGon; + + beforeEach(() => { + originalGon = window.gon; + gon.features = { showOverageOnRolePromotion: true }; + }); + afterEach(() => { + window.gon = originalGon; wrapper.destroy(); }); describe('when dropdown is open', () => { beforeEach(() => { + guestOverageConfirmAction.mockReturnValue(true); createComponent(); return findDropdownToggle().trigger('click'); @@ -113,12 +129,16 @@ describe('RoleDropdown', () => { expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); }); - it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => { + it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => { await getDropdownItemByText('Developer').trigger('click'); - expect(findDropdown().props('disabled')).toBe(true); + expect(findDropdown().props('loading')).toBe(true); + }); + + it('enables dropdown after `updateMemberRole` resolves', async () => { + await getDropdownItemByText('Developer').trigger('click'); - await nextTick(); + await waitForPromises(); expect(findDropdown().props('disabled')).toBe(false); }); @@ -148,4 +168,44 @@ describe('RoleDropdown', () => { expect(findDropdown().props('right')).toBe(false); }); + + describe('guestOverageConfirmAction', () => { + const mockConfirmAction = ({ confirmed }) => { + guestOverageConfirmAction.mockResolvedValueOnce(confirmed); + }; + + beforeEach(() => { + createComponent(); + + findDropdownToggle().trigger('click'); + }); + + afterEach(() => { + guestOverageConfirmAction.mockReset(); + }); + + describe('when guestOverageConfirmAction returns true', () => { + beforeEach(() => { + mockConfirmAction({ confirmed: true }); + + getDropdownItemByText('Reporter').trigger('click'); + }); + + it('calls updateMemberRole', () => { + expect(actions.updateMemberRole).toHaveBeenCalled(); + }); + }); + + describe('when guestOverageConfirmAction returns false', () => { + beforeEach(() => { + mockConfirmAction({ confirmed: false }); + + getDropdownItemByText('Reporter').trigger('click'); + }); + + it('does not call updateMemberRole', () => { + expect(actions.updateMemberRole).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/members/guest_overage_confirm_action_spec.js b/spec/frontend/members/guest_overage_confirm_action_spec.js new file mode 100644 index 00000000000..d7ab54fa13b --- /dev/null +++ b/spec/frontend/members/guest_overage_confirm_action_spec.js @@ -0,0 +1,7 @@ +import { guestOverageConfirmAction } from '~/members/guest_overage_confirm_action'; + +describe('guestOverageConfirmAction', () => { + it('returns true', () => { + expect(guestOverageConfirmAction()).toBe(true); + }); +}); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index 49c4c46c3ac..161e96c0c48 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -1,4 +1,8 @@ -import { MEMBER_TYPES, MEMBER_STATE_CREATED } from '~/members/constants'; +import { + MEMBER_TYPES, + MEMBER_STATE_CREATED, + MEMBER_MODEL_TYPE_GROUP_MEMBER, +} from '~/members/constants'; export const member = { requestedAt: null, @@ -13,7 +17,7 @@ export const member = { fullName: 'Foo Bar', webUrl: 'https://gitlab.com/groups/foo-bar', }, - type: 'GroupMember', + type: MEMBER_MODEL_TYPE_GROUP_MEMBER, state: MEMBER_STATE_CREATED, user: { id: 123, @@ -69,7 +73,7 @@ export const modalData = { isAccessRequest: true, isInvite: true, memberPath: '/groups/foo-bar/-/group_members/1', - memberType: 'GroupMember', + memberModelType: MEMBER_MODEL_TYPE_GROUP_MEMBER, message: 'Are you sure you want to remove John Smith?', userDeletionObstacles: { name: 'user', obstacles: [] }, }; @@ -123,7 +127,15 @@ export const dataAttribute = JSON.stringify({ pagination: paginationData, member_path: '/groups/foo-bar/-/group_members/:id', ldap_override_path: '/groups/ldap-group/-/group_members/:id/override', + disable_two_factor_path: '/groups/ldap-group/-/two_factor_auth', }, source_id: 234, can_manage_members: true, }); + +export const permissions = { + canRemove: true, + canRemoveBlockedByLastOwner: false, + canResend: true, + canUpdate: true, +}; diff --git a/spec/frontend/members/store/actions_spec.js b/spec/frontend/members/store/actions_spec.js index 20dce639177..38214048b23 100644 --- a/spec/frontend/members/store/actions_spec.js +++ b/spec/frontend/members/store/actions_spec.js @@ -4,7 +4,7 @@ import { noop } from 'lodash'; import { useFakeDate } from 'helpers/fake_date'; import testAction from 'helpers/vuex_action_helper'; import { members, group, modalData } from 'jest/members/mock_data'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { updateMemberRole, showRemoveGroupLinkModal, @@ -44,7 +44,7 @@ describe('Vuex members actions', () => { describe('successful request', () => { it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => { - mock.onPut().replyOnce(httpStatusCodes.OK); + mock.onPut().replyOnce(HTTP_STATUS_OK); await testAction(updateMemberRole, payload, state, [ { @@ -83,7 +83,7 @@ describe('Vuex members actions', () => { describe('successful request', () => { describe('changing expiration date', () => { it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => { - mock.onPut().replyOnce(httpStatusCodes.OK); + mock.onPut().replyOnce(HTTP_STATUS_OK); await testAction(updateMemberExpiration, { memberId, expiresAt }, state, [ { @@ -98,7 +98,7 @@ describe('Vuex members actions', () => { describe('removing the expiration date', () => { it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => { - mock.onPut().replyOnce(httpStatusCodes.OK); + mock.onPut().replyOnce(HTTP_STATUS_OK); await testAction(updateMemberExpiration, { memberId, expiresAt: null }, state, [ { diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 8bef2096a2a..9f200324c02 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -13,8 +13,10 @@ import { isDirectMember, isCurrentUser, canRemove, + canRemoveBlockedByLastOwner, canResend, canUpdate, + canDisableTwoFactor, canOverride, parseSortParam, buildSortHref, @@ -129,6 +131,17 @@ describe('Members Utils', () => { }); }); + describe('canRemoveBlockedByLastOwner', () => { + it.each` + member | canManageMembers | expected + ${{ ...directMember, isLastOwner: true }} | ${true} | ${true} + ${{ ...inheritedMember, isLastOwner: false }} | ${true} | ${false} + ${{ ...directMember, isLastOwner: true }} | ${false} | ${false} + `('returns $expected', ({ member, canManageMembers, expected }) => { + expect(canRemoveBlockedByLastOwner(member, canManageMembers)).toBe(expected); + }); + }); + describe('canResend', () => { it.each` member | expected @@ -151,6 +164,19 @@ describe('Members Utils', () => { }); }); + describe('canDisableTwoFactor', () => { + it.each` + member | expected + ${{ ...memberMock, canGetTwoFactorDisabled: true }} | ${false} + ${{ ...memberMock, canGetTwoFactorDisabled: false }} | ${false} + `( + 'returns $expected for members whose two factor authentication can be disabled', + ({ member, expected }) => { + expect(canDisableTwoFactor(member)).toBe(expected); + }, + ); + }); + describe('canOverride', () => { it('returns `false`', () => { expect(canOverride(memberMock)).toBe(false); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index 69ff5e47689..6d434d7e654 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -5,6 +5,7 @@ import initMrPage from 'helpers/init_vue_mr_page_helper'; import { stubPerformanceWebAPI } from 'helpers/performance'; import axios from '~/lib/utils/axios_utils'; import MergeRequestTabs from '~/merge_request_tabs'; +import Diff from '~/diff'; import '~/lib/utils/common_utils'; import '~/lib/utils/url_utility'; @@ -389,4 +390,73 @@ describe('MergeRequestTabs', () => { }); }); }); + + describe('tabs <-> diff interactions', () => { + beforeEach(() => { + jest.spyOn(testContext.class, 'loadDiff').mockImplementation(() => {}); + }); + + describe('switchViewType', () => { + it('marks the class as having not loaded diffs already', () => { + testContext.class.diffsLoaded = true; + + testContext.class.switchViewType({}); + + expect(testContext.class.diffsLoaded).toBe(false); + }); + + it('reloads the diffs', () => { + testContext.class.switchViewType({ source: 'a new url' }); + + expect(testContext.class.loadDiff).toHaveBeenCalledWith({ + endpoint: 'a new url', + strip: false, + }); + }); + }); + + describe('createDiff', () => { + it("creates a Diff if there isn't one", () => { + expect(testContext.class.diffsClass).toBe(null); + + testContext.class.createDiff(); + + expect(testContext.class.diffsClass).toBeInstanceOf(Diff); + }); + + it("doesn't create a Diff if one already exists", () => { + testContext.class.diffsClass = 'truthy'; + + testContext.class.createDiff(); + + expect(testContext.class.diffsClass).toBe('truthy'); + }); + + it('sets the available MR Tabs event hub to the new Diff', () => { + expect(testContext.class.diffsClass).toBe(null); + + testContext.class.createDiff(); + + expect(testContext.class.diffsClass.mrHub).toBe(testContext.class.eventHub); + }); + }); + + describe('setHubToDiff', () => { + it('sets the MR Tabs event hub to the child Diff', () => { + testContext.class.diffsClass = {}; + + testContext.class.setHubToDiff(); + + expect(testContext.class.diffsClass.mrHub).toBe(testContext.class.eventHub); + }); + + it('does not fatal if theres no child Diff', () => { + testContext.class.diffsClass = null; + + expect(() => { + testContext.class.setHubToDiff(); + }).not.toThrow(); + }); + }); + }); }); diff --git a/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap index 8af0753f929..0c3d3e78038 100644 --- a/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap +++ b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap @@ -163,8 +163,8 @@ exports[`MlCandidate renders correctly 1`] = ` class="gl-text-secondary gl-font-weight-bold" > - Parameters - + Parameters + </td> <td @@ -190,7 +190,6 @@ exports[`MlCandidate renders correctly 1`] = ` 3 </td> </tr> - <tr class="divider" /> @@ -200,8 +199,8 @@ exports[`MlCandidate renders correctly 1`] = ` class="gl-text-secondary gl-font-weight-bold" > - Metrics - + Metrics + </td> <td @@ -227,6 +226,42 @@ exports[`MlCandidate renders correctly 1`] = ` .99 </td> </tr> + <tr + class="divider" + /> + + <tr> + <td + class="gl-text-secondary gl-font-weight-bold" + > + + Metadata + + </td> + + <td + class="gl-font-weight-bold" + > + FileName + </td> + + <td> + test.py + </td> + </tr> + <tr> + <td /> + + <td + class="gl-font-weight-bold" + > + ExecutionTime + </td> + + <td> + .0856 + </td> + </tr> </tbody> </table> </div> diff --git a/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_experiment_spec.js.snap b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_experiment_spec.js.snap index e253a0afc6c..3ee2c1cc075 100644 --- a/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_experiment_spec.js.snap +++ b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_experiment_spec.js.snap @@ -95,8 +95,8 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` <table aria-busy="false" - aria-colcount="6" - class="table b-table gl-table gl-mt-0!" + aria-colcount="9" + class="table b-table gl-table gl-mt-0! ml-candidate-table table-sm" role="table" > <!----> @@ -117,7 +117,7 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` scope="col" > <div> - L1 Ratio + Name </div> </th> <th @@ -127,7 +127,7 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` scope="col" > <div> - Rmse + Created at </div> </th> <th @@ -137,7 +137,7 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` scope="col" > <div> - Auc + User </div> </th> <th @@ -147,11 +147,41 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` scope="col" > <div> - Mae + L1 Ratio </div> </th> <th aria-colindex="5" + class="" + role="columnheader" + scope="col" + > + <div> + Rmse + </div> + </th> + <th + aria-colindex="6" + class="" + role="columnheader" + scope="col" + > + <div> + Auc + </div> + </th> + <th + aria-colindex="7" + class="" + role="columnheader" + scope="col" + > + <div> + Mae + </div> + </th> + <th + aria-colindex="8" aria-label="Details" class="" role="columnheader" @@ -160,7 +190,7 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` <div /> </th> <th - aria-colindex="6" + aria-colindex="9" aria-label="Artifact" class="" role="columnheader" @@ -183,39 +213,97 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` class="" role="cell" > - 0.4 + <div + title="aCandidate" + > + aCandidate + </div> </td> <td aria-colindex="2" class="" role="cell" > - 1 + <time + class="" + datetime="2023-01-05T14:07:02.975Z" + title="2023-01-05T14:07:02.975Z" + > + in 2 years + </time> </td> <td aria-colindex="3" class="" role="cell" - /> + > + <a + class="gl-link" + href="/root" + title="root" + > + @root + </a> + </td> <td aria-colindex="4" class="" role="cell" - /> + > + <div + title="0.4" + > + 0.4 + </div> + </td> <td aria-colindex="5" class="" role="cell" > + <div + title="1" + > + 1 + </div> + </td> + <td + aria-colindex="6" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="7" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="8" + class="" + role="cell" + > <a class="gl-link" href="link_to_candidate1" + title="Details" > Details </a> </td> <td - aria-colindex="6" + aria-colindex="9" class="" role="cell" > @@ -224,6 +312,7 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` href="link_to_artifact" rel="noopener" target="_blank" + title="Artifacts" > Artifacts </a> @@ -238,47 +327,435 @@ exports[`MlExperiment with candidates renders correctly 1`] = ` class="" role="cell" > - 0.5 + <div + title="" + > + + </div> </td> <td aria-colindex="2" class="" role="cell" - /> + > + <time + class="" + datetime="2023-01-05T14:07:02.975Z" + title="2023-01-05T14:07:02.975Z" + > + in 2 years + </time> + </td> <td aria-colindex="3" class="" role="cell" > - 0.3 + <div> + - + </div> </td> <td aria-colindex="4" class="" role="cell" - /> + > + <div + title="0.5" + > + 0.5 + </div> + </td> <td aria-colindex="5" class="" role="cell" > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="6" + class="" + role="cell" + > + <div + title="0.3" + > + 0.3 + </div> + </td> + <td + aria-colindex="7" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="8" + class="" + role="cell" + > <a class="gl-link" href="link_to_candidate2" + title="Details" > Details </a> </td> <td + aria-colindex="9" + class="" + role="cell" + > + <div + title="Artifacts" + > + + - + + </div> + </td> + </tr> + <tr + class="" + role="row" + > + <td + aria-colindex="1" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="2" + class="" + role="cell" + > + <time + class="" + datetime="2023-01-05T14:07:02.975Z" + title="2023-01-05T14:07:02.975Z" + > + in 2 years + </time> + </td> + <td + aria-colindex="3" + class="" + role="cell" + > + <div> + - + </div> + </td> + <td + aria-colindex="4" + class="" + role="cell" + > + <div + title="0.5" + > + 0.5 + </div> + </td> + <td + aria-colindex="5" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td aria-colindex="6" class="" role="cell" - /> + > + <div + title="0.3" + > + 0.3 + </div> + </td> + <td + aria-colindex="7" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="8" + class="" + role="cell" + > + <a + class="gl-link" + href="link_to_candidate3" + title="Details" + > + Details + </a> + </td> + <td + aria-colindex="9" + class="" + role="cell" + > + <div + title="Artifacts" + > + + - + + </div> + </td> + </tr> + <tr + class="" + role="row" + > + <td + aria-colindex="1" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="2" + class="" + role="cell" + > + <time + class="" + datetime="2023-01-05T14:07:02.975Z" + title="2023-01-05T14:07:02.975Z" + > + in 2 years + </time> + </td> + <td + aria-colindex="3" + class="" + role="cell" + > + <div> + - + </div> + </td> + <td + aria-colindex="4" + class="" + role="cell" + > + <div + title="0.5" + > + 0.5 + </div> + </td> + <td + aria-colindex="5" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="6" + class="" + role="cell" + > + <div + title="0.3" + > + 0.3 + </div> + </td> + <td + aria-colindex="7" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="8" + class="" + role="cell" + > + <a + class="gl-link" + href="link_to_candidate4" + title="Details" + > + Details + </a> + </td> + <td + aria-colindex="9" + class="" + role="cell" + > + <div + title="Artifacts" + > + + - + + </div> + </td> + </tr> + <tr + class="" + role="row" + > + <td + aria-colindex="1" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="2" + class="" + role="cell" + > + <time + class="" + datetime="2023-01-05T14:07:02.975Z" + title="2023-01-05T14:07:02.975Z" + > + in 2 years + </time> + </td> + <td + aria-colindex="3" + class="" + role="cell" + > + <div> + - + </div> + </td> + <td + aria-colindex="4" + class="" + role="cell" + > + <div + title="0.5" + > + 0.5 + </div> + </td> + <td + aria-colindex="5" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="6" + class="" + role="cell" + > + <div + title="0.3" + > + 0.3 + </div> + </td> + <td + aria-colindex="7" + class="" + role="cell" + > + <div + title="" + > + + </div> + </td> + <td + aria-colindex="8" + class="" + role="cell" + > + <a + class="gl-link" + href="link_to_candidate5" + title="Details" + > + Details + </a> + </td> + <td + aria-colindex="9" + class="" + role="cell" + > + <div + title="Artifacts" + > + + - + + </div> + </td> </tr> <!----> <!----> </tbody> <!----> </table> + + <!----> </div> `; diff --git a/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js b/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js index 4b16312815a..fb45c4b07a4 100644 --- a/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js +++ b/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js @@ -15,6 +15,10 @@ describe('MlCandidate', () => { { name: 'AUC', value: '.55' }, { name: 'Accuracy', value: '.99' }, ], + metadata: [ + { name: 'FileName', value: 'test.py' }, + { name: 'ExecutionTime', value: '.0856' }, + ], info: { iid: 'candidate_iid', artifact_link: 'path_to_artifact', diff --git a/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js b/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js index 50539440f25..abcaf17303f 100644 --- a/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js +++ b/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js @@ -1,12 +1,19 @@ -import { GlAlert } from '@gitlab/ui'; +import { GlAlert, GlPagination } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue'; describe('MlExperiment', () => { let wrapper; - const createWrapper = (candidates = [], metricNames = [], paramNames = []) => { - return mountExtended(MlExperiment, { provide: { candidates, metricNames, paramNames } }); + const createWrapper = ( + candidates = [], + metricNames = [], + paramNames = [], + pagination = { page: 1, isLastPage: false, per_page: 2, totalItems: 0 }, + ) => { + return mountExtended(MlExperiment, { + provide: { candidates, metricNames, paramNames, pagination }, + }); }; const findAlert = () => wrapper.findComponent(GlAlert); @@ -25,20 +32,110 @@ describe('MlExperiment', () => { expect(findEmptyState().exists()).toBe(true); }); + + it('does not show pagination', () => { + wrapper = createWrapper(); + + expect(wrapper.findComponent(GlPagination).exists()).toBe(false); + }); }); describe('with candidates', () => { - it('renders correctly', () => { - wrapper = createWrapper( + const defaultPagination = { page: 1, isLastPage: false, per_page: 2, totalItems: 5 }; + + const createWrapperWithCandidates = (pagination = defaultPagination) => { + return createWrapper( [ - { rmse: 1, l1_ratio: 0.4, details: 'link_to_candidate1', artifact: 'link_to_artifact' }, - { auc: 0.3, l1_ratio: 0.5, details: 'link_to_candidate2' }, + { + rmse: 1, + l1_ratio: 0.4, + details: 'link_to_candidate1', + artifact: 'link_to_artifact', + name: 'aCandidate', + created_at: '2023-01-05T14:07:02.975Z', + user: { username: 'root', path: '/root' }, + }, + { + auc: 0.3, + l1_ratio: 0.5, + details: 'link_to_candidate2', + created_at: '2023-01-05T14:07:02.975Z', + name: null, + user: null, + }, + { + auc: 0.3, + l1_ratio: 0.5, + details: 'link_to_candidate3', + created_at: '2023-01-05T14:07:02.975Z', + name: null, + user: null, + }, + { + auc: 0.3, + l1_ratio: 0.5, + details: 'link_to_candidate4', + created_at: '2023-01-05T14:07:02.975Z', + name: null, + user: null, + }, + { + auc: 0.3, + l1_ratio: 0.5, + details: 'link_to_candidate5', + created_at: '2023-01-05T14:07:02.975Z', + name: null, + user: null, + }, ], ['rmse', 'auc', 'mae'], ['l1_ratio'], + pagination, ); + }; + + it('renders correctly', () => { + wrapper = createWrapperWithCandidates(); expect(wrapper.element).toMatchSnapshot(); }); + + describe('Pagination behaviour', () => { + it('should show', () => { + wrapper = createWrapperWithCandidates(); + + expect(wrapper.findComponent(GlPagination).exists()).toBe(true); + }); + + it('should get the page number from the URL', () => { + wrapper = createWrapperWithCandidates({ ...defaultPagination, page: 2 }); + + expect(wrapper.findComponent(GlPagination).props().value).toBe(2); + }); + + it('should not have a prevPage if the page is 1', () => { + wrapper = createWrapperWithCandidates(); + + expect(wrapper.findComponent(GlPagination).props().prevPage).toBe(null); + }); + + it('should set the prevPage to 1 if the page is 2', () => { + wrapper = createWrapperWithCandidates({ ...defaultPagination, page: 2 }); + + expect(wrapper.findComponent(GlPagination).props().prevPage).toBe(1); + }); + + it('should not have a nextPage if isLastPage is true', async () => { + wrapper = createWrapperWithCandidates({ ...defaultPagination, isLastPage: true }); + + expect(wrapper.findComponent(GlPagination).props().nextPage).toBe(null); + }); + + it('should set the nextPage to 2 if the page is 1', () => { + wrapper = createWrapperWithCandidates(); + + expect(wrapper.findComponent(GlPagination).props().nextPage).toBe(2); + }); + }); }); }); diff --git a/spec/frontend/monitoring/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js index def4bfe9443..cf7df3dd9d5 100644 --- a/spec/frontend/monitoring/requests/index_spec.js +++ b/spec/frontend/monitoring/requests/index_spec.js @@ -2,8 +2,12 @@ import MockAdapter from 'axios-mock-adapter'; import { backoffMockImplementation } from 'helpers/backoff_helper'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; -import statusCodes, { +import { + HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_OK, + HTTP_STATUS_SERVICE_UNAVAILABLE, + HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_UNPROCESSABLE_ENTITY, } from '~/lib/utils/http_status'; import { getDashboard, getPrometheusQueryData } from '~/monitoring/requests'; @@ -32,7 +36,7 @@ describe('monitoring metrics_requests', () => { }; it('returns a dashboard response', () => { - mock.onGet(dashboardEndpoint).reply(statusCodes.OK, response); + mock.onGet(dashboardEndpoint).reply(HTTP_STATUS_OK, response); return getDashboard(dashboardEndpoint, params).then((data) => { expect(data).toEqual(metricsDashboardResponse); @@ -42,7 +46,7 @@ describe('monitoring metrics_requests', () => { it('returns a dashboard response after retrying twice', () => { mock.onGet(dashboardEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); mock.onGet(dashboardEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); - mock.onGet(dashboardEndpoint).reply(statusCodes.OK, response); + mock.onGet(dashboardEndpoint).reply(HTTP_STATUS_OK, response); return getDashboard(dashboardEndpoint, params).then((data) => { expect(data).toEqual(metricsDashboardResponse); @@ -75,7 +79,7 @@ describe('monitoring metrics_requests', () => { }; it('returns a dashboard response', () => { - mock.onGet(prometheusEndpoint).reply(statusCodes.OK, response); + mock.onGet(prometheusEndpoint).reply(HTTP_STATUS_OK, response); return getPrometheusQueryData(prometheusEndpoint, params).then((data) => { expect(data).toEqual(response.data); @@ -86,7 +90,7 @@ describe('monitoring metrics_requests', () => { // Mock multiple attempts while the cache is filling up mock.onGet(prometheusEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); mock.onGet(prometheusEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); - mock.onGet(prometheusEndpoint).reply(statusCodes.OK, response); // 3rd attempt + mock.onGet(prometheusEndpoint).reply(HTTP_STATUS_OK, response); // 3rd attempt return getPrometheusQueryData(prometheusEndpoint, params).then((data) => { expect(data).toEqual(response.data); @@ -107,7 +111,7 @@ describe('monitoring metrics_requests', () => { it('rejects after retrying twice and getting an HTTP 401 error', () => { // Mock multiple attempts while the cache is filling up and fails - mock.onGet(prometheusEndpoint).reply(statusCodes.UNAUTHORIZED, { + mock.onGet(prometheusEndpoint).reply(HTTP_STATUS_UNAUTHORIZED, { status: 'error', error: 'An error occurred', }); @@ -134,9 +138,9 @@ describe('monitoring metrics_requests', () => { it.each` code | reason - ${statusCodes.BAD_REQUEST} | ${'Parameters are missing or incorrect'} + ${HTTP_STATUS_BAD_REQUEST} | ${'Parameters are missing or incorrect'} ${HTTP_STATUS_UNPROCESSABLE_ENTITY} | ${"Expression can't be executed"} - ${statusCodes.SERVICE_UNAVAILABLE} | ${'Query timed out or aborted'} + ${HTTP_STATUS_SERVICE_UNAVAILABLE} | ${'Query timed out or aborted'} `('rejects with details: "$reason" after getting an HTTP $code error', ({ code, reason }) => { mock.onGet(prometheusEndpoint).reply(code, { status: 'error', diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index 93af6526c67..fbe030b1a7d 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -4,8 +4,10 @@ import testAction from 'helpers/vuex_action_helper'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; -import statusCodes, { +import { + HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CREATED, + HTTP_STATUS_OK, HTTP_STATUS_UNPROCESSABLE_ENTITY, } from '~/lib/utils/http_status'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; @@ -983,7 +985,7 @@ describe('Monitoring store actions', () => { }); it('Failed POST request throws an error', async () => { - mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST); + mock.onPost(state.dashboardsEndpoint).reply(HTTP_STATUS_BAD_REQUEST); await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual( 'There was an error creating the dashboard.', @@ -994,7 +996,7 @@ describe('Monitoring store actions', () => { it('Failed POST request throws an error with a description', async () => { const backendErrorMsg = 'This file already exists!'; - mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST, { + mock.onPost(state.dashboardsEndpoint).reply(HTTP_STATUS_BAD_REQUEST, { error: backendErrorMsg, }); @@ -1116,7 +1118,7 @@ describe('Monitoring store actions', () => { mock .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) - .reply(statusCodes.OK, mockPanel); + .reply(HTTP_STATUS_OK, mockPanel); testAction( fetchPanelPreview, diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 49e8ab9ebd4..3baef743f42 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -1,5 +1,4 @@ -import httpStatusCodes from '~/lib/utils/http_status'; - +import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status'; import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; import * as types from '~/monitoring/stores/mutation_types'; import mutations from '~/monitoring/stores/mutations'; @@ -318,7 +317,7 @@ describe('Monitoring mutations', () => { metricId, error: { response: { - status: httpStatusCodes.SERVICE_UNAVAILABLE, + status: HTTP_STATUS_SERVICE_UNAVAILABLE, }, }, }); @@ -336,7 +335,7 @@ describe('Monitoring mutations', () => { metricId, error: { response: { - status: httpStatusCodes.BAD_REQUEST, + status: HTTP_STATUS_BAD_REQUEST, }, }, }); diff --git a/spec/frontend/nav/components/new_nav_toggle_spec.js b/spec/frontend/nav/components/new_nav_toggle_spec.js index f09bdef8caa..ee75dfb70e4 100644 --- a/spec/frontend/nav/components/new_nav_toggle_spec.js +++ b/spec/frontend/nav/components/new_nav_toggle_spec.js @@ -14,6 +14,8 @@ jest.mock('~/flash'); const TEST_ENDPONT = 'https://example.com/toggle'; describe('NewNavToggle', () => { + useMockLocationHelper(); + let wrapper; const findToggle = () => wrapper.findComponent(GlToggle); @@ -59,18 +61,22 @@ describe('NewNavToggle', () => { }); }); - describe('changing the toggle', () => { - useMockLocationHelper(); + 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 }) => { let mock; beforeEach(() => { mock = new MockAdapter(axios); - createComponent(); + createComponent({ enabled: false }); }); it('reloads the page on success', async () => { mock.onPut(TEST_ENDPONT).reply(200); - findToggle().vm.$emit('change'); + + actFn(); await waitForPromises(); expect(window.location.reload).toHaveBeenCalled(); @@ -78,7 +84,8 @@ describe('NewNavToggle', () => { it('shows an alert on error', async () => { mock.onPut(TEST_ENDPONT).reply(500); - findToggle().vm.$emit('change'); + + actFn(); await waitForPromises(); expect(createAlert).toHaveBeenCalledWith( @@ -91,6 +98,12 @@ describe('NewNavToggle', () => { expect(window.location.reload).not.toHaveBeenCalled(); }); + it('changes the toggle', async () => { + await actFn(); + + expect(findToggle().props('value')).toBe(true); + }); + afterEach(() => { mock.restore(); }); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 701ff492702..e13985ef469 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 Autosave from '~/autosave'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; @@ -20,6 +21,7 @@ import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } jest.mock('autosize'); jest.mock('~/commons/nav/user_merge_requests'); jest.mock('~/flash'); +jest.mock('~/autosave'); Vue.use(Vuex); @@ -336,8 +338,11 @@ describe('issue_comment_form component', () => { }); it('inits autosave', () => { - expect(wrapper.vm.autosave).toBeDefined(); - expect(wrapper.vm.autosave.key).toBe(`autosave/Note/Issue/${noteableDataMock.id}`); + expect(Autosave).toHaveBeenCalledWith(expect.any(Element), [ + 'Note', + 'Issue', + noteableDataMock.id, + ]); }); }); diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js index 3b5313744ff..c71cf7666ab 100644 --- a/spec/frontend/notes/components/note_body_spec.js +++ b/spec/frontend/notes/components/note_body_spec.js @@ -7,11 +7,14 @@ 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'); + const createComponent = ({ props = {}, noteableData = noteableDataMock, @@ -84,13 +87,8 @@ describe('issue_note_body component', () => { }); it('adds autosave', () => { - const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`; - - // While we discourage testing wrapper props - // here we aren't testing a component prop - // but instead an instance object property - // which is defined in `app/assets/javascripts/notes/mixins/autosave.js` - expect(wrapper.vm.autosave.key).toEqual(autosaveKey); + // passing undefined instead of an element because of shallowMount + expect(Autosave).toHaveBeenCalledWith(undefined, ['Note', note.noteable_type, note.id]); }); describe('isInternalNote', () => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index dce2e5d370d..0b2623f3d77 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -1442,7 +1442,7 @@ describe('Actions Notes Store', () => { return testAction( actions.fetchDiscussions, {}, - { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE }, + { noteableType: notesConstants.EPIC_NOTEABLE_TYPE }, [ { type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }, { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, @@ -1472,9 +1472,7 @@ describe('Actions Notes Store', () => { ); }); - it('dispatches `fetchDiscussionsBatch` action if `paginatedMrDiscussions` feature flag is enabled', () => { - window.gon = { features: { paginatedMrDiscussions: true } }; - + it('dispatches `fetchDiscussionsBatch` action if noteable is a MergeRequest', () => { return testAction( actions.fetchDiscussions, { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js index cd04adac72d..70749557e61 100644 --- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js +++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js @@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import CustomNotificationsModal from '~/notifications/components/custom_notifications_modal.vue'; import { i18n } from '~/notifications/constants'; @@ -138,7 +138,7 @@ describe('CustomNotificationsModal', () => { mockAxios .onGet(endpointUrl) - .reply(httpStatus.OK, mockNotificationSettingsResponses.default); + .reply(HTTP_STATUS_OK, mockNotificationSettingsResponses.default); wrapper = createComponent({ injectedProperties }); @@ -155,7 +155,7 @@ describe('CustomNotificationsModal', () => { mockAxios .onGet(endpointUrl) - .reply(httpStatus.OK, mockNotificationSettingsResponses.default); + .reply(HTTP_STATUS_OK, mockNotificationSettingsResponses.default); wrapper = createComponent(); @@ -173,7 +173,7 @@ describe('CustomNotificationsModal', () => { }); it('shows a toast message when the request fails', async () => { - mockAxios.onGet('/api/v4/notification_settings').reply(httpStatus.NOT_FOUND, {}); + mockAxios.onGet('/api/v4/notification_settings').reply(HTTP_STATUS_NOT_FOUND, {}); wrapper = createComponent(); wrapper.findComponent(GlModal).vm.$emit('show'); @@ -201,11 +201,11 @@ describe('CustomNotificationsModal', () => { async ({ projectId, groupId, endpointUrl }) => { mockAxios .onGet(endpointUrl) - .reply(httpStatus.OK, mockNotificationSettingsResponses.default); + .reply(HTTP_STATUS_OK, mockNotificationSettingsResponses.default); mockAxios .onPut(endpointUrl) - .reply(httpStatus.OK, mockNotificationSettingsResponses.updated); + .reply(HTTP_STATUS_OK, mockNotificationSettingsResponses.updated); const injectedProperties = { projectId, @@ -241,7 +241,7 @@ describe('CustomNotificationsModal', () => { ); it('shows a toast message when the request fails', async () => { - mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.NOT_FOUND, {}); + mockAxios.onPut('/api/v4/notification_settings').reply(HTTP_STATUS_NOT_FOUND, {}); wrapper = createComponent(); // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details diff --git a/spec/frontend/notifications/components/notification_email_listbox_input_spec.js b/spec/frontend/notifications/components/notification_email_listbox_input_spec.js new file mode 100644 index 00000000000..c490c737cf1 --- /dev/null +++ b/spec/frontend/notifications/components/notification_email_listbox_input_spec.js @@ -0,0 +1,81 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue'; +import NotificationEmailListboxInput from '~/notifications/components/notification_email_listbox_input.vue'; + +describe('NotificationEmailListboxInput', () => { + let wrapper; + + // Props + const label = 'label'; + const name = 'name'; + const emails = ['test@gitlab.com']; + const emptyValueText = 'emptyValueText'; + const value = 'value'; + const disabled = false; + + // Finders + const findListboxInput = () => wrapper.findComponent(ListboxInput); + + const createComponent = (attachTo) => { + wrapper = shallowMount(NotificationEmailListboxInput, { + provide: { + label, + name, + emails, + emptyValueText, + value, + disabled, + }, + attachTo, + }); + }; + + describe('props', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + propName | propValue + ${'label'} | ${label} + ${'name'} | ${name} + ${'selected'} | ${value} + ${'disabled'} | ${disabled} + `('passes the $propName prop to ListboxInput', ({ propName, propValue }) => { + expect(findListboxInput().props(propName)).toBe(propValue); + }); + + it('passes the options to ListboxInput', () => { + expect(findListboxInput().props('items')).toStrictEqual([ + { text: emptyValueText, value: '' }, + { text: emails[0], value: emails[0] }, + ]); + }); + }); + + describe('form', () => { + let form; + + beforeEach(() => { + form = document.createElement('form'); + const root = document.createElement('div'); + form.appendChild(root); + createComponent(root); + }); + + afterEach(() => { + form = null; + }); + + it('submits the parent form when the value changes', async () => { + jest.spyOn(form, 'submit'); + expect(form.submit).not.toHaveBeenCalled(); + + findListboxInput().vm.$emit('select'); + await nextTick(); + + expect(form.submit).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/notifications/components/notifications_dropdown_spec.js b/spec/frontend/notifications/components/notifications_dropdown_spec.js index 7a98b374095..0f13de0e6d8 100644 --- a/spec/frontend/notifications/components/notifications_dropdown_spec.js +++ b/spec/frontend/notifications/components/notifications_dropdown_spec.js @@ -4,7 +4,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import CustomNotificationsModal from '~/notifications/components/custom_notifications_modal.vue'; import NotificationsDropdown from '~/notifications/components/notifications_dropdown.vue'; import NotificationsDropdownItem from '~/notifications/components/notifications_dropdown_item.vue'; @@ -98,7 +98,7 @@ describe('NotificationsDropdown', () => { it('opens the modal when the user clicks the button', async () => { jest.spyOn(axios, 'put'); - mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {}); + mockAxios.onPut('/api/v4/notification_settings').reply(HTTP_STATUS_OK, {}); wrapper = createComponent({ initialNotificationLevel: 'custom', @@ -233,7 +233,7 @@ describe('NotificationsDropdown', () => { ); it('updates the selectedNotificationLevel and marks the item with a checkmark', async () => { - mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {}); + mockAxios.onPut('/api/v4/notification_settings').reply(HTTP_STATUS_OK, {}); wrapper = createComponent(); const dropdownItem = findDropdownItemAt(1); @@ -245,7 +245,7 @@ describe('NotificationsDropdown', () => { }); it("won't update the selectedNotificationLevel and shows a toast message when the request fails and", async () => { - mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.NOT_FOUND, {}); + mockAxios.onPut('/api/v4/notification_settings').reply(HTTP_STATUS_NOT_FOUND, {}); wrapper = createComponent(); await clickDropdownItemAt(1); @@ -257,7 +257,7 @@ describe('NotificationsDropdown', () => { }); it('opens the modal when the user clicks on the "Custom" dropdown item', async () => { - mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {}); + mockAxios.onPut('/api/v4/notification_settings').reply(HTTP_STATUS_OK, {}); wrapper = createComponent(); await clickDropdownItemAt(5); diff --git a/spec/frontend/observability/observability_app_spec.js b/spec/frontend/observability/observability_app_spec.js index 248b0a2057c..e3bcd140d60 100644 --- a/spec/frontend/observability/observability_app_spec.js +++ b/spec/frontend/observability/observability_app_spec.js @@ -2,11 +2,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ObservabilityApp from '~/observability/components/observability_app.vue'; import ObservabilitySkeleton from '~/observability/components/skeleton/index.vue'; -import { - MESSAGE_EVENT_TYPE, - OBSERVABILITY_ROUTES, - SKELETON_VARIANT, -} from '~/observability/constants'; +import { MESSAGE_EVENT_TYPE, SKELETON_VARIANTS_BY_ROUTE } from '~/observability/constants'; import { darkModeEnabled } from '~/lib/utils/color_utils'; @@ -20,6 +16,7 @@ describe('Observability root app', () => { }; const $route = { pathname: 'https://gitlab.com/gitlab-org/', + path: 'https://gitlab.com/gitlab-org/-/observability/dashboards', query: { otherQuery: 100 }, }; @@ -29,6 +26,10 @@ describe('Observability root app', () => { const TEST_IFRAME_SRC = 'https://observe.gitlab.com/9970/?groupId=14485840'; + const OBSERVABILITY_ROUTES = Object.keys(SKELETON_VARIANTS_BY_ROUTE); + + const SKELETON_VARIANTS = Object.values(SKELETON_VARIANTS_BY_ROUTE); + const mountComponent = (route = $route) => { wrapper = shallowMountExtended(ObservabilityApp, { propsData: { @@ -139,9 +140,9 @@ describe('Observability root app', () => { describe('on GOUI_LOADED', () => { beforeEach(() => { mountComponent(); - wrapper.vm.$refs.iframeSkeleton.handleSkeleton = mockHandleSkeleton; + wrapper.vm.$refs.observabilitySkeleton.onContentLoaded = mockHandleSkeleton; }); - it('should call handleSkeleton method', () => { + it('should call onContentLoaded method', () => { dispatchMessageEvent({ data: { type: MESSAGE_EVENT_TYPE.GOUI_LOADED }, origin: 'https://observe.gitlab.com', @@ -149,7 +150,7 @@ describe('Observability root app', () => { expect(mockHandleSkeleton).toHaveBeenCalled(); }); - it('should not call handleSkeleton method if origin is different', () => { + it('should not call onContentLoaded method if origin is different', () => { dispatchMessageEvent({ data: { type: MESSAGE_EVENT_TYPE.GOUI_LOADED }, origin: 'https://example.com', @@ -157,7 +158,7 @@ describe('Observability root app', () => { expect(mockHandleSkeleton).not.toHaveBeenCalled(); }); - it('should not call handleSkeleton method if event type is different', () => { + it('should not call onContentLoaded method if event type is different', () => { dispatchMessageEvent({ data: { type: 'UNKNOWN_EVENT' }, origin: 'https://observe.gitlab.com', @@ -168,11 +169,11 @@ describe('Observability root app', () => { describe('skeleton variant', () => { it.each` - pathDescription | path | variant - ${'dashboards'} | ${OBSERVABILITY_ROUTES.DASHBOARDS} | ${SKELETON_VARIANT.DASHBOARDS} - ${'explore'} | ${OBSERVABILITY_ROUTES.EXPLORE} | ${SKELETON_VARIANT.EXPLORE} - ${'manage dashboards'} | ${OBSERVABILITY_ROUTES.MANAGE} | ${SKELETON_VARIANT.MANAGE} - ${'any other'} | ${'unknown/route'} | ${SKELETON_VARIANT.DASHBOARDS} + pathDescription | path | variant + ${'dashboards'} | ${OBSERVABILITY_ROUTES[0]} | ${SKELETON_VARIANTS[0]} + ${'explore'} | ${OBSERVABILITY_ROUTES[1]} | ${SKELETON_VARIANTS[1]} + ${'manage dashboards'} | ${OBSERVABILITY_ROUTES[2]} | ${SKELETON_VARIANTS[2]} + ${'any other'} | ${'unknown/route'} | ${SKELETON_VARIANTS[0]} `('renders the $variant skeleton variant for $pathDescription path', ({ path, variant }) => { mountComponent({ ...$route, path }); const props = wrapper.findComponent(ObservabilitySkeleton).props(); diff --git a/spec/frontend/observability/skeleton_spec.js b/spec/frontend/observability/skeleton_spec.js index 5637c0e6d70..a95597d8516 100644 --- a/spec/frontend/observability/skeleton_spec.js +++ b/spec/frontend/observability/skeleton_spec.js @@ -1,96 +1,127 @@ -import { GlSkeletonLoader } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ObservabilitySkeleton from '~/observability/components/skeleton/index.vue'; +import Skeleton from '~/observability/components/skeleton/index.vue'; import DashboardsSkeleton from '~/observability/components/skeleton/dashboards.vue'; import ExploreSkeleton from '~/observability/components/skeleton/explore.vue'; import ManageSkeleton from '~/observability/components/skeleton/manage.vue'; -import { SKELETON_VARIANT } from '~/observability/constants'; +import { SKELETON_VARIANTS_BY_ROUTE, DEFAULT_TIMERS } from '~/observability/constants'; -describe('ObservabilitySkeleton component', () => { +describe('Skeleton component', () => { let wrapper; + const SKELETON_VARIANTS = Object.values(SKELETON_VARIANTS_BY_ROUTE); + + const findContentWrapper = () => wrapper.findByTestId('observability-wrapper'); + + const findExploreSkeleton = () => wrapper.findComponent(ExploreSkeleton); + + const findDashboardsSkeleton = () => wrapper.findComponent(DashboardsSkeleton); + + const findManageSkeleton = () => wrapper.findComponent(ManageSkeleton); + + const findAlert = () => wrapper.findComponent(GlAlert); + const mountComponent = ({ ...props } = {}) => { - wrapper = shallowMountExtended(ObservabilitySkeleton, { + wrapper = shallowMountExtended(Skeleton, { propsData: props, }); }; - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - describe('on mount', () => { beforeEach(() => { - jest.spyOn(global, 'setTimeout'); - mountComponent(); + mountComponent({ variant: 'explore' }); }); - it('should call setTimeout on mount and show ObservabilitySkeleton if Observability UI is not loaded yet', () => { - jest.runAllTimers(); + describe('loading timers', () => { + it('show Skeleton if content is not loaded within CONTENT_WAIT_MS', async () => { + expect(findExploreSkeleton().exists()).toBe(false); + expect(findContentWrapper().isVisible()).toBe(false); - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); - expect(wrapper.vm.loading).toBe(true); - expect(wrapper.vm.timerId).not.toBeNull(); - }); + jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS); - it('should call setTimeout on mount and dont show ObservabilitySkeleton if Observability UI is loaded', () => { - wrapper.vm.loading = false; - jest.runAllTimers(); + await nextTick(); - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); - expect(wrapper.vm.loading).toBe(false); - expect(wrapper.vm.timerId).not.toBeNull(); - }); - }); + expect(findExploreSkeleton().exists()).toBe(true); + expect(findContentWrapper().isVisible()).toBe(false); + }); - describe('handleSkeleton', () => { - it('will not show the skeleton if Observability UI is loaded before', () => { - jest.spyOn(global, 'clearTimeout'); - mountComponent(); - wrapper.vm.handleSkeleton(); - expect(clearTimeout).toHaveBeenCalledWith(wrapper.vm.timerId); + it('does not show the skeleton if content has loaded within CONTENT_WAIT_MS', async () => { + expect(findExploreSkeleton().exists()).toBe(false); + expect(findContentWrapper().isVisible()).toBe(false); + + wrapper.vm.onContentLoaded(); + + await nextTick(); + + expect(findContentWrapper().isVisible()).toBe(true); + expect(findExploreSkeleton().exists()).toBe(false); + + jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS); + + await nextTick(); + + expect(findContentWrapper().isVisible()).toBe(true); + expect(findExploreSkeleton().exists()).toBe(false); + }); }); - it('will hide skeleton gracefully after 400ms if skeleton was present on screen before Observability UI', () => { - jest.spyOn(global, 'setTimeout'); - mountComponent(); - jest.runAllTimers(); - wrapper.vm.handleSkeleton(); - jest.runAllTimers(); + describe('error timeout', () => { + it('shows the error dialog if content has not loaded within TIMEOUT_MS', async () => { + expect(findAlert().exists()).toBe(false); + jest.advanceTimersByTime(DEFAULT_TIMERS.TIMEOUT_MS); + + await nextTick(); + + expect(findAlert().exists()).toBe(true); + expect(findContentWrapper().isVisible()).toBe(false); + }); + + it('does not show the error dialog if content has loaded within TIMEOUT_MS', async () => { + wrapper.vm.onContentLoaded(); + jest.advanceTimersByTime(DEFAULT_TIMERS.TIMEOUT_MS); - expect(setTimeout).toHaveBeenCalledWith(wrapper.vm.hideSkeleton, 400); - expect(wrapper.vm.loading).toBe(false); + await nextTick(); + + expect(findAlert().exists()).toBe(false); + expect(findContentWrapper().isVisible()).toBe(true); + }); }); }); describe('skeleton variant', () => { it.each` skeletonType | condition | variant - ${'dashboards'} | ${'variant is dashboards'} | ${SKELETON_VARIANT.DASHBOARDS} - ${'explore'} | ${'variant is explore'} | ${SKELETON_VARIANT.EXPLORE} - ${'manage'} | ${'variant is manage'} | ${SKELETON_VARIANT.MANAGE} + ${'dashboards'} | ${'variant is dashboards'} | ${SKELETON_VARIANTS[0]} + ${'explore'} | ${'variant is explore'} | ${SKELETON_VARIANTS[1]} + ${'manage'} | ${'variant is manage'} | ${SKELETON_VARIANTS[2]} ${'default'} | ${'variant is not manage, dashboards or explore'} | ${'unknown'} `('should render $skeletonType skeleton if $condition', async ({ skeletonType, variant }) => { mountComponent({ variant }); - const showsDefaultSkeleton = ![ - SKELETON_VARIANT.DASHBOARDS, - SKELETON_VARIANT.EXPLORE, - SKELETON_VARIANT.MANAGE, - ].includes(variant); - expect(wrapper.findComponent(DashboardsSkeleton).exists()).toBe( - skeletonType === SKELETON_VARIANT.DASHBOARDS, - ); - expect(wrapper.findComponent(ExploreSkeleton).exists()).toBe( - skeletonType === SKELETON_VARIANT.EXPLORE, - ); - expect(wrapper.findComponent(ManageSkeleton).exists()).toBe( - skeletonType === SKELETON_VARIANT.MANAGE, - ); + jest.advanceTimersByTime(DEFAULT_TIMERS.CONTENT_WAIT_MS); + await nextTick(); + const showsDefaultSkeleton = !SKELETON_VARIANTS.includes(variant); + + expect(findDashboardsSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[0]); + expect(findExploreSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[1]); + expect(findManageSkeleton().exists()).toBe(skeletonType === SKELETON_VARIANTS[2]); expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(showsDefaultSkeleton); }); }); + + describe('on destroy', () => { + it('should clear init timer and timeout timer', () => { + jest.spyOn(global, 'clearTimeout'); + mountComponent(); + wrapper.destroy(); + expect(clearTimeout).toHaveBeenCalledTimes(2); + expect(clearTimeout.mock.calls).toEqual([ + [wrapper.vm.loadingTimeout], // First call + [wrapper.vm.errorTimeout], // Second call + ]); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js index 96c670eaad2..fa0d76762df 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js @@ -335,10 +335,10 @@ describe('tags list row', () => { }); describe.each` - name | finderFunction | text | icon | clipboard - ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 UTC on 2020-11-03'} | ${'clock'} | ${false} - ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} - ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} + name | finderFunction | text | icon | clipboard + ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 13:29:38 UTC on 2020-11-03'} | ${'clock'} | ${false} + ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true} + ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true} `('$name details row', ({ finderFunction, text, icon, clipboard }) => { it(`has ${text} as text`, async () => { mountComponent(); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js index 7da91c4af96..75068591007 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js @@ -1,6 +1,6 @@ import { GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { createMockDirective } from 'helpers/vue_mock_directive'; import { mockTracking } from 'helpers/tracking_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import DeleteButton from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue'; @@ -59,31 +59,6 @@ describe('Image List Row', () => { wrapper = null; }); - describe('list item component', () => { - describe('tooltip', () => { - it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => { - mountComponent(); - - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip).toBeDefined(); - expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION); - }); - - it('is disabled when item is being deleted', () => { - mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); - - const tooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(tooltip.value.disabled).toBe(false); - }); - }); - - it('is disabled when the item is in deleting status', () => { - mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); - - expect(findListItemComponent().props('disabled')).toBe(true); - }); - }); - describe('image title and path', () => { it('renders shortened name of image and contains a link to the details page', () => { mountComponent(); @@ -158,10 +133,22 @@ describe('Image List Row', () => { mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } }); }); - it('the router link is disabled', () => { - // we check the event prop as is the only workaround to disable a router link - expect(findDetailsLink().props('event')).toBe(''); + it('the router link does not exist', () => { + expect(findDetailsLink().exists()).toBe(false); + }); + + it('image name exists', () => { + expect(findListItemComponent().text()).toContain('gitlab-test/rails-12009'); + }); + + it(`contains secondary text ${ROW_SCHEDULED_FOR_DELETION}`, () => { + expect(findListItemComponent().text()).toContain(ROW_SCHEDULED_FOR_DELETION); }); + + it('the tags count does not exist', () => { + expect(findTagsCount().exists()).toBe(false); + }); + it('the clipboard button is disabled', () => { expect(findClipboardButton().attributes('disabled')).toBe('true'); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js index c6b5138639e..0cbe2755f7e 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js @@ -61,14 +61,14 @@ describe('Package History', () => { ); }); describe.each` - name | amount | icon | text | timeAgoTooltip | link - ${'created-on'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'clock'} | ${'Test package version 1.0.0 was first created'} | ${mavenPackage.created_at} | ${null} - ${'first-pipeline-commit'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'commit'} | ${'Created by commit #sha-baz on branch branch-name'} | ${null} | ${mockPipelineInfo.project.commit_url} - ${'first-pipeline-pipeline'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pipeline'} | ${'Built by pipeline #1 triggered by foo'} | ${mockPipelineInfo.created_at} | ${mockPipelineInfo.project.pipeline_url} - ${'published'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'package'} | ${'Published to the baz project Package Registry'} | ${mavenPackage.created_at} | ${null} - ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'history'} | ${'Package has 1 archived update'} | ${null} | ${null} - ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 3} | ${'history'} | ${'Package has 2 archived updates'} | ${null} | ${null} - ${'pipeline-entry'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pencil'} | ${'Package updated by commit #sha-baz on branch branch-name, built by pipeline #3, and published to the registry'} | ${mavenPackage.created_at} | ${mockPipelineInfo.project.commit_url} + name | amount | icon | text | timeAgoTooltip | link + ${'created-on'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'clock'} | ${'Test package version 1.0.0 was first created'} | ${mavenPackage.created_at} | ${null} + ${'first-pipeline-commit'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'commit'} | ${'Created by commit sha-baz on branch branch-name'} | ${null} | ${mockPipelineInfo.project.commit_url} + ${'first-pipeline-pipeline'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pipeline'} | ${'Built by pipeline #1 triggered by foo'} | ${mockPipelineInfo.created_at} | ${mockPipelineInfo.project.pipeline_url} + ${'published'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'package'} | ${'Published to the baz project Package Registry'} | ${mavenPackage.created_at} | ${null} + ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'history'} | ${'Package has 1 archived update'} | ${null} | ${null} + ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 3} | ${'history'} | ${'Package has 2 archived updates'} | ${null} | ${null} + ${'pipeline-entry'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pencil'} | ${'Package updated by commit sha-baz on branch branch-name, built by pipeline #3, and published to the registry'} | ${mavenPackage.created_at} | ${mockPipelineInfo.project.commit_url} `( 'with $amount pipelines history element $name', ({ name, icon, text, timeAgoTooltip, link, amount }) => { 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 e0e26434680..9c1ebf5a2eb 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 @@ -63,6 +63,14 @@ describe('DeleteModal', () => { expect(wrapper.emitted('confirm')).toHaveLength(1); }); + it('emits cancel when cancel event is emitted', () => { + expect(wrapper.emitted('cancel')).toBeUndefined(); + + findModal().vm.$emit('cancel'); + + expect(wrapper.emitted('cancel')).toHaveLength(1); + }); + it('show calls gl-modal show', () => { findModal().vm.show(); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap index c4020eeb75f..b2375da7b11 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap @@ -114,7 +114,7 @@ exports[`PypiInstallation renders all the messages 1`] = ` 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="pip install @gitlab-org/package-15 --extra-index-url http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple" + data-clipboard-text="pip install @gitlab-org/package-15 --index-url http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple" id="clipboard-button-6" title="Copy Pip command" type="button" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js index ec2e833552a..bb2fa9eb6f5 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js @@ -131,14 +131,14 @@ describe('Package History', () => { }); describe.each` - name | amount | icon | text | timeAgoTooltip | link - ${'created-on'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'clock'} | ${'@gitlab-org/package-15 version 1.0.0 was first created'} | ${packageData().createdAt} | ${null} - ${'first-pipeline-commit'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'commit'} | ${'Created by commit #b83d6e39 on branch master'} | ${null} | ${onePipeline.commitPath} - ${'first-pipeline-pipeline'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pipeline'} | ${'Built by pipeline #1 triggered by Administrator'} | ${onePipeline.createdAt} | ${onePipeline.path} - ${'published'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'package'} | ${'Published to the baz project Package Registry'} | ${packageData().createdAt} | ${null} - ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'history'} | ${'Package has 1 archived update'} | ${null} | ${null} - ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 3} | ${'history'} | ${'Package has 2 archived updates'} | ${null} | ${null} - ${'pipeline-entry'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pencil'} | ${'Package updated by commit #b83d6e39 on branch master, built by pipeline #3, and published to the registry'} | ${packageData().createdAt} | ${onePipeline.commitPath} + name | amount | icon | text | timeAgoTooltip | link + ${'created-on'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'clock'} | ${'@gitlab-org/package-15 version 1.0.0 was first created'} | ${packageData().createdAt} | ${null} + ${'first-pipeline-commit'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'commit'} | ${'Created by commit b83d6e39 on branch master'} | ${null} | ${onePipeline.commitPath} + ${'first-pipeline-pipeline'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pipeline'} | ${'Built by pipeline #1 triggered by Administrator'} | ${onePipeline.createdAt} | ${onePipeline.path} + ${'published'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'package'} | ${'Published to the baz project Package Registry'} | ${packageData().createdAt} | ${null} + ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'history'} | ${'Package has 1 archived update'} | ${null} | ${null} + ${'archived'} | ${HISTORY_PIPELINES_LIMIT + 3} | ${'history'} | ${'Package has 2 archived updates'} | ${null} | ${null} + ${'pipeline-entry'} | ${HISTORY_PIPELINES_LIMIT + 2} | ${'pencil'} | ${'Package updated by commit b83d6e39 on branch master, built by pipeline #3, and published to the registry'} | ${packageData().createdAt} | ${onePipeline.commitPath} `( 'with $amount pipelines history element $name', ({ name, icon, text, timeAgoTooltip, link, amount }) => { 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 f0fa9592419..20a459e2c1a 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,7 +1,7 @@ -import { GlKeysetPagination } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import { packageData } from '../../mock_data'; @@ -21,7 +21,7 @@ describe('PackageVersionsList', () => { const uiElements = { findLoader: () => wrapper.findComponent(PackagesListLoader), - findListPagination: () => wrapper.findComponent(GlKeysetPagination), + findRegistryList: () => wrapper.findComponent(RegistryList), findEmptySlot: () => wrapper.findComponent(EmptySlotStub), findListRow: () => wrapper.findAllComponents(VersionRow), }; @@ -33,6 +33,9 @@ describe('PackageVersionsList', () => { isLoading: false, ...props, }, + stubs: { + RegistryList, + }, slots: { 'empty-state': EmptySlotStub, }, @@ -55,8 +58,8 @@ describe('PackageVersionsList', () => { expect(uiElements.findEmptySlot().exists()).toBe(false); }); - it('does not display pagination', () => { - expect(uiElements.findListPagination().exists()).toBe(false); + it('does not display registry list', () => { + expect(uiElements.findRegistryList().exists()).toBe(false); }); }); @@ -77,8 +80,8 @@ describe('PackageVersionsList', () => { expect(uiElements.findListRow().exists()).toBe(false); }); - it('does not display pagination', () => { - expect(uiElements.findListPagination().exists()).toBe(false); + it('does not display registry list', () => { + expect(uiElements.findRegistryList().exists()).toBe(false); }); }); @@ -87,6 +90,19 @@ describe('PackageVersionsList', () => { mountComponent(); }); + it('displays package registry list', () => { + expect(uiElements.findRegistryList().exists()).toEqual(true); + }); + + it('binds the right props', () => { + expect(uiElements.findRegistryList().props()).toMatchObject({ + items: packageList, + pagination: {}, + isLoading: false, + hiddenDelete: true, + }); + }); + it('displays package version rows', () => { expect(uiElements.findListRow().exists()).toEqual(true); expect(uiElements.findListRow()).toHaveLength(packageList.length); @@ -102,27 +118,6 @@ describe('PackageVersionsList', () => { }); }); - describe('pagination display', () => { - it('does not display pagination if there is no previous or next page', () => { - expect(uiElements.findListPagination().exists()).toBe(false); - }); - - it('displays pagination if pageInfo.hasNextPage is true', async () => { - await wrapper.setProps({ pageInfo: { hasNextPage: true } }); - expect(uiElements.findListPagination().exists()).toBe(true); - }); - - it('displays pagination if pageInfo.hasPreviousPage is true', async () => { - await wrapper.setProps({ pageInfo: { hasPreviousPage: true } }); - expect(uiElements.findListPagination().exists()).toBe(true); - }); - - it('displays pagination if both pageInfo.hasNextPage and pageInfo.hasPreviousPage are true', async () => { - await wrapper.setProps({ pageInfo: { hasNextPage: true, hasPreviousPage: true } }); - expect(uiElements.findListPagination().exists()).toBe(true); - }); - }); - it('does not display loader', () => { expect(uiElements.findLoader().exists()).toBe(false); }); @@ -137,14 +132,14 @@ describe('PackageVersionsList', () => { mountComponent({ pageInfo: { hasNextPage: true } }); }); - it('emits prev-page event when paginator emits prev event', () => { - uiElements.findListPagination().vm.$emit('prev'); + it('emits prev-page event when registry list emits prev event', () => { + uiElements.findRegistryList().vm.$emit('prev-page'); expect(wrapper.emitted('prev-page')).toHaveLength(1); }); - it('emits next-page when paginator emits next event', () => { - uiElements.findListPagination().vm.$emit('next'); + it('emits next-page when registry list emits next event', () => { + uiElements.findRegistryList().vm.$emit('next-page'); expect(wrapper.emitted('next-page')).toHaveLength(1); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js index 20acb0872e5..4a27f8011df 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js @@ -16,7 +16,7 @@ const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_PYPI }; describe('PypiInstallation', () => { let wrapper; - const pipCommandStr = `pip install @gitlab-org/package-15 --extra-index-url ${packageEntity.pypiUrl}`; + const pipCommandStr = `pip install @gitlab-org/package-15 --index-url ${packageEntity.pypiUrl}`; const pypiSetupStr = `[gitlab] repository = ${packageEntity.pypiSetupUrl} username = __token__ 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 7cc5bea0f7a..5e9cb8fbb0b 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 @@ -1,14 +1,19 @@ import { GlAlert, GlSprintf } from '@gitlab/ui'; import { nextTick } from 'vue'; 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 { DELETE_PACKAGE_TRACKING_ACTION, + DELETE_PACKAGES_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGES_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGES_TRACKING_ACTION, } from '~/packages_and_registries/package_registry/constants'; import PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; import Tracking from '~/tracking'; @@ -44,6 +49,7 @@ describe('packages_list', () => { const findRegistryList = () => wrapper.findComponent(RegistryList); const findPackagesListRow = () => wrapper.findComponent(PackagesListRow); const findErrorPackageAlert = () => wrapper.findComponent(GlAlert); + const findDeletePackagesModal = () => wrapper.findComponent(DeleteModal); const mountComponent = (props) => { wrapper = shallowMountExtended(PackagesList, { @@ -53,6 +59,11 @@ describe('packages_list', () => { }, stubs: { DeletePackageModal, + DeleteModal: stubComponent(DeleteModal, { + methods: { + show: jest.fn(), + }, + }), GlSprintf, RegistryList, }, @@ -125,20 +136,48 @@ describe('packages_list', () => { }); }); - describe('when the user can destroy the package', () => { - beforeEach(async () => { + describe.each` + description | finderFunction | deletePayload + ${'when the user can destroy the package'} | ${findPackagesListRow} | ${firstPackage} + ${'when the user can bulk destroy packages and deletes only one package'} | ${findRegistryList} | ${[firstPackage]} + `('$description', ({ finderFunction, deletePayload }) => { + let eventSpy; + const category = 'UI::NpmPackages'; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); mountComponent(); - await findPackagesListRow().vm.$emit('delete', firstPackage); + finderFunction().vm.$emit('delete', deletePayload); }); it('passes itemToBeDeleted to the modal', () => { expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(firstPackage); }); - it('emits package:delete when modal confirms', async () => { - await findPackageListDeleteModal().vm.$emit('ok'); + it('requesting delete tracks the right action', () => { + expect(eventSpy).toHaveBeenCalledWith( + category, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + expect.any(Object), + ); + }); + + describe('when modal confirms', () => { + beforeEach(() => { + findPackageListDeleteModal().vm.$emit('ok'); + }); + + it('emits package:delete when modal confirms', () => { + expect(wrapper.emitted('package:delete')[0]).toEqual([firstPackage]); + }); - expect(wrapper.emitted('package:delete')[0]).toEqual([firstPackage]); + it('tracks the right action', () => { + expect(eventSpy).toHaveBeenCalledWith( + category, + DELETE_PACKAGE_TRACKING_ACTION, + expect.any(Object), + ); + }); }); it.each(['ok', 'cancel'])('resets itemToBeDeleted when modal emits %s', async (event) => { @@ -146,26 +185,73 @@ describe('packages_list', () => { expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull(); }); + + it('canceling delete tracks the right action', () => { + findPackageListDeleteModal().vm.$emit('cancel'); + + expect(eventSpy).toHaveBeenCalledWith( + category, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + expect.any(Object), + ); + }); }); describe('when the user can bulk destroy packages', () => { + let eventSpy; + const items = [firstPackage, secondPackage]; + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); mountComponent(); + findRegistryList().vm.$emit('delete', items); }); - it('passes itemToBeDeleted to the modal when there is only one package', async () => { - await findRegistryList().vm.$emit('delete', [firstPackage]); - - expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(firstPackage); + it('passes itemsToBeDeleted to the modal', () => { + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual(items); expect(wrapper.emitted('delete')).toBeUndefined(); }); - it('emits delete when there is more than one package', () => { - const items = [firstPackage, secondPackage]; - findRegistryList().vm.$emit('delete', items); + it('requesting delete tracks the right action', () => { + expect(eventSpy).toHaveBeenCalledWith( + undefined, + REQUEST_DELETE_PACKAGES_TRACKING_ACTION, + expect.any(Object), + ); + }); + + describe('when modal confirms', () => { + beforeEach(() => { + findDeletePackagesModal().vm.$emit('confirm'); + }); + + it('emits delete event', () => { + expect(wrapper.emitted('delete')[0]).toEqual([items]); + }); + + it('tracks the right action', () => { + expect(eventSpy).toHaveBeenCalledWith( + undefined, + DELETE_PACKAGES_TRACKING_ACTION, + expect.any(Object), + ); + }); + }); + + it.each(['confirm', 'cancel'])('resets itemsToBeDeleted when modal emits %s', async (event) => { + await findDeletePackagesModal().vm.$emit(event); - expect(wrapper.emitted('delete')).toHaveLength(1); - expect(wrapper.emitted('delete')[0]).toEqual([items]); + expect(findDeletePackagesModal().props('itemsToBeDeleted')).toHaveLength(0); + }); + + it('canceling delete tracks the right action', () => { + findDeletePackagesModal().vm.$emit('cancel'); + + expect(eventSpy).toHaveBeenCalledWith( + undefined, + CANCEL_DELETE_PACKAGES_TRACKING_ACTION, + expect.any(Object), + ); }); }); @@ -223,44 +309,4 @@ describe('packages_list', () => { expect(wrapper.emitted('next-page')).toHaveLength(1); }); }); - - describe('tracking', () => { - let eventSpy; - const category = 'UI::NpmPackages'; - - beforeEach(() => { - eventSpy = jest.spyOn(Tracking, 'event'); - mountComponent(); - findPackagesListRow().vm.$emit('delete', firstPackage); - return nextTick(); - }); - - it('requesting the delete tracks the right action', () => { - expect(eventSpy).toHaveBeenCalledWith( - category, - REQUEST_DELETE_PACKAGE_TRACKING_ACTION, - expect.any(Object), - ); - }); - - it('confirming delete tracks the right action', () => { - findPackageListDeleteModal().vm.$emit('ok'); - - expect(eventSpy).toHaveBeenCalledWith( - category, - DELETE_PACKAGE_TRACKING_ACTION, - expect.any(Object), - ); - }); - - it('canceling delete tracks the right action', () => { - findPackageListDeleteModal().vm.$emit('cancel'); - - expect(eventSpy).toHaveBeenCalledWith( - category, - CANCEL_DELETE_PACKAGE_TRACKING_ACTION, - expect.any(Object), - ); - }); - }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap deleted file mode 100644 index c2fecf87428..00000000000 --- a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap +++ /dev/null @@ -1,125 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PackagesListApp renders 1`] = ` -<div> - <!----> - - <gl-card-stub - bodyclass="gl-display-flex gl-p-0!" - class="gl-px-8 gl-py-6 gl-line-height-20 gl-mt-3" - footerclass="" - headerclass="" - > - <!----> - - <div - class="gl-banner-content" - > - <h2 - class="gl-banner-title" - > - Help us learn about your registry migration needs - </h2> - - <p> - If you are interested in migrating packages from your private registry to the GitLab Package Registry, take our survey and tell us more about your needs. - </p> - - <gl-button-stub - buttontextclasses="" - category="primary" - data-testid="gl-banner-primary-button" - href="https://gitlab.fra1.qualtrics.com/jfe/form/SV_cHomH9FPzOaiDTU" - icon="" - size="medium" - variant="confirm" - > - Take survey - </gl-button-stub> - - </div> - - <gl-button-stub - aria-label="Close banner" - buttontextclasses="" - category="tertiary" - class="gl-banner-close" - icon="close" - size="small" - variant="default" - /> - </gl-card-stub> - - <package-title-stub - count="2" - helpurl="/help/user/packages/index" - /> - - <package-search-stub - class="gl-mb-5" - /> - - <div> - <section - class="gl-display-flex empty-state gl-text-center gl-flex-direction-column" - > - <div - class="gl-max-w-full" - > - <div - class="svg-250 svg-content" - > - <img - alt="" - class="gl-max-w-full gl-dark-invert-keep-hue" - role="img" - src="emptyListIllustration" - /> - </div> - </div> - - <div - class="gl-max-w-full gl-m-auto" - > - <div - class="gl-mx-auto gl-my-0 gl-p-5" - > - <h1 - class="gl-font-size-h-display gl-line-height-36 h4" - > - - There are no packages yet - - </h1> - - <p - class="gl-mt-3" - > - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="/help/user/packages/package_registry/index" - routertag="a" - target="_blank" - > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div - class="gl-display-flex gl-flex-wrap gl-justify-content-center" - > - <!----> - - <!----> - </div> - </div> - </div> - </section> - </div> - - <div /> -</div> -`; 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 abdb875e839..b3cbd9f5dcf 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,23 +1,18 @@ -import { GlAlert, GlBanner, GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; - import VueApollo from 'vue-apollo'; -import * as utils from '~/lib/utils/common_utils'; 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'; import ListPage from '~/packages_and_registries/package_registry/pages/list.vue'; import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; -import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, GRAPHQL_PAGE_SIZE, - HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, EMPTY_LIST_HELP_URL, PACKAGE_HELP_URL, DELETE_PACKAGES_ERROR_MESSAGE, @@ -59,13 +54,11 @@ describe('PackagesListApp', () => { }; const findAlert = () => wrapper.findComponent(GlAlert); - const findBanner = () => wrapper.findComponent(GlBanner); const findPackageTitle = () => wrapper.findComponent(PackageTitle); const findSearch = () => wrapper.findComponent(PackageSearch); const findListComponent = () => wrapper.findComponent(PackageList); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findDeletePackage = () => wrapper.findComponent(DeletePackage); - const findDeletePackagesModal = () => wrapper.findComponent(DeleteModal); const mountComponent = ({ resolver = jest.fn().mockResolvedValue(packagesListQuery()), @@ -84,18 +77,12 @@ describe('PackagesListApp', () => { apolloProvider, provide, stubs: { - GlBanner, GlEmptyState, GlLoadingIcon, GlSprintf, GlLink, PackageList, DeletePackage, - DeleteModal: stubComponent(DeleteModal, { - methods: { - show: jest.fn(), - }, - }), }, }); }; @@ -118,14 +105,6 @@ describe('PackagesListApp', () => { expect(resolver).not.toHaveBeenCalled(); }); - it('renders', async () => { - mountComponent(); - - await waitForFirstRequest(); - - expect(wrapper.element).toMatchSnapshot(); - }); - it('has a package title', async () => { mountComponent(); @@ -138,70 +117,6 @@ describe('PackagesListApp', () => { }); }); - describe('package migration survey banner', () => { - describe('with no cookie set', () => { - beforeEach(() => { - utils.setCookie = jest.fn(); - - mountComponent(); - }); - - it('displays the banner', () => { - expect(findBanner().exists()).toBe(true); - }); - - it('does not call setCookie', () => { - expect(utils.setCookie).not.toHaveBeenCalled(); - }); - - describe('when the close button is clicked', () => { - beforeEach(() => { - findBanner().vm.$emit('close'); - }); - - it('sets the dismissed cookie', () => { - expect(utils.setCookie).toHaveBeenCalledWith( - HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, - 'true', - ); - }); - - it('does not display the banner', () => { - expect(findBanner().exists()).toBe(false); - }); - }); - - describe('when the primary button is clicked', () => { - beforeEach(() => { - findBanner().vm.$emit('primary'); - }); - - it('sets the dismissed cookie', () => { - expect(utils.setCookie).toHaveBeenCalledWith( - HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, - 'true', - ); - }); - - it('does not display the banner', () => { - expect(findBanner().exists()).toBe(false); - }); - }); - }); - - describe('with the dismissed cookie set', () => { - beforeEach(() => { - jest.spyOn(utils, 'getCookie').mockReturnValue('true'); - - mountComponent(); - }); - - it('does not display the banner', () => { - expect(findBanner().exists()).toBe(false); - }); - }); - }); - describe('search component', () => { it('exists', () => { mountComponent(); @@ -372,18 +287,6 @@ describe('PackagesListApp', () => { describe('bulk delete package', () => { const items = [{ id: '1' }, { id: '2' }]; - it('deletePackage is bound to package-list package:delete event', async () => { - mountComponent(); - - await waitForFirstRequest(); - - findListComponent().vm.$emit('delete', [{ id: '1' }, { id: '2' }]); - - await waitForPromises(); - - expect(findDeletePackagesModal().props('itemsToBeDeleted')).toEqual(items); - }); - it('calls mutation with the right values and shows success alert', async () => { const mutationResolver = jest.fn().mockResolvedValue(packagesDestroyMutation()); mountComponent({ @@ -394,8 +297,6 @@ describe('PackagesListApp', () => { findListComponent().vm.$emit('delete', items); - findDeletePackagesModal().vm.$emit('confirm'); - expect(mutationResolver).toHaveBeenCalledWith({ ids: items.map((item) => item.id), }); @@ -417,8 +318,6 @@ describe('PackagesListApp', () => { findListComponent().vm.$emit('delete', items); - findDeletePackagesModal().vm.$emit('confirm'); - await waitForPromises(); expect(findAlert().exists()).toBe(true); 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 1790a9c9bf5..1a157beebe4 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 @@ -1,4 +1,4 @@ -import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlEmptyState, GlLoadingIcon, GlTableLite } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; @@ -23,7 +23,9 @@ describe('BulkImportsHistoryApp', () => { id: 1, bulk_import_id: 1, status: 'finished', + entity_type: 'group', source_full_path: 'top-level-group-12', + destination_full_path: 'h5bp/top-level-group-12', destination_name: 'top-level-group-12', destination_namespace: 'h5bp', created_at: '2021-07-08T10:03:44.743Z', @@ -33,8 +35,10 @@ describe('BulkImportsHistoryApp', () => { id: 2, bulk_import_id: 2, status: 'failed', + entity_type: 'project', source_full_path: 'autodevops-demo', destination_name: 'autodevops-demo', + destination_full_path: 'some-group/autodevops-demo', destination_namespace: 'flightjs', parent_id: null, namespace_id: null, @@ -74,6 +78,7 @@ describe('BulkImportsHistoryApp', () => { beforeEach(() => { mock = new MockAdapter(axios); + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); }); afterEach(() => { @@ -97,11 +102,10 @@ describe('BulkImportsHistoryApp', () => { }); it('renders table with data when history is available', async () => { - mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent(); await axios.waitForAll(); - const table = wrapper.findComponent(GlTable); + const table = wrapper.findComponent(GlTableLite); expect(table.exists()).toBe(true); // can't use .props() or .attributes() here expect(table.vm.$attrs.items).toHaveLength(DUMMY_RESPONSE.length); @@ -110,7 +114,6 @@ describe('BulkImportsHistoryApp', () => { it('changes page when requested by pagination bar', async () => { const NEW_PAGE = 4; - mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent(); await axios.waitForAll(); mock.resetHistory(); @@ -126,7 +129,6 @@ describe('BulkImportsHistoryApp', () => { it('changes page size when requested by pagination bar', async () => { const NEW_PAGE_SIZE = 4; - mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent(); await axios.waitForAll(); mock.resetHistory(); @@ -143,7 +145,6 @@ describe('BulkImportsHistoryApp', () => { it('sets up the local storage sync correctly', async () => { const NEW_PAGE_SIZE = 4; - mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent(); await axios.waitForAll(); mock.resetHistory(); @@ -155,12 +156,37 @@ describe('BulkImportsHistoryApp', () => { }); it('renders correct url for destination group when relative_url is empty', async () => { - mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent({ shallow: false }); await axios.waitForAll(); expect(wrapper.find('tbody tr a').attributes().href).toBe( - `/${DUMMY_RESPONSE[0].destination_namespace}/${DUMMY_RESPONSE[0].destination_name}`, + `/${DUMMY_RESPONSE[0].destination_full_path}`, + ); + }); + + it('renders loading icon when destination namespace is not defined', async () => { + const RESPONSE = [{ ...DUMMY_RESPONSE[0], destination_full_path: null }]; + + mock.onGet(API_URL).reply(200, RESPONSE, DEFAULT_HEADERS); + createComponent({ shallow: false }); + await axios.waitForAll(); + + expect(wrapper.find('tbody tr').findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('adds slash to group urls', async () => { + createComponent({ shallow: false }); + await axios.waitForAll(); + + expect(wrapper.find('tbody tr a').text()).toBe(`${DUMMY_RESPONSE[0].destination_full_path}/`); + }); + + it('does not prefixes project urls with slash', async () => { + createComponent({ shallow: false }); + await axios.waitForAll(); + + expect(wrapper.findAll('tbody tr a').at(1).text()).toBe( + DUMMY_RESPONSE[1].destination_full_path, ); }); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index 9718d847ed5..aee56247209 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -33,6 +33,7 @@ describe('ForkForm component', () => { const DEFAULT_PROVIDE = { newGroupPath: 'some/groups/path', visibilityHelpPath: 'some/visibility/help/path', + cancelPath: '/some/project-full-path', projectFullPath: '/some/project-full-path', projectId: '10', projectName: 'Project Name', @@ -124,13 +125,13 @@ describe('ForkForm component', () => { const findVisibilityRadioGroup = () => wrapper.find('[data-testid="fork-visibility-radio-group"]'); - it('will go to projectFullPath when click cancel button', () => { + it('will go to cancelPath when click cancel button', () => { createComponent(); - const { projectFullPath } = DEFAULT_PROVIDE; + const { cancelPath } = DEFAULT_PROVIDE; const cancelButton = wrapper.find('[data-testid="cancel-button"]'); - expect(cancelButton.attributes('href')).toBe(projectFullPath); + expect(cancelButton.attributes('href')).toBe(cancelPath); }); const selectedMockNamespace = { @@ -463,16 +464,12 @@ describe('ForkForm component', () => { expect(urlUtility.redirectTo).not.toHaveBeenCalled(); }); - it('does not make POST request if no visbility is checked', async () => { + it('does not make POST request if no visibility is checked', async () => { jest.spyOn(axios, 'post'); - setupComponent({ - fields: { - visibility: { - value: null, - }, - }, - }); + setupComponent(); + wrapper.vm.form.fields.visibility.value = null; + await nextTick(); await submitForm(); 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 f6d3957115f..82f451ed6ef 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 @@ -1,11 +1,4 @@ -import { - GlButton, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlTruncate, -} from '@gitlab/ui'; +import { GlButton, GlListboxItem, GlCollapsibleListbox } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -80,17 +73,16 @@ describe('ProjectNamespace component', () => { }; const findButtonLabel = () => wrapper.findComponent(GlButton); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownText = () => wrapper.findComponent(GlTruncate); - const findInput = () => wrapper.findComponent(GlSearchBoxByType); + const findListBox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListBoxText = () => findListBox().props('toggleText'); - const clickDropdownItem = async () => { - wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + const clickListBoxItem = async (value = '') => { + wrapper.findComponent(GlListboxItem).vm.$emit('select', value); await nextTick(); }; const showDropdown = () => { - findDropdown().vm.$emit('shown'); + findListBox().vm.$emit('shown'); }; beforeAll(() => { @@ -115,7 +107,7 @@ describe('ProjectNamespace component', () => { }); it('renders placeholder text', () => { - expect(findDropdownText().props('text')).toBe('Select a namespace'); + expect(findListBoxText()).toBe('Select a namespace'); }); }); @@ -127,24 +119,18 @@ describe('ProjectNamespace component', () => { showDropdown(); }); - it('focuses on the input when the dropdown is opened', () => { - const spy = jest.spyOn(findInput().vm, 'focusInput'); - showDropdown(); - expect(spy).toHaveBeenCalledTimes(1); - }); - it('displays fetched namespaces', () => { const listItems = wrapper.findAll('li'); - expect(listItems).toHaveLength(3); - expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Namespaces'); - expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[0].fullPath); - expect(listItems.at(2).text()).toBe(data.project.forkTargets.nodes[1].fullPath); + expect(listItems).toHaveLength(2); + expect(listItems.at(0).text()).toBe(data.project.forkTargets.nodes[0].fullPath); + expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[1].fullPath); }); it('sets the selected namespace', async () => { const { fullPath } = data.project.forkTargets.nodes[0]; - await clickDropdownItem(); - expect(findDropdownText().props('text')).toBe(fullPath); + await clickListBoxItem(fullPath); + + expect(findListBoxText()).toBe(fullPath); }); }); @@ -155,7 +141,7 @@ describe('ProjectNamespace component', () => { }); it('renders `No matches found`', () => { - expect(wrapper.find('li').text()).toBe('No matches found'); + expect(findListBox().text()).toContain('No matches found'); }); }); diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index e7c7ec0d336..d67f842d011 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -45,6 +45,7 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] toggletext="rspec" variant="default" > + <!----> <!----> @@ -57,22 +58,31 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] tabindex="-1" > <gl-listbox-item-stub + data-testid="listbox-item-0" isselected="true" > rspec </gl-listbox-item-stub> - <gl-listbox-item-stub> + <gl-listbox-item-stub + data-testid="listbox-item-1" + > cypress </gl-listbox-item-stub> - <gl-listbox-item-stub> + <gl-listbox-item-stub + data-testid="listbox-item-2" + > karma </gl-listbox-item-stub> + + <!----> + + <!----> </ul> <!----> diff --git a/spec/frontend/pages/projects/graphs/code_coverage_spec.js b/spec/frontend/pages/projects/graphs/code_coverage_spec.js index e99734963e3..2ff45266a07 100644 --- a/spec/frontend/pages/projects/graphs/code_coverage_spec.js +++ b/spec/frontend/pages/projects/graphs/code_coverage_spec.js @@ -6,7 +6,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import CodeCoverage from '~/pages/projects/graphs/components/code_coverage.vue'; import { codeCoverageMockData, sortedDataByDates } from './mock_data'; @@ -49,7 +49,7 @@ describe('Code Coverage', () => { describe('when fetching data is successful', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - mockAxios.onGet().replyOnce(httpStatusCodes.OK, codeCoverageMockData); + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, codeCoverageMockData); createComponent(); @@ -84,7 +84,7 @@ describe('Code Coverage', () => { describe('when fetching data fails', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - mockAxios.onGet().replyOnce(httpStatusCodes.BAD_REQUEST); + mockAxios.onGet().replyOnce(HTTP_STATUS_BAD_REQUEST); createComponent(); @@ -108,7 +108,7 @@ describe('Code Coverage', () => { describe('when fetching data succeed but returns an empty state', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - mockAxios.onGet().replyOnce(httpStatusCodes.OK, []); + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, []); createComponent(); @@ -136,7 +136,7 @@ describe('Code Coverage', () => { describe('dropdown options', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - mockAxios.onGet().replyOnce(httpStatusCodes.OK, codeCoverageMockData); + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, codeCoverageMockData); createComponent(); @@ -153,7 +153,7 @@ describe('Code Coverage', () => { describe('interactions', () => { beforeEach(() => { mockAxios = new MockAdapter(axios); - mockAxios.onGet().replyOnce(httpStatusCodes.OK, codeCoverageMockData); + mockAxios.onGet().replyOnce(HTTP_STATUS_OK, codeCoverageMockData); createComponent(); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js index 897cbf5eaa4..29335308370 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js @@ -85,6 +85,9 @@ describe('Learn GitLab Section Link', () => { it('renders a popover trigger with question icon', () => { expect(findPopoverTrigger().exists()).toBe(true); expect(findPopoverTrigger().props('icon')).toBe('question-o'); + expect(findPopoverTrigger().attributes('aria-label')).toBe( + LearnGitlabSectionLink.i18n.contactAdmin, + ); }); it('renders a popover', () => { @@ -95,6 +98,15 @@ describe('Learn GitLab Section Link', () => { }); }); + it('renders default disabled message', () => { + expect(findPopover().text()).toContain(LearnGitlabSectionLink.i18n.contactAdmin); + }); + + it('renders custom disabled message if provided', () => { + createWrapper('trialStarted', { enabled: false, message: 'Custom message' }); + expect(findPopover().text()).toContain('Custom message'); + }); + it('renders a link inside the popover', () => { expect(findPopoverLink().exists()).toBe(true); expect(findPopoverLink().attributes('href')).toBe(defaultProps.url); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js index 99df5b58d90..2d3b9afa8f6 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js @@ -138,7 +138,7 @@ describe('Interval Pattern Input Component', () => { 'Every day (at 4:00am)', 'Every week (Monday at 4:00am)', 'Every month (Day 1 at 4:00am)', - 'Custom ( Cron syntax )', + 'Custom ( Learn more. )', ]); }); }); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js index 7c9aae13d25..c8e9a31b526 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js @@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import WikiContent from '~/pages/shared/wikis/components/wiki_content.vue'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; import { handleLocationHash } from '~/lib/utils/common_utils'; @@ -59,7 +59,7 @@ describe('pages/shared/wikis/components/wiki_content', () => { const content = 'content'; beforeEach(() => { - mock.onGet(PATH, { params: { render_html: true } }).replyOnce(httpStatus.OK, { content }); + mock.onGet(PATH, { params: { render_html: true } }).replyOnce(HTTP_STATUS_OK, { content }); buildWrapper(); return waitForPromises(); }); @@ -88,7 +88,7 @@ describe('pages/shared/wikis/components/wiki_content', () => { describe('when loading content fails', () => { beforeEach(() => { - mock.onGet(PATH).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, ''); + mock.onGet(PATH).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR, ''); buildWrapper(); return waitForPromises(); }); diff --git a/spec/frontend/pipeline_new/utils/format_refs_spec.js b/spec/frontend/pipeline_new/utils/format_refs_spec.js deleted file mode 100644 index 71190f55c16..00000000000 --- a/spec/frontend/pipeline_new/utils/format_refs_spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '~/pipeline_new/constants'; -import formatRefs from '~/pipeline_new/utils/format_refs'; -import { mockBranchRefs, mockTagRefs } from '../mock_data'; - -describe('Format refs util', () => { - it('formats branch ref correctly', () => { - expect(formatRefs(mockBranchRefs, BRANCH_REF_TYPE)).toEqual([ - { fullName: 'refs/heads/main', shortName: 'main' }, - { fullName: 'refs/heads/dev', shortName: 'dev' }, - { fullName: 'refs/heads/release', shortName: 'release' }, - ]); - }); - - it('formats tag ref correctly', () => { - expect(formatRefs(mockTagRefs, TAG_REF_TYPE)).toEqual([ - { fullName: 'refs/tags/1.0.0', shortName: '1.0.0' }, - { fullName: 'refs/tags/1.1.0', shortName: '1.1.0' }, - { fullName: 'refs/tags/1.2.0', shortName: '1.2.0' }, - ]); - }); -}); diff --git a/spec/frontend/pipeline_wizard/components/wrapper_spec.js b/spec/frontend/pipeline_wizard/components/wrapper_spec.js index d5b78cebcb3..33c6394eb41 100644 --- a/spec/frontend/pipeline_wizard/components/wrapper_spec.js +++ b/spec/frontend/pipeline_wizard/components/wrapper_spec.js @@ -364,6 +364,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { extra: { fromStep: 0, toStep: 1, + features: expect.any(Object), }, }); }); @@ -386,6 +387,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { extra: { fromStep: 1, toStep: 0, + features: expect.any(Object), }, }); }); @@ -409,6 +411,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { extra: { fromStep: 2, toStep: 1, + features: expect.any(Object), }, }); }); @@ -429,6 +432,9 @@ describe('Pipeline Wizard - wrapper.vue', () => { category: trackingCategory, label: 'pipeline_wizard_commit', property: 'commit', + extra: { + features: expect.any(Object), + }, }); }); @@ -443,6 +449,7 @@ describe('Pipeline Wizard - wrapper.vue', () => { label: 'pipeline_wizard_editor_interaction', extra: { currentStep: 0, + features: expect.any(Object), }, }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index a3f15e25f36..351572fc83a 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -71,7 +71,7 @@ describe('Pipelines', () => { const findTablePagination = () => wrapper.findComponent(TablePagination); const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); - const findPipelineKeyDropdown = () => wrapper.findByTestId('pipeline-key-dropdown'); + const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box'); const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); @@ -545,8 +545,8 @@ describe('Pipelines', () => { expect(findFilteredSearch().exists()).toBe(true); }); - it('renders the pipeline key dropdown', () => { - expect(findPipelineKeyDropdown().exists()).toBe(true); + it('renders the pipeline key collapsible box', () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); }); it('renders tab empty state finished scope', async () => { @@ -578,7 +578,7 @@ describe('Pipelines', () => { }); it('does not render the pipeline key dropdown', () => { - expect(findPipelineKeyDropdown().exists()).toBe(false); + expect(findPipelineKeyCollapsibleBox().exists()).toBe(false); }); it('does not render tabs nor buttons', () => { diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 740037a5ac8..9359bd9b95f 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -17,7 +17,7 @@ import { TRACKING_CATEGORIES, } from '~/pipelines/constants'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; jest.mock('~/pipelines/event_hub'); @@ -50,7 +50,7 @@ describe('Pipelines Table', () => { }; const findGlTableLite = () => wrapper.findComponent(GlTableLite); - const findStatusBadge = () => wrapper.findComponent(CiBadge); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); @@ -97,7 +97,7 @@ describe('Pipelines Table', () => { describe('status cell', () => { it('should render a status badge', () => { - expect(findStatusBadge().exists()).toBe(true); + expect(findCiBadgeLink().exists()).toBe(true); }); }); @@ -171,7 +171,7 @@ describe('Pipelines Table', () => { }); it('tracks status badge click', () => { - findStatusBadge().vm.$emit('ciStatusBadgeClick'); + findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { label: TRACKING_CATEGORIES.table, diff --git a/spec/frontend/projects/commit/components/branches_dropdown_spec.js b/spec/frontend/projects/commit/components/branches_dropdown_spec.js index a84dd246f5d..7334e007e18 100644 --- a/spec/frontend/projects/commit/components/branches_dropdown_spec.js +++ b/spec/frontend/projects/commit/components/branches_dropdown_spec.js @@ -1,9 +1,8 @@ -import { GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import BranchesDropdown from '~/projects/commit/components/branches_dropdown.vue'; Vue.use(Vuex); @@ -34,12 +33,7 @@ describe('BranchesDropdown', () => { }), ); }; - - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); - const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); - const findNoResults = () => wrapper.findByTestId('empty-result-message'); - const findLoading = () => wrapper.findByTestId('dropdown-text-loading-icon'); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); afterEach(() => { wrapper.destroy(); @@ -55,72 +49,6 @@ describe('BranchesDropdown', () => { it('invokes fetchBranches', () => { expect(spyFetchBranches).toHaveBeenCalled(); }); - - describe('with a value but visually blanked', () => { - beforeEach(() => { - createComponent({ value: '_main_', blanked: true }, { branch: '_main_' }); - }); - - it('renders all branches', () => { - expect(findAllDropdownItems()).toHaveLength(3); - expect(findDropdownItemByIndex(0).text()).toBe('_main_'); - expect(findDropdownItemByIndex(1).text()).toBe('_branch_1_'); - expect(findDropdownItemByIndex(2).text()).toBe('_branch_2_'); - }); - - it('selects the active branch', () => { - expect(wrapper.vm.isSelected('_main_')).toBe(true); - }); - }); - }); - - describe('Loading states', () => { - it('shows loading icon while fetching', () => { - createComponent({ value: '' }, { isFetching: true }); - - expect(findLoading().isVisible()).toBe(true); - }); - - it('does not show loading icon', () => { - createComponent({ value: '' }); - - expect(findLoading().isVisible()).toBe(false); - }); - }); - - describe('No branches found', () => { - beforeEach(() => { - createComponent({ value: '_non_existent_branch_' }); - }); - - it('renders empty results message', () => { - expect(findNoResults().text()).toBe('No matching results'); - }); - - it('shows GlSearchBoxByType with default attributes', () => { - expect(findSearchBoxByType().exists()).toBe(true); - expect(findSearchBoxByType().vm.$attrs).toMatchObject({ - placeholder: 'Search branches', - debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS, - }); - }); - }); - - describe('Search term is empty', () => { - beforeEach(() => { - createComponent({ value: '' }); - }); - - it('renders all branches when search term is empty', () => { - expect(findAllDropdownItems()).toHaveLength(3); - expect(findDropdownItemByIndex(0).text()).toBe('_main_'); - expect(findDropdownItemByIndex(1).text()).toBe('_branch_1_'); - expect(findDropdownItemByIndex(2).text()).toBe('_branch_2_'); - }); - - it('should not be selected on the inactive branch', () => { - expect(wrapper.vm.isSelected('_main_')).toBe(false); - }); }); describe('When searching', () => { @@ -131,7 +59,7 @@ describe('BranchesDropdown', () => { it('invokes fetchBranches', async () => { const spy = jest.spyOn(wrapper.vm, 'fetchBranches'); - findSearchBoxByType().vm.$emit('input', '_anything_'); + findDropdown().vm.$emit('search', '_anything_'); await nextTick(); @@ -140,46 +68,13 @@ describe('BranchesDropdown', () => { }); }); - describe('Branches found', () => { - beforeEach(() => { - createComponent({ value: '_branch_1_' }, { branch: '_branch_1_' }); - }); - - it('renders only the branch searched for', () => { - expect(findAllDropdownItems()).toHaveLength(1); - expect(findDropdownItemByIndex(0).text()).toBe('_branch_1_'); - }); - - it('should not display empty results message', () => { - expect(findNoResults().exists()).toBe(false); - }); - - it('should signify this branch is selected', () => { - expect(wrapper.vm.isSelected('_branch_1_')).toBe(true); - }); - - it('should signify the branch is not selected', () => { - expect(wrapper.vm.isSelected('_not_selected_branch_')).toBe(false); - }); - - describe('Custom events', () => { - it('should emit selectBranch if an branch is clicked', () => { - findDropdownItemByIndex(0).vm.$emit('click'); - - expect(wrapper.emitted('selectBranch')).toEqual([['_branch_1_']]); - expect(wrapper.vm.searchTerm).toBe('_branch_1_'); - }); - }); - }); - describe('Case insensitive for search term', () => { beforeEach(() => { createComponent({ value: '_BrAnCh_1_' }); }); - it('renders only the branch searched for', () => { - expect(findAllDropdownItems()).toHaveLength(1); - expect(findDropdownItemByIndex(0).text()).toBe('_branch_1_'); + it('returns only the branch searched for', () => { + expect(findDropdown().props('items')).toEqual([{ text: '_branch_1_', value: '_branch_1_' }]); }); }); }); diff --git a/spec/frontend/projects/commit/components/projects_dropdown_spec.js b/spec/frontend/projects/commit/components/projects_dropdown_spec.js index bb20918e0cd..0e213ff388a 100644 --- a/spec/frontend/projects/commit/components/projects_dropdown_spec.js +++ b/spec/frontend/projects/commit/components/projects_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -35,78 +35,23 @@ describe('ProjectsDropdown', () => { ); }; - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); - const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); - const findNoResults = () => wrapper.findByTestId('empty-result-message'); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); afterEach(() => { wrapper.destroy(); spyFetchProjects.mockReset(); }); - describe('No projects found', () => { - beforeEach(() => { - createComponent('_non_existent_project_'); - }); - - it('renders empty results message', () => { - expect(findNoResults().text()).toBe('No matching results'); - }); - - it('shows GlSearchBoxByType with default attributes', () => { - expect(findSearchBoxByType().exists()).toBe(true); - expect(findSearchBoxByType().vm.$attrs).toMatchObject({ - placeholder: 'Search projects', - }); - }); - }); - - describe('Search term is empty', () => { - beforeEach(() => { - createComponent(''); - }); - - it('renders all projects when search term is empty', () => { - expect(findAllDropdownItems()).toHaveLength(3); - expect(findDropdownItemByIndex(0).text()).toBe('_project_1_'); - expect(findDropdownItemByIndex(1).text()).toBe('_project_2_'); - expect(findDropdownItemByIndex(2).text()).toBe('_project_3_'); - }); - - it('should not be selected on the inactive project', () => { - expect(wrapper.vm.isSelected('_project_1_')).toBe(false); - }); - }); - describe('Projects found', () => { beforeEach(() => { createComponent('_project_1_', { targetProjectId: '1' }); }); - it('renders only the project searched for', () => { - expect(findAllDropdownItems()).toHaveLength(1); - expect(findDropdownItemByIndex(0).text()).toBe('_project_1_'); - }); - - it('should not display empty results message', () => { - expect(findNoResults().exists()).toBe(false); - }); - - it('should signify this project is selected', () => { - expect(findDropdownItemByIndex(0).props('isChecked')).toBe(true); - }); - - it('should signify the project is not selected', () => { - expect(wrapper.vm.isSelected('_not_selected_project_')).toBe(false); - }); - describe('Custom events', () => { it('should emit selectProject if a project is clicked', () => { - findDropdownItemByIndex(0).vm.$emit('click'); + findDropdown().vm.$emit('select', '1'); expect(wrapper.emitted('selectProject')).toEqual([['1']]); - expect(wrapper.vm.filterTerm).toBe('_project_1_'); }); }); }); @@ -117,8 +62,7 @@ describe('ProjectsDropdown', () => { }); it('renders only the project searched for', () => { - expect(findAllDropdownItems()).toHaveLength(1); - expect(findDropdownItemByIndex(0).text()).toBe('_project_1_'); + expect(findDropdown().props('items')).toEqual([{ text: '_project_1_', value: '1' }]); }); }); }); diff --git a/spec/frontend/projects/merge_requests/components/report_abuse_dropdown_item_spec.js b/spec/frontend/projects/merge_requests/components/report_abuse_dropdown_item_spec.js new file mode 100644 index 00000000000..35b10375821 --- /dev/null +++ b/spec/frontend/projects/merge_requests/components/report_abuse_dropdown_item_spec.js @@ -0,0 +1,73 @@ +import { nextTick } from 'vue'; +import { GlDropdownItem } from '@gitlab/ui'; +import { MountingPortal } from 'portal-vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ReportAbuseDropdownItem from '~/projects/merge_requests/components/report_abuse_dropdown_item.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +describe('ReportAbuseDropdownItem', () => { + let wrapper; + + const ACTION_PATH = '/abuse_reports/add_category'; + const USER_ID = '1'; + const REPORTED_FROM_URL = 'http://example.com'; + + const createComponent = (props) => { + wrapper = shallowMountExtended(ReportAbuseDropdownItem, { + propsData: { + ...props, + }, + provide: { + reportAbusePath: ACTION_PATH, + reportedUserId: USER_ID, + reportedFromUrl: REPORTED_FROM_URL, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + const findReportAbuseItem = () => wrapper.findComponent(GlDropdownItem); + const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); + const findMountingPortal = () => wrapper.findComponent(MountingPortal); + + it('renders report abuse dropdown item', () => { + expect(findReportAbuseItem().text()).toBe(ReportAbuseDropdownItem.i18n.reportAbuse); + }); + + it('renders abuse category selector with the drawer initially closed', () => { + expect(findAbuseCategorySelector().exists()).toBe(true); + + expect(findAbuseCategorySelector().props('showDrawer')).toBe(false); + }); + + it('renders abuse category selector inside MountingPortal', () => { + expect(findMountingPortal().props()).toMatchObject({ + mountTo: '#js-report-abuse-drawer', + append: true, + name: 'abuse-category-selector', + }); + }); + + describe('when dropdown item is clicked', () => { + beforeEach(() => { + findReportAbuseItem().vm.$emit('click'); + return nextTick(); + }); + + it('opens the abuse category selector', () => { + expect(findAbuseCategorySelector().props('showDrawer')).toBe(true); + }); + + it('closes the abuse category selector', async () => { + findAbuseCategorySelector().vm.$emit('close-drawer'); + + await nextTick(); + + expect(findAbuseCategorySelector().props('showDrawer')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js index 8c18d2992ea..cf28eda5349 100644 --- a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js @@ -5,25 +5,32 @@ import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_a import SegmentedControlButtonGroup from '~/vue_shared/components/segmented_control_button_group.vue'; import { transformedAreaChartData, chartOptions } from '../mock_data'; +const charts = [ + { + range: 'test range 1', + title: 'title 1', + data: transformedAreaChartData, + }, + { + range: 'test range 2', + title: 'title 2', + data: transformedAreaChartData, + }, + { + range: 'test range 3', + title: 'title 3', + data: transformedAreaChartData, + }, + { + range: 'test range 4', + title: 'title 4', + data: transformedAreaChartData, + }, +]; + const DEFAULT_PROPS = { chartOptions, - charts: [ - { - range: 'test range 1', - title: 'title 1', - data: transformedAreaChartData, - }, - { - range: 'test range 2', - title: 'title 2', - data: transformedAreaChartData, - }, - { - range: 'test range 3', - title: 'title 3', - data: transformedAreaChartData, - }, - ], + charts, }; describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', () => { @@ -55,13 +62,13 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', ( wrapper = createWrapper(); }); - it('should default to the first chart', () => { - expect(findSegmentedControl().props('value')).toBe(0); + it('should default to the 3rd chart (last 90 days)', () => { + expect(findSegmentedControl().props('value')).toBe(2); }); it('should use the title and index as values', () => { const options = findSegmentedControl().props('options'); - expect(options).toHaveLength(3); + expect(options).toHaveLength(charts.length); expect(options).toEqual([ { text: 'title 1', @@ -75,6 +82,10 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', ( text: 'title 3', value: 2, }, + { + text: 'title 4', + value: 3, + }, ]); }); diff --git a/spec/frontend/projects/settings/components/default_branch_selector_spec.js b/spec/frontend/projects/settings/components/default_branch_selector_spec.js index 94648d87524..bfbf3e234f4 100644 --- a/spec/frontend/projects/settings/components/default_branch_selector_spec.js +++ b/spec/frontend/projects/settings/components/default_branch_selector_spec.js @@ -32,6 +32,7 @@ describe('projects/settings/components/default_branch_selector', () => { value: persistedDefaultBranch, enabledRefTypes: [REF_TYPE_BRANCHES], projectId, + refType: null, state: true, translations: { dropdownHeader: expect.any(String), diff --git a/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js b/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js index 49c45c080b4..8d0fd390e35 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js +++ b/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js @@ -20,6 +20,7 @@ describe('Branch rule', () => { }; const findDefaultBadge = () => wrapper.findByText(i18n.defaultLabel); + const findProtectedBadge = () => wrapper.findByText(i18n.protectedLabel); const findBranchName = () => wrapper.findByText(branchRulePropsMock.name); const findProtectionDetailsList = () => wrapper.findByRole('list'); const findProtectionDetailsListItems = () => wrapper.findAllByRole('listitem'); @@ -32,17 +33,23 @@ describe('Branch rule', () => { }); describe('badges', () => { - it('renders default badge', () => { + it('renders both default and protected badges', () => { expect(findDefaultBadge().exists()).toBe(true); + expect(findProtectedBadge().exists()).toBe(true); }); it('does not render default badge if isDefault is set to false', () => { createComponent({ isDefault: false }); expect(findDefaultBadge().exists()).toBe(false); }); + + it('does not render default badge if branchProtection is null', () => { + createComponent(branchRuleWithoutDetailsPropsMock); + expect(findProtectedBadge().exists()).toBe(false); + }); }); - it('does not render the protection details list if no details are present', () => { + it('does not render the protection details list when branchProtection is null', () => { createComponent(branchRuleWithoutDetailsPropsMock); expect(findProtectionDetailsList().exists()).toBe(false); }); diff --git a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js index 6f506882c36..de7f6c8b88d 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js +++ b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js @@ -92,10 +92,7 @@ export const branchRuleWithoutDetailsPropsMock = { name: 'branch-1', isDefault: false, matchingBranchesCount: 1, - branchProtection: { - allowForcePush: false, - codeOwnerApprovalRequired: false, - }, + branchProtection: null, approvalRulesTotal: 0, statusChecksTotal: 0, }; 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 13f3eea277a..5fc9f9ba629 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 @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import ServiceDeskRoot from '~/projects/settings_service_desk/components/service_desk_root.vue'; import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue'; @@ -95,7 +95,7 @@ describe('ServiceDeskRoot', () => { }); it('sends a request to turn service desk on', () => { - axiosMock.onPut(provideData.endpoint).replyOnce(httpStatusCodes.OK); + axiosMock.onPut(provideData.endpoint).replyOnce(HTTP_STATUS_OK); expect(spy).toHaveBeenCalledWith(provideData.endpoint, { service_desk_enabled: true }); }); @@ -117,7 +117,7 @@ describe('ServiceDeskRoot', () => { }); it('sends a request to turn service desk off', () => { - axiosMock.onPut(provideData.endpoint).replyOnce(httpStatusCodes.OK); + axiosMock.onPut(provideData.endpoint).replyOnce(HTTP_STATUS_OK); expect(spy).toHaveBeenCalledWith(provideData.endpoint, { service_desk_enabled: false }); }); @@ -133,7 +133,7 @@ describe('ServiceDeskRoot', () => { describe('save event', () => { describe('successful request', () => { beforeEach(async () => { - axiosMock.onPut(provideData.endpoint).replyOnce(httpStatusCodes.OK); + axiosMock.onPut(provideData.endpoint).replyOnce(HTTP_STATUS_OK); wrapper = createComponent(); diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js index 80d7c941660..9eddc50d50a 100644 --- a/spec/frontend/read_more_spec.js +++ b/spec/frontend/read_more_spec.js @@ -1,21 +1,23 @@ -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { loadHTMLFixture, resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; import initReadMore from '~/read_more'; describe('Read more click-to-expand functionality', () => { const fixtureName = 'projects/overview.html'; - beforeEach(() => { - loadHTMLFixture(fixtureName); - }); + const findTrigger = () => document.querySelector('.js-read-more-trigger'); afterEach(() => { resetHTMLFixture(); }); describe('expands target element', () => { + beforeEach(() => { + loadHTMLFixture(fixtureName); + }); + it('adds "is-expanded" class to target element', () => { const target = document.querySelector('.read-more-container'); - const trigger = document.querySelector('.js-read-more-trigger'); + const trigger = findTrigger(); initReadMore(); trigger.click(); @@ -23,4 +25,25 @@ describe('Read more click-to-expand functionality', () => { expect(target.classList.contains('is-expanded')).toEqual(true); }); }); + + describe('given click on nested element', () => { + beforeEach(() => { + setHTMLFixture(` + <p>Target</p> + <button type="button" class="js-read-more-trigger"> + <span>Button text</span> + </button> + `); + + const trigger = findTrigger(); + const nestedElement = trigger.firstElementChild; + initReadMore(); + + nestedElement.click(); + }); + + it('removes the trigger element', async () => { + 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 96601a729b2..4997c13bbb2 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -18,6 +18,8 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS, + BRANCH_REF_TYPE, + TAG_REF_TYPE, } from '~/ref/constants'; import createStore from '~/ref/stores/'; @@ -34,7 +36,7 @@ describe('Ref selector component', () => { let commitApiCallSpy; let requestSpies; - const createComponent = (mountOverrides = {}) => { + const createComponent = (mountOverrides = {}, propsData = {}) => { wrapper = mount( RefSelector, merge( @@ -42,6 +44,7 @@ describe('Ref selector component', () => { propsData: { projectId, value: '', + ...propsData, }, listeners: { // simulate a parent component v-model binding @@ -338,13 +341,14 @@ describe('Ref selector component', () => { describe('branches', () => { describe('when the branches search returns results', () => { beforeEach(() => { - createComponent(); + createComponent({}, { refType: BRANCH_REF_TYPE, useSymbolicRefNames: true }); return waitForRequests(); }); it('renders the branches section in the dropdown', () => { expect(findBranchesSection().exists()).toBe(true); + expect(findBranchesSection().props('shouldShowCheck')).toBe(true); }); it('renders the "Branches" heading with a total number indicator', () => { @@ -415,13 +419,14 @@ describe('Ref selector component', () => { describe('tags', () => { describe('when the tags search returns results', () => { beforeEach(() => { - createComponent(); + createComponent({}, { refType: TAG_REF_TYPE, useSymbolicRefNames: true }); return waitForRequests(); }); it('renders the tags section in the dropdown', () => { expect(findTagsSection().exists()).toBe(true); + expect(findTagsSection().props('shouldShowCheck')).toBe(true); }); it('renders the "Tags" heading with a total number indicator', () => { diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js index de7c56f239a..e56975d021a 100644 --- a/spec/frontend/repository/commits_service_spec.js +++ b/spec/frontend/repository/commits_service_spec.js @@ -1,9 +1,10 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { createAlert } from '~/flash'; import { I18N_COMMIT_DATA_FETCH_ERROR } from '~/repository/constants'; +import { refWithSpecialCharMock } from './mock_data'; jest.mock('~/flash'); @@ -14,7 +15,7 @@ describe('commits service', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(url).reply(httpStatus.OK, [], {}); + mock.onGet(url).reply(HTTP_STATUS_OK, [], {}); jest.spyOn(axios, 'get'); }); @@ -39,10 +40,12 @@ describe('commits service', () => { expect(axios.get).toHaveBeenCalledWith(testUrl, { params: { format: 'json', offset } }); }); - it('encodes the path correctly', async () => { - await requestCommits(1, 'some-project', 'with $peci@l ch@rs/'); + it('encodes the path and ref', async () => { + const encodedRef = encodeURIComponent(refWithSpecialCharMock); + const encodedUrl = `/some-project/-/refs/${encodedRef}/logs_tree/with%20%24peci%40l%20ch%40rs%2F`; + + await requestCommits(1, 'some-project', 'with $peci@l ch@rs/', refWithSpecialCharMock); - const encodedUrl = '/some-project/-/refs/main/logs_tree/with%20%24peci%40l%20ch%40rs%2F'; expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything()); }); @@ -68,7 +71,7 @@ describe('commits service', () => { it('calls `createAlert` when the request fails', async () => { const invalidPath = '/#@ some/path'; const invalidUrl = `${url}${invalidPath}`; - mock.onGet(invalidUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, [], {}); + mock.onGet(invalidUrl).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR, [], {}); await requestCommits(1, 'my-project', invalidPath); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 6ece72c41bb..2e8860f67ef 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -25,7 +25,7 @@ import CodeIntelligence from '~/code_navigation/components/app.vue'; import * as urlUtility from '~/lib/utils/url_utility'; import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import LineHighlighter from '~/blob/line_highlighter'; import { LEGACY_FILE_TYPES } from '~/repository/constants'; import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants'; @@ -256,19 +256,19 @@ describe('Blob content viewer component', () => { ); it('loads the LineHighlighter', async () => { - mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test'); + mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, 'test'); await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } }); expect(LineHighlighter).toHaveBeenCalled(); }); it('does not load the LineHighlighter for RichViewers', async () => { - mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test'); + mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, 'test'); await createComponent({ blob: { ...richViewerMock, fileType, highlightJs } }); expect(LineHighlighter).not.toHaveBeenCalled(); }); it('scrolls to the hash', async () => { - mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test'); + mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, 'test'); await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } }); expect(handleLocationHash).toHaveBeenCalled(); }); @@ -368,7 +368,7 @@ describe('Blob content viewer component', () => { it('does not load a CodeIntelligence component when no viewers are loaded', async () => { const url = 'some_file.js?format=json&viewer=rich'; - mockAxios.onGet(url).replyOnce(httpStatusCodes.INTERNAL_SERVER_ERROR); + mockAxios.onGet(url).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); await createComponent({ blob: { ...richViewerMock, fileType: 'unknown' } }); expect(findCodeIntelligence().exists()).toBe(false); diff --git a/spec/frontend/repository/components/blob_viewers/notebook_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/notebook_viewer_spec.js new file mode 100644 index 00000000000..51f3d31ec72 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/notebook_viewer_spec.js @@ -0,0 +1,40 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import NotebookViewer from '~/repository/components/blob_viewers/notebook_viewer.vue'; +import notebookLoader from '~/blob/notebook'; + +jest.mock('~/blob/notebook'); + +describe('Notebook Viewer', () => { + let wrapper; + + const ROOT_RELATIVE_PATH = '/some/notebook/'; + const DEFAULT_BLOB_DATA = { rawPath: `${ROOT_RELATIVE_PATH}file.ipynb` }; + + const createComponent = () => { + wrapper = shallowMountExtended(NotebookViewer, { + propsData: { blob: DEFAULT_BLOB_DATA }, + }); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findNotebookWrapper = () => wrapper.findByTestId('notebook'); + + beforeEach(() => createComponent()); + + it('calls the notebook loader', () => { + expect(notebookLoader).toHaveBeenCalledWith({ + el: wrapper.vm.$refs.viewer, + relativeRawPath: ROOT_RELATIVE_PATH, + }); + }); + + it('renders a loading icon component', () => { + expect(findLoadingIcon().props('size')).toBe('lg'); + }); + + it('renders the notebook wrapper', () => { + expect(findNotebookWrapper().exists()).toBe(true); + expect(findNotebookWrapper().attributes('data-endpoint')).toBe(DEFAULT_BLOB_DATA.rawPath); + }); +}); diff --git a/spec/frontend/repository/components/blob_viewers/openapi_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/openapi_viewer_spec.js new file mode 100644 index 00000000000..21994d04076 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/openapi_viewer_spec.js @@ -0,0 +1,30 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import OpenapiViewer from '~/repository/components/blob_viewers/openapi_viewer.vue'; +import renderOpenApi from '~/blob/openapi'; + +jest.mock('~/blob/openapi'); + +describe('OpenAPI Viewer', () => { + let wrapper; + + const DEFAULT_BLOB_DATA = { rawPath: 'some/openapi.yml' }; + + const createOpenApiViewer = () => { + wrapper = shallowMountExtended(OpenapiViewer, { + propsData: { blob: DEFAULT_BLOB_DATA }, + }); + }; + + const findOpenApiViewer = () => wrapper.findByTestId('openapi'); + + beforeEach(() => createOpenApiViewer()); + + it('calls the openapi render', () => { + expect(renderOpenApi).toHaveBeenCalledWith(wrapper.vm.$refs.viewer); + }); + + it('renders an openapi viewer', () => { + expect(findOpenApiViewer().exists()).toBe(true); + expect(findOpenApiViewer().attributes('data-endpoint')).toBe(DEFAULT_BLOB_DATA.rawPath); + }); +}); diff --git a/spec/frontend/repository/components/fork_info_spec.js b/spec/frontend/repository/components/fork_info_spec.js new file mode 100644 index 00000000000..c23d5ae5823 --- /dev/null +++ b/spec/frontend/repository/components/fork_info_spec.js @@ -0,0 +1,122 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlSkeletonLoader, GlIcon, GlLink } 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 { createAlert } from '~/flash'; + +import ForkInfo, { i18n } from '~/repository/components/fork_info.vue'; +import forkDetailsQuery from '~/repository/queries/fork_details.query.graphql'; +import { propsForkInfo } from '../mock_data'; + +jest.mock('~/flash'); + +describe('ForkInfo component', () => { + let wrapper; + let mockResolver; + const forkInfoError = new Error('Something went wrong'); + + Vue.use(VueApollo); + + const createCommitData = ({ ahead = 3, behind = 7 }) => { + return { + data: { + project: { id: '1', forkDetails: { ahead, behind, __typename: 'ForkDetails' } }, + }, + }; + }; + + const createComponent = (props = {}, data = {}, isRequestFailed = false) => { + mockResolver = isRequestFailed + ? jest.fn().mockRejectedValue(forkInfoError) + : jest.fn().mockResolvedValue(createCommitData(data)); + + wrapper = shallowMountExtended(ForkInfo, { + apolloProvider: createMockApollo([[forkDetailsQuery, mockResolver]]), + propsData: { ...propsForkInfo, ...props }, + }); + return waitForPromises(); + }; + + const findLink = () => wrapper.findComponent(GlLink); + const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); + const findIcon = () => wrapper.findComponent(GlIcon); + const findDivergenceMessage = () => wrapper.find('.gl-text-secondary'); + const findInaccessibleMessage = () => wrapper.findByTestId('inaccessible-project'); + it('displays a skeleton while loading data', async () => { + createComponent(); + expect(findSkeleton().exists()).toBe(true); + }); + + it('does not display skeleton when data is loaded', async () => { + await createComponent(); + expect(findSkeleton().exists()).toBe(false); + }); + + it('renders fork icon', async () => { + await createComponent(); + expect(findIcon().exists()).toBe(true); + }); + + it('queries the data when sourceName is present', async () => { + await createComponent(); + expect(mockResolver).toHaveBeenCalled(); + }); + + it('does not query the data when sourceName is empty', async () => { + await createComponent({ sourceName: null }); + expect(mockResolver).not.toHaveBeenCalled(); + }); + + it('renders inaccessible message when fork source is not available', async () => { + await createComponent({ sourceName: '' }); + const message = findInaccessibleMessage(); + expect(message.exists()).toBe(true); + expect(message.text()).toBe(i18n.inaccessibleProject); + }); + + it('shows source project name with a link to a repo', async () => { + await createComponent(); + const link = findLink(); + expect(link.text()).toBe(propsForkInfo.sourceName); + expect(link.attributes('href')).toBe(propsForkInfo.sourcePath); + }); + + it('renders unknown divergence message when divergence is unknown', async () => { + await createComponent({}, { ahead: null, behind: null }); + expect(findDivergenceMessage().text()).toBe(i18n.unknown); + }); + + it('shows correct divergence message when data is present', async () => { + await createComponent(); + expect(findDivergenceMessage().text()).toMatchInterpolatedText( + '7 commits behind, 3 commits ahead of the upstream repository.', + ); + }); + + it('renders up to date message when divergence is unknown', async () => { + await createComponent({}, { ahead: 0, behind: 0 }); + expect(findDivergenceMessage().text()).toBe(i18n.upToDate); + }); + + it('renders commits ahead message', async () => { + await createComponent({}, { behind: 0 }); + expect(findDivergenceMessage().text()).toBe('3 commits ahead of the upstream repository.'); + }); + + it('renders commits behind message', async () => { + await createComponent({}, { ahead: 0 }); + + expect(findDivergenceMessage().text()).toBe('7 commits behind the upstream repository.'); + }); + + it('renders alert with error message when request fails', async () => { + await createComponent({}, {}, true); + expect(createAlert).toHaveBeenCalledWith({ + message: i18n.error, + captureError: true, + error: forkInfoError, + }); + }); +}); diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js index cf0d48280f4..4e5c9a685c4 100644 --- a/spec/frontend/repository/components/new_directory_modal_spec.js +++ b/spec/frontend/repository/components/new_directory_modal_spec.js @@ -5,7 +5,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; @@ -149,7 +149,7 @@ describe('NewDirectoryModal', () => { originalBranch, createNewMr, } = defaultFormValue; - mock.onPost(initialProps.path).reply(httpStatusCodes.OK, {}); + mock.onPost(initialProps.path).reply(HTTP_STATUS_OK, {}); await fillForm(); await submitForm(); @@ -161,7 +161,7 @@ describe('NewDirectoryModal', () => { }); it('does not submit "create_merge_request" formData if createNewMr is not checked', async () => { - mock.onPost(initialProps.path).reply(httpStatusCodes.OK, {}); + mock.onPost(initialProps.path).reply(HTTP_STATUS_OK, {}); await fillForm({ createNewMr: false }); await submitForm(); expect(mock.history.post[0].data.get('create_merge_request')).toBeNull(); @@ -169,7 +169,7 @@ describe('NewDirectoryModal', () => { it('redirects to the new directory', async () => { const response = { filePath: 'new-dir-path' }; - mock.onPost(initialProps.path).reply(httpStatusCodes.OK, response); + mock.onPost(initialProps.path).reply(HTTP_STATUS_OK, response); await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' }); await submitForm(); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 6eea66f1a7d..f694c8e9166 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -5,19 +5,25 @@ import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; import TreeContent from 'jh_else_ce/repository/components/tree_content.vue'; import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { i18n } from '~/repository/constants'; +import { graphQLErrors } from '../mock_data'; jest.mock('~/repository/commits_service', () => ({ loadCommits: jest.fn(() => Promise.resolve()), isRequested: jest.fn(), resetRequestedCommits: jest.fn(), })); +jest.mock('~/flash'); let vm; let $apollo; +const mockResponse = jest.fn().mockReturnValue(Promise.resolve({ data: {} })); -function factory(path, data = () => ({})) { +function factory(path, appoloMockResponse = mockResponse) { $apollo = { - query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })), + query: appoloMockResponse, }; vm = shallowMount(TreeContent, { @@ -222,4 +228,17 @@ describe('Repository table component', () => { expect(loadCommits.mock.calls).toEqual([['', path, '', 0]]); }); }); + + describe('error handling', () => { + const gitalyError = { graphQLErrors }; + it.each` + error | message + ${gitalyError} | ${i18n.gitalyError} + ${'Error'} | ${i18n.generalError} + `('should show an expected error', async ({ error, message }) => { + factory('/', jest.fn().mockRejectedValue(error)); + await waitForPromises(); + expect(createAlert).toHaveBeenCalledWith({ message, captureError: true }); + }); + }); }); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index 8db169b02b4..9de0666f27a 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { visitUrl } from '~/lib/utils/url_utility'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; @@ -158,7 +158,7 @@ describe('UploadBlobModal', () => { describe('successful response', () => { beforeEach(async () => { mock = new MockAdapter(axios); - mock.onPost(initialProps.path).reply(httpStatusCodes.OK, { filePath: 'blah' }); + mock.onPost(initialProps.path).reply(HTTP_STATUS_OK, { filePath: 'blah' }); findModal().vm.$emit('primary', mockEvent); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index cda47a5b0a5..d85434a9148 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -87,6 +87,8 @@ export const applicationInfoMock = { gitpodEnabled: true }; export const propsMock = { path: 'some_file.js', projectPath: 'some/path' }; export const refMock = 'default-ref'; +export const refWithSpecialCharMock = 'feat/selected-#-ref-#'; +export const encodedRefWithSpecialCharMock = 'feat/selected-%23-ref-%23'; export const blobControlsDataMock = { id: '1234', @@ -106,3 +108,19 @@ export const blobControlsDataMock = { }, }, }; + +export const graphQLErrors = [ + { + message: '14:failed to connect to all addresses.', + locations: [{ line: 16, column: 7 }], + path: ['project', 'repository', 'paginatedTree'], + extensions: { code: 'unavailable', gitaly_code: 14, service: 'git' }, + }, +]; + +export const propsForkInfo = { + projectPath: 'nataliia/myGitLab', + selectedRef: 'main', + sourceName: 'gitLab', + sourcePath: 'gitlab-org/gitlab', +}; diff --git a/spec/frontend/repository/utils/ref_switcher_utils_spec.js b/spec/frontend/repository/utils/ref_switcher_utils_spec.js index 3335059554f..4d0250fffbf 100644 --- a/spec/frontend/repository/utils/ref_switcher_utils_spec.js +++ b/spec/frontend/repository/utils/ref_switcher_utils_spec.js @@ -1,5 +1,6 @@ import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils'; import setWindowLocation from 'helpers/set_window_location_helper'; +import { refWithSpecialCharMock, encodedRefWithSpecialCharMock } from '../mock_data'; const projectRootPath = 'root/Project1'; const currentRef = 'main'; @@ -19,4 +20,10 @@ describe('generateRefDestinationPath', () => { setWindowLocation(currentPath); expect(generateRefDestinationPath(projectRootPath, selectedRef)).toBe(result); }); + + it('encodes the selected ref', () => { + const result = `${projectRootPath}/-/tree/${encodedRefWithSpecialCharMock}`; + + expect(generateRefDestinationPath(projectRootPath, refWithSpecialCharMock)).toBe(result); + }); }); diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js index 20d764190b1..487ed7bfe03 100644 --- a/spec/frontend/search/store/utils_spec.js +++ b/spec/frontend/search/store/utils_spec.js @@ -5,7 +5,10 @@ import { setFrequentItemToLS, mergeById, isSidebarDirty, + formatSearchResultCount, + getAggregationsUrl, } from '~/search/store/utils'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { MOCK_LS_KEY, MOCK_GROUPS, @@ -241,4 +244,23 @@ describe('Global Search Store Utils', () => { }); }); }); + describe('formatSearchResultCount', () => { + it('returns zero as string if no count is provided', () => { + expect(formatSearchResultCount()).toStrictEqual('0'); + }); + it('returns 10K string for 10000 integer', () => { + expect(formatSearchResultCount(10000)).toStrictEqual('10K'); + }); + it('returns 23K string for "23,000+" string', () => { + expect(formatSearchResultCount('23,000+')).toStrictEqual('23K'); + }); + }); + + describe('getAggregationsUrl', () => { + useMockLocationHelper(); + it('returns zero as string if no count is provided', () => { + const testURL = window.location.href; + expect(getAggregationsUrl()).toStrictEqual(`${testURL}search/aggregations`); + }); + }); }); diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js index 65c9d2f5f01..4c266fabea6 100644 --- a/spec/frontend/self_monitor/store/actions_spec.js +++ b/spec/frontend/self_monitor/store/actions_spec.js @@ -1,7 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import statusCodes, { HTTP_STATUS_ACCEPTED } from '~/lib/utils/http_status'; +import { HTTP_STATUS_ACCEPTED, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import * as actions from '~/self_monitor/store/actions'; import * as types from '~/self_monitor/store/mutation_types'; import createState from '~/self_monitor/store/state'; @@ -47,7 +47,7 @@ describe('self-monitor actions', () => { mock.onPost(state.createProjectEndpoint).reply(HTTP_STATUS_ACCEPTED, { job_id: '123', }); - mock.onGet(state.createProjectStatusEndpoint).reply(statusCodes.OK, { + mock.onGet(state.createProjectStatusEndpoint).reply(HTTP_STATUS_OK, { project_full_path: '/self-monitor-url', }); }); @@ -154,7 +154,7 @@ describe('self-monitor actions', () => { mock.onDelete(state.deleteProjectEndpoint).reply(HTTP_STATUS_ACCEPTED, { job_id: '456', }); - mock.onGet(state.deleteProjectStatusEndpoint).reply(statusCodes.OK, { + mock.onGet(state.deleteProjectStatusEndpoint).reply(HTTP_STATUS_OK, { status: 'success', }); }); diff --git a/spec/frontend/set_status_modal/set_status_form_spec.js b/spec/frontend/set_status_modal/set_status_form_spec.js index 486e06d2906..df740d4a431 100644 --- a/spec/frontend/set_status_modal/set_status_form_spec.js +++ b/spec/frontend/set_status_modal/set_status_form_spec.js @@ -1,12 +1,15 @@ import $ from 'jquery'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { useFakeDate } from 'helpers/fake_date'; import SetStatusForm from '~/set_status_modal/set_status_form.vue'; +import { NEVER_TIME_RANGE } from '~/set_status_modal/constants'; import EmojiPicker from '~/emoji/components/picker.vue'; import { timeRanges } from '~/vue_shared/constants'; -import { sprintf } from '~/locale'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; +const [thirtyMinutes, , , oneDay] = timeRanges; + describe('SetStatusForm', () => { let wrapper; @@ -73,17 +76,71 @@ describe('SetStatusForm', () => { }); }); - describe('when clear status after is set', () => { - it('displays value in dropdown toggle button', async () => { - const clearStatusAfter = timeRanges[0]; + describe('clear status after dropdown toggle button text', () => { + useFakeDate(2022, 11, 5); - await createComponent({ - propsData: { - clearStatusAfter, - }, + describe('when clear status after has previously been set', () => { + describe('when date is today', () => { + it('displays time that status will clear', async () => { + await createComponent({ + propsData: { + currentClearStatusAfter: '2022-12-05 11:00:00 UTC', + }, + }); + + expect(wrapper.findByRole('button', { name: '11:00am' }).exists()).toBe(true); + }); }); - expect(wrapper.findByRole('button', { name: clearStatusAfter.label }).exists()).toBe(true); + describe('when date is not today', () => { + it('displays date and time that status will clear', async () => { + await createComponent({ + propsData: { + currentClearStatusAfter: '2022-12-06 11:00:00 UTC', + }, + }); + + expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 11:00am' }).exists()).toBe(true); + }); + }); + + describe('when a new option is choose from the dropdown', () => { + describe('when chosen option is today', () => { + it('displays chosen option as time', async () => { + await createComponent({ + propsData: { + clearStatusAfter: thirtyMinutes, + currentClearStatusAfter: '2022-12-05 11:00:00 UTC', + }, + }); + + expect(wrapper.findByRole('button', { name: '12:30am' }).exists()).toBe(true); + }); + }); + + describe('when chosen option is not today', () => { + it('displays chosen option as date and time', async () => { + await createComponent({ + propsData: { + clearStatusAfter: oneDay, + currentClearStatusAfter: '2022-12-06 11:00:00 UTC', + }, + }); + + expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 12:00am' }).exists()).toBe( + true, + ); + }); + }); + }); + }); + + describe('when clear status after has not been set', () => { + it('displays `Never`', async () => { + await createComponent(); + + expect(wrapper.findByRole('button', { name: NEVER_TIME_RANGE.label }).exists()).toBe(true); + }); }); }); @@ -131,7 +188,7 @@ describe('SetStatusForm', () => { await wrapper.findByTestId('thirtyMinutes').trigger('click'); - expect(wrapper.emitted('clear-status-after-click')).toEqual([[timeRanges[0]]]); + expect(wrapper.emitted('clear-status-after-click')).toEqual([[thirtyMinutes]]); }); }); @@ -150,20 +207,4 @@ describe('SetStatusForm', () => { expect(wrapper.findByTestId('no-emoji-placeholder').exists()).toBe(true); }); }); - - describe('when `currentClearStatusAfter` prop is set', () => { - it('displays clear status message', async () => { - const date = '2022-08-25 21:14:48 UTC'; - - await createComponent({ - propsData: { - currentClearStatusAfter: date, - }, - }); - - expect( - wrapper.findByText(sprintf(SetStatusForm.i18n.clearStatusAfterMessage, { date })).exists(), - ).toBe(true); - }); - }); }); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index 53d2a9e0978..85cd8d51272 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -1,6 +1,7 @@ import { GlModal, GlFormCheckbox } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { useFakeDate } from 'helpers/fake_date'; import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import * as UserApi from '~/api/user_api'; import EmojiPicker from '~/emoji/components/picker.vue'; @@ -56,7 +57,6 @@ describe('SetStatusModalWrapper', () => { wrapper.findByPlaceholderText(SetStatusForm.i18n.statusMessagePlaceholder); const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button'); const findAvailabilityCheckbox = () => wrapper.findComponent(GlFormCheckbox); - const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]'); const getEmojiPicker = () => wrapper.findComponent(EmojiPickerStub); const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => { @@ -103,10 +103,6 @@ describe('SetStatusModalWrapper', () => { expect(wrapper.find('[data-testid="clear-status-at-dropdown"]').exists()).toBe(true); }); - it('does not display the clear status at message', () => { - expect(findClearStatusAtMessage().exists()).toBe(false); - }); - it('renders emoji picker dropdown with custom positioning', () => { expect(getEmojiPicker().props()).toMatchObject({ right: false, @@ -138,17 +134,16 @@ describe('SetStatusModalWrapper', () => { }); describe('with currentClearStatusAfter set', () => { + useFakeDate(2022, 11, 5); + beforeEach(async () => { await initEmojiMock(); - wrapper = createComponent({ currentClearStatusAfter: '2021-01-01 00:00:00 UTC' }); + wrapper = createComponent({ currentClearStatusAfter: '2022-12-06 11:00:00 UTC' }); return initModal(); }); - it('displays the clear status at message', () => { - const clearStatusAtMessage = findClearStatusAtMessage(); - - expect(clearStatusAtMessage.exists()).toBe(true); - expect(clearStatusAtMessage.text()).toBe('Your status resets on 2021-01-01 00:00:00 UTC.'); + it('displays date and time that status will expire in dropdown toggle button', () => { + expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 11:00am' }).exists()).toBe(true); }); }); @@ -170,33 +165,33 @@ describe('SetStatusModalWrapper', () => { }); it('clicking "setStatus" submits the user status', async () => { - findModal().vm.$emit('primary'); - await nextTick(); - // set the availability status findAvailabilityCheckbox().vm.$emit('input', true); // set the currentClearStatusAfter to 30 minutes - wrapper.find('[data-testid="thirtyMinutes"]').trigger('click'); + await wrapper.find('[data-testid="thirtyMinutes"]').trigger('click'); findModal().vm.$emit('primary'); await nextTick(); - const commonParams = { + expect(UserApi.updateUserStatus).toHaveBeenCalledWith({ + availability: AVAILABILITY_STATUS.BUSY, + clearStatusAfter: '30_minutes', emoji: defaultEmoji, message: defaultMessage, - }; - - expect(UserApi.updateUserStatus).toHaveBeenCalledTimes(2); - expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(1, { - availability: AVAILABILITY_STATUS.NOT_SET, - clearStatusAfter: null, - ...commonParams, }); - expect(UserApi.updateUserStatus).toHaveBeenNthCalledWith(2, { - availability: AVAILABILITY_STATUS.BUSY, - clearStatusAfter: '30_minutes', - ...commonParams, + }); + + describe('when `Clear status after` field has not been set', () => { + it('does not include `clearStatusAfter` in API request', async () => { + findModal().vm.$emit('primary'); + await nextTick(); + + expect(UserApi.updateUserStatus).toHaveBeenCalledWith({ + availability: AVAILABILITY_STATUS.NOT_SET, + emoji: defaultEmoji, + message: defaultMessage, + }); }); }); diff --git a/spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js b/spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js index eaee0e77311..a4a2a86dc73 100644 --- a/spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js +++ b/spec/frontend/set_status_modal/user_profile_set_status_wrapper_spec.js @@ -1,8 +1,6 @@ import { nextTick } from 'vue'; import { cloneDeep } from 'lodash'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { resetHTMLFixture } from 'helpers/fixtures'; -import { useFakeDate } from 'helpers/fake_date'; import UserProfileSetStatusWrapper from '~/set_status_modal/user_profile_set_status_wrapper.vue'; import SetStatusForm from '~/set_status_modal/set_status_form.vue'; import { TIME_RANGES_WITH_NEVER, NEVER_TIME_RANGE } from '~/set_status_modal/constants'; @@ -51,7 +49,7 @@ describe('UserProfileSetStatusWrapper', () => { emoji: defaultProvide.fields.emoji.value, message: defaultProvide.fields.message.value, availability: true, - clearStatusAfter: NEVER_TIME_RANGE, + clearStatusAfter: null, currentClearStatusAfter: defaultProvide.fields.clearStatusAfter.value, }); }); @@ -69,27 +67,41 @@ describe('UserProfileSetStatusWrapper', () => { ); }); - describe('when clear status after dropdown is set to `Never`', () => { - it('renders hidden clear status after input with value unset', () => { - createComponent(); + describe('when clear status after has previously been set', () => { + describe('when clear status after dropdown is not set', () => { + it('does not render hidden clear status after input', () => { + createComponent(); - expect( - findInput(defaultProvide.fields.clearStatusAfter.name).attributes('value'), - ).toBeUndefined(); + expect(findInput(defaultProvide.fields.clearStatusAfter.name).exists()).toBe(false); + }); }); - }); - describe('when clear status after dropdown has a value selected', () => { - it('renders hidden clear status after input with value set', async () => { - createComponent(); + describe('when clear status after dropdown is set to `Never`', () => { + it('renders hidden clear status after input with value unset', async () => { + createComponent(); - findSetStatusForm().vm.$emit('clear-status-after-click', TIME_RANGES_WITH_NEVER[1]); + findSetStatusForm().vm.$emit('clear-status-after-click', NEVER_TIME_RANGE); - await nextTick(); + await nextTick(); - expect(findInput(defaultProvide.fields.clearStatusAfter.name).attributes('value')).toBe( - TIME_RANGES_WITH_NEVER[1].shortcut, - ); + expect( + findInput(defaultProvide.fields.clearStatusAfter.name).attributes('value'), + ).toBeUndefined(); + }); + }); + + describe('when clear status after dropdown is set to a time range', () => { + it('renders hidden clear status after input with value set', async () => { + createComponent(); + + findSetStatusForm().vm.$emit('clear-status-after-click', TIME_RANGES_WITH_NEVER[1]); + + await nextTick(); + + expect(findInput(defaultProvide.fields.clearStatusAfter.name).attributes('value')).toBe( + TIME_RANGES_WITH_NEVER[1].shortcut, + ); + }); }); }); @@ -120,37 +132,4 @@ describe('UserProfileSetStatusWrapper', () => { expect(findInput(defaultProvide.fields.message.name).attributes('value')).toBe(newMessage); }); }); - - describe('when form is successfully submitted', () => { - // 2022-09-02 00:00:00 UTC - useFakeDate(2022, 8, 2); - - const form = document.createElement('form'); - form.classList.add('js-edit-user'); - - beforeEach(async () => { - document.body.appendChild(form); - createComponent(); - - const oneDay = TIME_RANGES_WITH_NEVER[4]; - - findSetStatusForm().vm.$emit('clear-status-after-click', oneDay); - - await nextTick(); - - form.dispatchEvent(new Event('ajax:success')); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('updates clear status after dropdown to `Never`', () => { - expect(findSetStatusForm().props('clearStatusAfter')).toBe(NEVER_TIME_RANGE); - }); - - it('updates `currentClearStatusAfter` prop', () => { - expect(findSetStatusForm().props('currentClearStatusAfter')).toBe('2022-09-03 00:00:00 UTC'); - }); - }); }); diff --git a/spec/frontend/set_status_modal/utils_spec.js b/spec/frontend/set_status_modal/utils_spec.js index 1e918b75a98..a1c899be900 100644 --- a/spec/frontend/set_status_modal/utils_spec.js +++ b/spec/frontend/set_status_modal/utils_spec.js @@ -1,5 +1,8 @@ -import { isUserBusy } from '~/set_status_modal/utils'; -import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; +import { isUserBusy, computedClearStatusAfterValue } from '~/set_status_modal/utils'; +import { AVAILABILITY_STATUS, NEVER_TIME_RANGE } from '~/set_status_modal/constants'; +import { timeRanges } from '~/vue_shared/constants'; + +const [thirtyMinutes] = timeRanges; describe('Set status modal utils', () => { describe('isUserBusy', () => { @@ -13,4 +16,15 @@ describe('Set status modal utils', () => { expect(isUserBusy(value)).toBe(result); }); }); + + describe('computedClearStatusAfterValue', () => { + it.each` + value | expected + ${null} | ${null} + ${NEVER_TIME_RANGE} | ${null} + ${thirtyMinutes} | ${thirtyMinutes.shortcut} + `('with $value returns $expected', ({ value, expected }) => { + expect(computedClearStatusAfterValue(value)).toBe(expected); + }); + }); }); diff --git a/spec/frontend/sidebar/components/assignees/assignees_spec.js b/spec/frontend/sidebar/components/assignees/assignees_spec.js index 6971ae2f9ed..d422292ed9e 100644 --- a/spec/frontend/sidebar/components/assignees/assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignees_spec.js @@ -1,10 +1,10 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import { trimText } from 'helpers/text_helper'; import UsersMockHelper from 'helpers/user_mock_data_helper'; import Assignee from '~/sidebar/components/assignees/assignees.vue'; import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue'; +import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; import UsersMock from '../../mock_data'; describe('Assignee component', () => { @@ -66,10 +66,8 @@ describe('Assignee component', () => { editable: true, }); - jest.spyOn(wrapper.vm, '$emit'); - wrapper.find('[data-testid="assign-yourself"]').trigger('click'); + await wrapper.find('[data-testid="assign-yourself"]').trigger('click'); - await nextTick(); expect(wrapper.emitted('assign-self')).toHaveLength(1); }); }); @@ -166,7 +164,11 @@ describe('Assignee component', () => { editable: true, }); - expect(wrapper.vm.sortedAssigness[0].can_merge).toBe(true); + expect(wrapper.findComponent(CollapsedAssigneeList).props('users')[0]).toEqual( + expect.objectContaining({ + can_merge: true, + }), + ); }); it('passes the sorted assignees to the uncollapsed-assignee-list', () => { diff --git a/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js index c5161a748a9..40f14d581dc 100644 --- a/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js +++ b/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js @@ -66,7 +66,7 @@ describe('Sidebar Reference Widget', () => { }); describe('when error occurs', () => { - it('calls createFlash with correct parameters', async () => { + it(`emits 'fetch-error' event with correct parameters`, async () => { const mockError = new Error('mayday'); createComponent({ diff --git a/spec/frontend/super_sidebar/components/counter_spec.js b/spec/frontend/super_sidebar/components/counter_spec.js new file mode 100644 index 00000000000..1150b0a3aa8 --- /dev/null +++ b/spec/frontend/super_sidebar/components/counter_spec.js @@ -0,0 +1,56 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { __ } from '~/locale'; +import Counter from '~/super_sidebar/components/counter.vue'; + +describe('Counter component', () => { + let wrapper; + + const defaultPropsData = { + count: 3, + href: '', + icon: 'issues', + label: __('Issues'), + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findButton = () => wrapper.find('button'); + const findIcon = () => wrapper.getComponent(GlIcon); + const findLink = () => wrapper.find('a'); + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(Counter, { + propsData: { + ...defaultPropsData, + ...props, + }, + }); + }; + + beforeEach(() => { + createWrapper(); + }); + + describe('default', () => { + it('renders icon', () => { + expect(findIcon().props('name')).toBe('issues'); + }); + + it('renders button', () => { + expect(findButton().attributes('aria-label')).toBe('Issues 3'); + expect(findLink().exists()).toBe(false); + }); + }); + + describe('link', () => { + it('renders link', () => { + createWrapper({ href: '/dashboard/todos' }); + expect(findLink().attributes('aria-label')).toBe('Issues 3'); + expect(findLink().attributes('href')).toBe('/dashboard/todos'); + expect(findButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js new file mode 100644 index 00000000000..d7d2f67dc8a --- /dev/null +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -0,0 +1,33 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue'; +import UserBar from '~/super_sidebar/components/user_bar.vue'; +import { sidebarData } from '../mock_data'; + +describe('SuperSidebar component', () => { + let wrapper; + + const findUserBar = () => wrapper.findComponent(UserBar); + + afterEach(() => { + wrapper.destroy(); + }); + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(SuperSidebar, { + propsData: { + sidebarData, + ...props, + }, + }); + }; + + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders UserBar with sidebarData', () => { + expect(findUserBar().props('sidebarData')).toBe(sidebarData); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js new file mode 100644 index 00000000000..6d0186a2749 --- /dev/null +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -0,0 +1,46 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { __ } from '~/locale'; +import Counter from '~/super_sidebar/components/counter.vue'; +import UserBar from '~/super_sidebar/components/user_bar.vue'; +import { sidebarData } from '../mock_data'; + +describe('UserBar component', () => { + let wrapper; + + const findCounter = (at) => wrapper.findAllComponents(Counter).at(at); + + afterEach(() => { + wrapper.destroy(); + }); + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(UserBar, { + propsData: { + sidebarData, + ...props, + }, + provide: { + rootPath: '/', + toggleNewNavEndpoint: '/-/profile/preferences', + }, + }); + }; + + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + 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')); + }); + + 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')); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js new file mode 100644 index 00000000000..7db0d0ea5cc --- /dev/null +++ b/spec/frontend/super_sidebar/mock_data.js @@ -0,0 +1,9 @@ +export const sidebarData = { + name: 'Administrator', + username: 'root', + avatar_url: 'path/to/img_administrator', + assigned_open_issues_count: 1, + assigned_open_merge_requests_count: 2, + todos_pending_count: 3, + issues_dashboard_path: 'path/to/issues', +}; diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js new file mode 100644 index 00000000000..3379af3f41c --- /dev/null +++ b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js @@ -0,0 +1,150 @@ +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ProjectStorageApp from '~/usage_quotas/storage/components/project_storage_app.vue'; +import UsageGraph from '~/usage_quotas/storage/components/usage_graph.vue'; +import { TOTAL_USAGE_DEFAULT_TEXT } from '~/usage_quotas/storage/constants'; +import getProjectStorageStatistics from '~/usage_quotas/storage/queries/project_storage.query.graphql'; +import { + projectData, + mockGetProjectStorageStatisticsGraphQLResponse, + mockEmptyResponse, + defaultProjectProvideValues, +} from '../mock_data'; + +Vue.use(VueApollo); + +describe('ProjectStorageApp', () => { + let wrapper; + + const createMockApolloProvider = ({ reject = false, mockedValue } = {}) => { + let response; + + if (reject) { + response = jest.fn().mockRejectedValue(mockedValue || new Error('GraphQL error')); + } else { + response = jest.fn().mockResolvedValue(mockedValue); + } + + const requestHandlers = [[getProjectStorageStatistics, response]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = ({ provide = {}, mockApollo } = {}) => { + wrapper = extendedWrapper( + shallowMount(ProjectStorageApp, { + apolloProvider: mockApollo, + provide: { + ...defaultProjectProvideValues, + ...provide, + }, + }), + ); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findUsagePercentage = () => wrapper.findByTestId('total-usage'); + const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link'); + const findUsageGraph = () => wrapper.findComponent(UsageGraph); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with apollo fetching successful', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageStatisticsGraphQLResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders correct total usage', () => { + expect(findUsagePercentage().text()).toBe(projectData.storage.totalUsage); + }); + + it('renders correct usage quotas help link', () => { + expect(findUsageQuotasHelpLink().attributes('href')).toBe( + defaultProjectProvideValues.helpLinks.usageQuotas, + ); + }); + }); + + describe('with apollo loading', () => { + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApolloProvider({ + mockedValue: new Promise(() => {}), + }); + createComponent({ mockApollo }); + }); + + it('should show loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('with apollo returning empty data', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockEmptyResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('shows default text for total usage', () => { + expect(findUsagePercentage().text()).toBe(TOTAL_USAGE_DEFAULT_TEXT); + }); + }); + + describe('with apollo fetching error', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider(); + createComponent({ mockApollo, reject: true }); + await waitForPromises(); + }); + + it('renders gl-alert', () => { + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('rendering <usage-graph />', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageStatisticsGraphQLResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders usage-graph component if project.statistics exists', () => { + expect(findUsageGraph().exists()).toBe(true); + }); + + it('passes project.statistics to usage-graph component', () => { + const { + __typename, + ...statistics + } = mockGetProjectStorageStatisticsGraphQLResponse.data.project.statistics; + expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics); + }); + }); +}); 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 new file mode 100644 index 00000000000..ce489f69cad --- /dev/null +++ b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js @@ -0,0 +1,129 @@ +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 { numberToHumanSize } from '~/lib/utils/number_utils'; +import { projectData, projectHelpLinks } from '../mock_data'; + +describe('ProjectStorageDetail', () => { + let wrapper; + + const { storageTypes } = projectData.storage; + const defaultProps = { storageTypes }; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + mount(ProjectStorageDetail, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + containerRegistryPopoverContent: 'Sample popover message', + }, + }), + ); + }; + + const generateStorageType = (id = 'buildArtifactsSize') => { + return { + storageType: { + id, + name: 'Test Name', + description: 'Test Description', + helpPath: '/test-type', + }, + value: 400000, + }; + }; + + const findTable = () => wrapper.findComponent(GlTableLite); + 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(); + }); + afterEach(() => { + wrapper.destroy(); + }); + + describe('with storage types', () => { + it.each(storageTypes)( + 'renders table row correctly %o', + ({ storageType: { id, name, description } }) => { + expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name); + expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description); + expect(wrapper.findByTestId(`${id}-icon`).props('name')).toBe(id); + expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe( + projectHelpLinks[id.replace(`Size`, ``)], + ); + }, + ); + + it('should render items in order from the biggest usage size to the smallest', () => { + const rows = findTable().find('tbody').findAll('tr'); + // Cloning array not to mutate the source + const sortedStorageTypes = [...storageTypes].sort((a, b) => b.value - a.value); + + sortedStorageTypes.forEach((storageType, i) => { + const rowUsageAmount = rows.wrappers[i].find('td:last-child').text(); + const expectedUsageAmount = numberToHumanSize(storageType.value, 1); + expect(rowUsageAmount).toBe(expectedUsageAmount); + }); + }); + }); + + describe('without storage types', () => { + beforeEach(() => { + createComponent({ storageTypes: [] }); + }); + + it('should render the table header <th>', () => { + expect(findTable().find('th').exists()).toBe(true); + }); + + it('should not render any table data <td>', () => { + expect(findTable().find('td').exists()).toBe(false); + }); + }); + + 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); + }); + + it(`does ${ + rendersUploadsPopover ? '' : ' not' + } render container registry warning icon and popover`, () => { + expect(findUploadsWarningIcon().exists()).toBe(rendersUploadsPopover); + expect(findUploadsPopover().exists()).toBe(rendersUploadsPopover); + }); + }, + ); +}); 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 new file mode 100644 index 00000000000..1eb3386bfb8 --- /dev/null +++ b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js @@ -0,0 +1,41 @@ +import { mount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import StorageTypeIcon from '~/usage_quotas/storage/components/storage_type_icon.vue'; + +describe('StorageTypeIcon', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(StorageTypeIcon, { + propsData: { + ...props, + }, + }); + }; + + const findGlIcon = () => wrapper.findComponent(GlIcon); + + describe('rendering icon', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + expected | provided + ${'doc-image'} | ${'lfsObjectsSize'} + ${'snippet'} | ${'snippetsSize'} + ${'infrastructure-registry'} | ${'repositorySize'} + ${'package'} | ${'packagesSize'} + ${'upload'} | ${'uploadsSize'} + ${'disk'} | ${'wikiSize'} + ${'disk'} | ${'anything-else'} + `( + 'renders icon with name of $expected when name prop is $provided', + ({ expected, provided }) => { + createComponent({ name: provided }); + + expect(findGlIcon().props('name')).toBe(expected); + }, + ); + }); +}); diff --git a/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js new file mode 100644 index 00000000000..75b970d937a --- /dev/null +++ b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js @@ -0,0 +1,144 @@ +import { shallowMount } from '@vue/test-utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import UsageGraph from '~/usage_quotas/storage/components/usage_graph.vue'; + +let data; +let wrapper; + +function mountComponent({ rootStorageStatistics, limit }) { + wrapper = shallowMount(UsageGraph, { + propsData: { + rootStorageStatistics, + limit, + }, + }); +} +function findStorageTypeUsagesSerialized() { + return wrapper + .findAll('[data-testid="storage-type-usage"]') + .wrappers.map((wp) => wp.element.style.flex); +} + +describe('Storage Counter usage graph component', () => { + beforeEach(() => { + data = { + rootStorageStatistics: { + wikiSize: 5000, + repositorySize: 4000, + packagesSize: 3000, + containerRegistrySize: 2500, + lfsObjectsSize: 2000, + buildArtifactsSize: 500, + pipelineArtifactsSize: 500, + snippetsSize: 2000, + storageSize: 17000, + uploadsSize: 1000, + }, + limit: 2000, + }; + mountComponent(data); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the legend in order', () => { + const types = wrapper.findAll('[data-testid="storage-type-legend"]'); + + const { + buildArtifactsSize, + pipelineArtifactsSize, + lfsObjectsSize, + packagesSize, + containerRegistrySize, + repositorySize, + wikiSize, + snippetsSize, + uploadsSize, + } = data.rootStorageStatistics; + + expect(types.at(0).text()).toMatchInterpolatedText(`Wiki ${numberToHumanSize(wikiSize)}`); + expect(types.at(1).text()).toMatchInterpolatedText( + `Repository ${numberToHumanSize(repositorySize)}`, + ); + expect(types.at(2).text()).toMatchInterpolatedText( + `Packages ${numberToHumanSize(packagesSize)}`, + ); + expect(types.at(3).text()).toMatchInterpolatedText( + `Container Registry ${numberToHumanSize(containerRegistrySize)}`, + ); + expect(types.at(4).text()).toMatchInterpolatedText( + `LFS storage ${numberToHumanSize(lfsObjectsSize)}`, + ); + expect(types.at(5).text()).toMatchInterpolatedText( + `Snippets ${numberToHumanSize(snippetsSize)}`, + ); + expect(types.at(6).text()).toMatchInterpolatedText( + `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`, + ); + expect(types.at(7).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`); + }); + + describe('when storage type is not used', () => { + beforeEach(() => { + data.rootStorageStatistics.wikiSize = 0; + mountComponent(data); + }); + + it('filters the storage type', () => { + expect(wrapper.text()).not.toContain('Wikis'); + }); + }); + + describe('when there is no storage usage', () => { + beforeEach(() => { + data.rootStorageStatistics.storageSize = 0; + mountComponent(data); + }); + + it('does not render', () => { + expect(wrapper.html()).toEqual(''); + }); + }); + + describe('when limit is 0', () => { + beforeEach(() => { + data.limit = 0; + mountComponent(data); + }); + + it('sets correct flex values', () => { + expect(findStorageTypeUsagesSerialized()).toStrictEqual([ + '0.29411764705882354', + '0.23529411764705882', + '0.17647058823529413', + '0.14705882352941177', + '0.11764705882352941', + '0.11764705882352941', + '0.058823529411764705', + '0.058823529411764705', + ]); + }); + }); + + describe('when storage exceeds limit', () => { + beforeEach(() => { + data.limit = data.rootStorageStatistics.storageSize - 1; + mountComponent(data); + }); + + it('does render correclty', () => { + expect(findStorageTypeUsagesSerialized()).toStrictEqual([ + '0.29411764705882354', + '0.23529411764705882', + '0.17647058823529413', + '0.14705882352941177', + '0.11764705882352941', + '0.11764705882352941', + '0.058823529411764705', + '0.058823529411764705', + ]); + }); + }); +}); diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js new file mode 100644 index 00000000000..b1c6be10d80 --- /dev/null +++ b/spec/frontend/usage_quotas/storage/mock_data.js @@ -0,0 +1,101 @@ +import mockGetProjectStorageStatisticsGraphQLResponse from 'test_fixtures/graphql/usage_quotas/storage/project_storage.query.graphql.json'; + +export { mockGetProjectStorageStatisticsGraphQLResponse }; +export const mockEmptyResponse = { data: { project: null } }; + +export const projectData = { + storage: { + totalUsage: '13.8 MiB', + storageTypes: [ + { + storageType: { + id: 'containerRegistrySize', + name: 'Container Registry', + description: 'Gitlab-integrated Docker Container Registry for storing Docker Images.', + helpPath: '/container_registry', + }, + value: 3_900_000, + }, + { + storageType: { + id: 'buildArtifactsSize', + name: 'Artifacts', + description: 'Pipeline artifacts and job artifacts, created with CI/CD.', + helpPath: '/build-artifacts', + }, + value: 400000, + }, + { + storageType: { + id: 'lfsObjectsSize', + name: 'LFS storage', + description: 'Audio samples, videos, datasets, and graphics.', + helpPath: '/lsf-objects', + }, + value: 4800000, + }, + { + storageType: { + id: 'packagesSize', + name: 'Packages', + description: 'Code packages and container images.', + helpPath: '/packages', + }, + value: 3800000, + }, + { + storageType: { + id: 'repositorySize', + name: 'Repository', + description: 'Git repository.', + helpPath: '/repository', + }, + value: 3900000, + }, + { + storageType: { + id: 'snippetsSize', + name: 'Snippets', + description: 'Shared bits of code and text.', + helpPath: '/snippets', + }, + value: 0, + }, + { + storageType: { + id: 'uploadsSize', + name: 'Uploads', + description: 'File attachments and smaller design graphics.', + helpPath: '/uploads', + }, + value: 900000, + }, + { + storageType: { + id: 'wikiSize', + name: 'Wiki', + description: 'Wiki content.', + helpPath: '/wiki', + }, + value: 300000, + }, + ], + }, +}; + +export const projectHelpLinks = { + containerRegistry: '/container_registry', + usageQuotas: '/usage-quotas', + buildArtifacts: '/build-artifacts', + lfsObjects: '/lsf-objects', + packages: '/packages', + repository: '/repository', + snippets: '/snippets', + uploads: '/uploads', + wiki: '/wiki', +}; + +export const defaultProjectProvideValues = { + projectPath: '/project-path', + helpLinks: projectHelpLinks, +}; diff --git a/spec/frontend/usage_quotas/storage/utils_spec.js b/spec/frontend/usage_quotas/storage/utils_spec.js new file mode 100644 index 00000000000..8fdd307c008 --- /dev/null +++ b/spec/frontend/usage_quotas/storage/utils_spec.js @@ -0,0 +1,88 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { PROJECT_STORAGE_TYPES } from '~/usage_quotas/storage/constants'; +import { + parseGetProjectStorageResults, + getStorageTypesFromProjectStatistics, + descendingStorageUsageSort, +} from '~/usage_quotas/storage/utils'; +import { + mockGetProjectStorageStatisticsGraphQLResponse, + defaultProjectProvideValues, + projectData, +} from './mock_data'; + +describe('getStorageTypesFromProjectStatistics', () => { + const projectStatistics = mockGetProjectStorageStatisticsGraphQLResponse.data.project.statistics; + + describe('matches project statistics value with matching storage type', () => { + const typesWithStats = getStorageTypesFromProjectStatistics(projectStatistics); + + it.each(PROJECT_STORAGE_TYPES)('storage type: $id', ({ id }) => { + expect(typesWithStats).toContainEqual({ + storageType: expect.objectContaining({ + id, + }), + value: projectStatistics[id], + }); + }); + }); + + it('adds helpPath to a relevant type', () => { + const trimTypeId = (id) => id.replace('Size', ''); + const helpLinks = PROJECT_STORAGE_TYPES.reduce((acc, { id }) => { + const key = trimTypeId(id); + return { + ...acc, + [key]: `url://${id}`, + }; + }, {}); + + const typesWithStats = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks); + + typesWithStats.forEach((type) => { + const key = trimTypeId(type.storageType.id); + expect(type.storageType.helpPath).toBe(helpLinks[key]); + }); + }); +}); +describe('parseGetProjectStorageResults', () => { + it('parses project statistics correctly', () => { + expect( + parseGetProjectStorageResults( + mockGetProjectStorageStatisticsGraphQLResponse.data, + defaultProjectProvideValues.helpLinks, + ), + ).toMatchObject(projectData); + }); + + it('includes storage type with size of 0 in returned value', () => { + const mockedResponse = cloneDeep(mockGetProjectStorageStatisticsGraphQLResponse.data); + // ensuring a specific storage type item has size of 0 + mockedResponse.project.statistics.repositorySize = 0; + + const response = parseGetProjectStorageResults( + mockedResponse, + defaultProjectProvideValues.helpLinks, + ); + + expect(response.storage.storageTypes).toEqual( + expect.arrayContaining([ + { + storageType: expect.any(Object), + value: 0, + }, + ]), + ); + }); +}); + +describe('descendingStorageUsageSort', () => { + it('sorts items by a given key in descending order', () => { + const items = [{ k: 1 }, { k: 3 }, { k: 2 }]; + + const sorted = [...items].sort(descendingStorageUsageSort('k')); + + const expectedSorted = [{ k: 3 }, { k: 2 }, { k: 1 }]; + expect(sorted).toEqual(expectedSorted); + }); +}); diff --git a/spec/frontend/users/profile/components/report_abuse_button_spec.js b/spec/frontend/users/profile/components/report_abuse_button_spec.js new file mode 100644 index 00000000000..7ad28566f49 --- /dev/null +++ b/spec/frontend/users/profile/components/report_abuse_button_spec.js @@ -0,0 +1,79 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; + +import ReportAbuseButton from '~/users/profile/components/report_abuse_button.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +describe('ReportAbuseButton', () => { + let wrapper; + + const ACTION_PATH = '/abuse_reports/add_category'; + const USER_ID = '1'; + const REPORTED_FROM_URL = 'http://example.com'; + + const createComponent = (props) => { + wrapper = shallowMountExtended(ReportAbuseButton, { + propsData: { + ...props, + }, + provide: { + reportAbusePath: ACTION_PATH, + reportedUserId: USER_ID, + reportedFromUrl: REPORTED_FROM_URL, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + const findReportAbuseButton = () => wrapper.findComponent(GlButton); + const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); + + it('renders report abuse button', () => { + expect(findReportAbuseButton().exists()).toBe(true); + + expect(findReportAbuseButton().props()).toMatchObject({ + category: 'primary', + icon: 'error', + }); + + expect(findReportAbuseButton().attributes('aria-label')).toBe( + wrapper.vm.$options.i18n.reportAbuse, + ); + }); + + it('renders abuse category selector with the drawer initially closed', () => { + expect(findAbuseCategorySelector().exists()).toBe(true); + + expect(findAbuseCategorySelector().props('showDrawer')).toBe(false); + }); + + describe('when button is clicked', () => { + beforeEach(async () => { + await findReportAbuseButton().vm.$emit('click'); + }); + + it('opens the abuse category selector', () => { + expect(findAbuseCategorySelector().props('showDrawer')).toBe(true); + }); + + it('closes the abuse category selector', async () => { + await findAbuseCategorySelector().vm.$emit('close-drawer'); + + expect(findAbuseCategorySelector().props('showDrawer')).toBe(false); + }); + }); + + describe('when user hovers out of the button', () => { + it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, () => { + jest.spyOn(wrapper.vm.$root, '$emit'); + + findReportAbuseButton().vm.$emit('mouseout'); + + expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith(BV_HIDE_TOOLTIP); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js index c253dc63f23..81f266d8070 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js @@ -42,8 +42,8 @@ describe('Merge Request Collapsible Extension', () => { expect(wrapper.find('[data-testid="collapsed-header"]').text()).toBe('hello there'); }); - it('renders chevron-lg-right icon', () => { - expect(findIcon().props('name')).toBe('chevron-lg-right'); + it('renders chevron-right icon', () => { + expect(findIcon().props('name')).toBe('chevron-right'); }); describe('onClick', () => { @@ -60,8 +60,8 @@ describe('Merge Request Collapsible Extension', () => { expect(findTitle().text()).toBe('Collapse'); }); - it('renders chevron-lg-down icon', () => { - expect(findIcon().props('name')).toBe('chevron-lg-down'); + it('renders chevron-down icon', () => { + expect(findIcon().props('name')).toBe('chevron-down'); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap deleted file mode 100644 index 4077564486c..00000000000 --- a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap +++ /dev/null @@ -1,163 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MRWidgetAutoMergeEnabled template should have correct elements 1`] = ` -<div - class="mr-widget-body media gl-display-flex gl-align-items-center" -> - <div - class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3" - > - <div - class="gl-display-flex gl-m-auto" - > - <div - class="gl-mr-3 gl-p-2 gl-m-0! gl-text-blue-500 gl-w-6 gl-p-2" - > - <div - class="gl-rounded-full gl-relative gl-display-flex mr-widget-extension-icon" - > - <div - class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto" - > - <div - class="gl-display-flex gl-m-auto gl-translate-y-n50" - > - <svg - aria-label="Scheduled " - class="gl-display-block gl-icon s12" - data-qa-selector="status_scheduled_icon" - data-testid="status-scheduled-icon" - role="img" - > - <use - href="#status-scheduled" - /> - </svg> - </div> - </div> - </div> - </div> - </div> - </div> - - <div - class="gl-display-flex gl-w-full" - > - <div - class="media-body gl-display-flex gl-align-items-center" - > - - <h4 - class="gl-mr-3" - data-testid="statusText" - > - Set by to be merged automatically when the pipeline succeeds - </h4> - - <div - class="gl-display-flex gl-font-size-0 gl-ml-auto gl-gap-3" - > - <div - class="gl-display-flex gl-align-items-flex-start" - > - <div - class="dropdown b-dropdown gl-dropdown gl-display-block gl-md-display-none! btn-group" - lazy="" - no-caret="" - title="Options" - > - <!----> - <button - aria-expanded="false" - aria-haspopup="true" - class="btn dropdown-toggle btn-default btn-sm gl-p-2! gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret" - type="button" - > - <!----> - - <svg - aria-hidden="true" - class="dropdown-icon gl-icon s16" - data-testid="ellipsis_v-icon" - role="img" - > - <use - href="#ellipsis_v" - /> - </svg> - - <span - class="gl-dropdown-button-text gl-sr-only" - > - - </span> - - <svg - aria-hidden="true" - class="gl-button-icon dropdown-chevron gl-icon s16" - data-testid="chevron-down-icon" - role="img" - > - <use - href="#chevron-down" - /> - </svg> - </button> - <ul - class="dropdown-menu dropdown-menu-right" - role="menu" - tabindex="-1" - > - <!----> - </ul> - </div> - - <button - class="btn gl-display-none gl-md-display-block gl-float-left btn-confirm btn-sm gl-button btn-confirm-tertiary js-cancel-auto-merge" - data-qa-selector="cancel_auto_merge_button" - data-testid="cancelAutomaticMergeButton" - type="button" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Cancel auto-merge - - </span> - </button> - </div> - </div> - </div> - - <div - class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1" - > - <button - class="btn gl-vertical-align-top btn-default btn-sm gl-button btn-default-tertiary btn-icon" - title="Collapse merge details" - type="button" - > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="chevron-lg-up-icon" - role="img" - > - <use - href="#chevron-lg-up" - /> - </svg> - - <!----> - </button> - </div> - </div> -</div> -`; diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js index 5b9f30dfb86..fef5fee5f19 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -128,14 +128,6 @@ describe('MRWidgetAutoMergeEnabled', () => { }); describe('template', () => { - it('should have correct elements', () => { - factory({ - ...defaultMrProps(), - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - it('should disable cancel auto merge button when the action is in progress', async () => { factory({ ...defaultMrProps(), 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 4c93c88de16..7e941c5ceaa 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 @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; import * as Sentry from '@sentry/browser'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +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 StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; @@ -26,8 +26,8 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { const findHelpPopover = () => wrapper.findComponent(HelpPopover); const findDynamicScroller = () => wrapper.findByTestId('dynamic-content-scroller'); - const createComponent = ({ propsData, slots } = {}) => { - wrapper = shallowMountExtended(Widget, { + const createComponent = ({ propsData, slots, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(Widget, { propsData: { isCollapsible: false, loadingText: 'Loading widget', @@ -73,6 +73,13 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { expect(findStatusIcon().props()).toMatchObject({ iconName: 'failed', isLoading: false }); }); + it('displays the error text when :has-error is true', () => { + createComponent({ + propsData: { hasError: true, errorText: 'API error' }, + }); + expect(wrapper.findByText('API error').exists()).toBe(true); + }); + it('displays loading icon until request is made and then displays status icon when the request is complete', async () => { const fetchCollapsedData = jest .fn() @@ -425,6 +432,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { beforeEach(() => { createComponent({ + mountFn: mountExtended, propsData: { isCollapsible: true, content, @@ -437,5 +445,11 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { await waitForPromises(); expect(findDynamicScroller().props('items')).toEqual(content); }); + + it('renders the dynamic content inside the dynamic scroller', async () => { + findToggleButton().vm.$emit('click'); + await waitForPromises(); + expect(wrapper.findByText('Main text for the row').exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/extensions/security_reports/mock_data.js b/spec/frontend/vue_merge_request_widget/extensions/security_reports/mock_data.js new file mode 100644 index 00000000000..c7354483e8b --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extensions/security_reports/mock_data.js @@ -0,0 +1,141 @@ +export const mockArtifacts = () => ({ + data: { + project: { + id: 'gid://gitlab/Project/9', + mergeRequest: { + id: 'gid://gitlab/MergeRequest/1', + headPipeline: { + id: 'gid://gitlab/Ci::Pipeline/1', + jobs: { + nodes: [ + { + id: 'gid://gitlab/Ci::Build/14', + name: 'sam_scan', + artifacts: { + nodes: [ + { + downloadPath: + '/root/security-reports/-/jobs/14/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/root/security-reports/-/jobs/14/artifacts/download?file_type=sast', + fileType: 'SAST', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + id: 'gid://gitlab/Ci::Build/11', + name: 'sast-spotbugs', + artifacts: { + nodes: [ + { + downloadPath: + '/root/security-reports/-/jobs/11/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/root/security-reports/-/jobs/11/artifacts/download?file_type=sast', + fileType: 'SAST', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + id: 'gid://gitlab/Ci::Build/10', + name: 'sast-sobelow', + artifacts: { + nodes: [ + { + downloadPath: + '/root/security-reports/-/jobs/10/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + id: 'gid://gitlab/Ci::Build/9', + name: 'sast-pmd-apex', + artifacts: { + nodes: [ + { + downloadPath: + '/root/security-reports/-/jobs/9/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + id: 'gid://gitlab/Ci::Build/8', + name: 'sast-eslint', + artifacts: { + nodes: [ + { + downloadPath: + '/root/security-reports/-/jobs/8/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/root/security-reports/-/jobs/8/artifacts/download?file_type=sast', + fileType: 'SAST', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + { + id: 'gid://gitlab/Ci::Build/7', + name: 'secrets', + artifacts: { + nodes: [ + { + downloadPath: + '/root/security-reports/-/jobs/7/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + { + downloadPath: + '/root/security-reports/-/jobs/7/artifacts/download?file_type=secret_detection', + fileType: 'SECRET_DETECTION', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + __typename: 'CiJob', + }, + ], + __typename: 'CiJobConnection', + }, + __typename: 'Pipeline', + }, + __typename: 'MergeRequest', + }, + __typename: 'Project', + }, + }, +}); diff --git a/spec/frontend/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports_spec.js b/spec/frontend/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports_spec.js new file mode 100644 index 00000000000..16c2adaffaf --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import { GlDropdown } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import MRSecurityWidget from '~/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue'; +import Widget from '~/vue_merge_request_widget/components/widget/widget.vue'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_merge_request_widget/extensions/security_reports/graphql/security_report_merge_request_download_paths.query.graphql'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockArtifacts } from './mock_data'; + +Vue.use(VueApollo); + +describe('vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue', () => { + let wrapper; + + const createComponent = ({ propsData, mockResponse = mockArtifacts() } = {}) => { + wrapper = mountExtended(MRSecurityWidget, { + apolloProvider: createMockApollo([ + [securityReportMergeRequestDownloadPathsQuery, jest.fn().mockResolvedValue(mockResponse)], + ]), + propsData: { + ...propsData, + mr: {}, + }, + }); + }; + + const findWidget = () => wrapper.findComponent(Widget); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItem = (name) => wrapper.findByTestId(name); + + describe('with data', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('displays the correct message', () => { + expect(wrapper.findByText('Security scans have run').exists()).toBe(true); + }); + + it('displays the help popover', () => { + expect(findWidget().props('helpPopover')).toEqual({ + content: { + learnMorePath: + '/help/user/application_security/index#view-security-scan-information-in-merge-requests', + text: + 'New vulnerabilities are vulnerabilities that the security scan detects in the merge request that are different to existing vulnerabilities in the default branch.', + }, + options: { + title: 'Security scan results', + }, + }); + }); + + it.each` + artifactName | exists | downloadPath + ${'sam_scan'} | ${true} | ${'/root/security-reports/-/jobs/14/artifacts/download?file_type=sast'} + ${'sast-spotbugs'} | ${true} | ${'/root/security-reports/-/jobs/11/artifacts/download?file_type=sast'} + ${'sast-sobelow'} | ${false} | ${''} + ${'sast-pmd-apex'} | ${false} | ${''} + ${'sast-eslint'} | ${true} | ${'/root/security-reports/-/jobs/8/artifacts/download?file_type=sast'} + ${'secrets'} | ${true} | ${'/root/security-reports/-/jobs/7/artifacts/download?file_type=secret_detection'} + `( + 'has a dropdown to download $artifactName artifacts', + ({ artifactName, exists, downloadPath }) => { + expect(findDropdown().exists()).toBe(true); + expect(wrapper.findByText(`Download ${artifactName}`).exists()).toBe(exists); + + if (exists) { + const dropdownItem = findDropdownItem(`download-${artifactName}`); + expect(dropdownItem.attributes('download')).toBe(''); + expect(dropdownItem.attributes('href')).toBe(downloadPath); + } + }, + ); + }); + + describe('without data', () => { + beforeEach(() => { + createComponent({ mockResponse: { data: { project: { id: 'project-id' } } } }); + }); + + it('displays the correct message', () => { + expect(wrapper.findByText('Security scans have run').exists()).toBe(true); + }); + + it('should not display the artifacts dropdown', () => { + expect(findDropdown().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js index baef247b649..548b68bc103 100644 --- a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js @@ -8,7 +8,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; -import httpStatusCodes, { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; +import { + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_OK, +} from '~/lib/utils/http_status'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; import { failedReport } from 'jest/ci/reports/mock_data/mock_data'; @@ -57,7 +61,7 @@ describe('Test report extension', () => { }; const createExpandedWidgetWithData = async (data = mixedResultsTestReports) => { - mockApi(httpStatusCodes.OK, data); + mockApi(HTTP_STATUS_OK, data); createComponent(); await waitForPromises(); findToggleCollapsedButton().trigger('click'); @@ -75,7 +79,7 @@ describe('Test report extension', () => { describe('summary', () => { it('displays loading state initially', () => { - mockApi(httpStatusCodes.OK); + mockApi(HTTP_STATUS_OK); createComponent(); expect(wrapper.text()).toContain(i18n.loading); @@ -91,7 +95,7 @@ describe('Test report extension', () => { }); it('with an error response, displays failed to load text', async () => { - mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + mockApi(HTTP_STATUS_INTERNAL_SERVER_ERROR); createComponent(); await waitForPromises(); @@ -107,7 +111,7 @@ describe('Test report extension', () => { ${'failed test results'} | ${newFailedTestReports} | ${'Test summary: 2 failed, 11 total tests'} ${'resolved failures'} | ${resolvedFailures} | ${'Test summary: 4 fixed test results, 11 total tests'} `('displays summary text for $description', async ({ mockData, expectedResult }) => { - mockApi(httpStatusCodes.OK, mockData); + mockApi(HTTP_STATUS_OK, mockData); createComponent(); await waitForPromises(); @@ -116,7 +120,7 @@ describe('Test report extension', () => { }); it('displays report level recently failed count', async () => { - mockApi(httpStatusCodes.OK, recentFailures); + mockApi(HTTP_STATUS_OK, recentFailures); createComponent(); await waitForPromises(); @@ -127,7 +131,7 @@ describe('Test report extension', () => { }); it('displays a link to the full report', async () => { - mockApi(httpStatusCodes.OK); + mockApi(HTTP_STATUS_OK); createComponent(); await waitForPromises(); @@ -137,7 +141,7 @@ describe('Test report extension', () => { }); it('hides copy failed tests button when there are no failing tests', async () => { - mockApi(httpStatusCodes.OK); + mockApi(HTTP_STATUS_OK); createComponent(); await waitForPromises(); @@ -146,7 +150,7 @@ describe('Test report extension', () => { }); it('displays copy failed tests button when there are failing tests', async () => { - mockApi(httpStatusCodes.OK, newFailedTestReports); + mockApi(HTTP_STATUS_OK, newFailedTestReports); createComponent(); await waitForPromises(); @@ -159,7 +163,7 @@ describe('Test report extension', () => { }); it('hides copy failed tests button when endpoint returns null files', async () => { - mockApi(httpStatusCodes.OK, newFailedTestWithNullFilesReport); + mockApi(HTTP_STATUS_OK, newFailedTestWithNullFilesReport); createComponent(); await waitForPromises(); @@ -168,7 +172,7 @@ describe('Test report extension', () => { }); it('copy failed tests button updates tooltip text when clicked', async () => { - mockApi(httpStatusCodes.OK, newFailedTestReports); + mockApi(HTTP_STATUS_OK, newFailedTestReports); createComponent(); await waitForPromises(); @@ -195,7 +199,7 @@ describe('Test report extension', () => { }); it('shows an error when a suite has a parsing error', async () => { - mockApi(httpStatusCodes.OK, reportWithParsingErrors); + mockApi(HTTP_STATUS_OK, reportWithParsingErrors); createComponent(); await waitForPromises(); 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 a06ad930abe..01049e54a7f 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 @@ -6,7 +6,7 @@ import axios from '~/lib/utils/axios_utils'; import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; import accessibilityExtension from '~/vue_merge_request_widget/extensions/accessibility'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { accessibilityReportResponseErrors, accessibilityReportResponseSuccess } from './mock_data'; describe('Accessibility extension', () => { @@ -45,7 +45,7 @@ describe('Accessibility extension', () => { describe('summary', () => { it('displays loading text', () => { - mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors); + mockApi(HTTP_STATUS_OK, accessibilityReportResponseErrors); createComponent(); @@ -53,7 +53,7 @@ describe('Accessibility extension', () => { }); it('displays failed loading text', async () => { - mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + mockApi(HTTP_STATUS_INTERNAL_SERVER_ERROR); createComponent(); @@ -63,7 +63,7 @@ describe('Accessibility extension', () => { }); it('displays detected errors and is expandable', async () => { - mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors); + mockApi(HTTP_STATUS_OK, accessibilityReportResponseErrors); createComponent(); @@ -76,7 +76,7 @@ describe('Accessibility extension', () => { }); it('displays no detected errors and is not expandable', async () => { - mockApi(httpStatusCodes.OK, accessibilityReportResponseSuccess); + mockApi(HTTP_STATUS_OK, accessibilityReportResponseSuccess); createComponent(); @@ -91,7 +91,7 @@ describe('Accessibility extension', () => { describe('expanded data', () => { beforeEach(async () => { - mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors); + mockApi(HTTP_STATUS_OK, accessibilityReportResponseErrors); createComponent(); 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 f0ebbb1a82e..67b327217ef 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 @@ -7,10 +7,18 @@ import axios from '~/lib/utils/axios_utils'; import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality'; -import httpStatusCodes, { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; -import { i18n } from '~/vue_merge_request_widget/extensions/code_quality/constants'; +import { + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_OK, +} from '~/lib/utils/http_status'; +import { + i18n, + codeQualityPrefixes, +} from '~/vue_merge_request_widget/extensions/code_quality/constants'; import { codeQualityResponseNewErrors, + codeQualityResponseResolvedErrors, codeQualityResponseResolvedAndNewErrors, codeQualityResponseNoErrors, } from './mock_data'; @@ -29,6 +37,10 @@ describe('Code Quality extension', () => { const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button'); const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); + const isCollapsable = () => wrapper.findByTestId('toggle-button').exists(); + const getNeutralIcon = () => wrapper.findByTestId('status-neutral-icon').exists(); + const getAlertIcon = () => wrapper.findByTestId('status-alert-icon').exists(); + const getSuccessIcon = () => wrapper.findByTestId('status-success-icon').exists(); const createComponent = () => { wrapper = mountExtended(extensionsContainer, { @@ -55,7 +67,7 @@ describe('Code Quality extension', () => { describe('summary', () => { it('displays loading text', () => { - mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors); + mockApi(HTTP_STATUS_OK, codeQualityResponseNewErrors); createComponent(); @@ -72,28 +84,57 @@ describe('Code Quality extension', () => { }); it('displays failed loading text', async () => { - mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + mockApi(HTTP_STATUS_INTERNAL_SERVER_ERROR); createComponent(); await waitForPromises(); + expect(wrapper.text()).toBe(i18n.error); + expect(isCollapsable()).toBe(false); }); - it('displays correct single Report', async () => { - mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors); + it('displays new Errors finding', async () => { + mockApi(HTTP_STATUS_OK, codeQualityResponseNewErrors); createComponent(); await waitForPromises(); + expect(wrapper.text()).toBe( + i18n + .singularCopy( + i18n.findings(codeQualityResponseNewErrors.new_errors, codeQualityPrefixes.new), + ) + .replace(/%{strong_start}/g, '') + .replace(/%{strong_end}/g, ''), + ); + expect(isCollapsable()).toBe(true); + expect(getAlertIcon()).toBe(true); + }); + + it('displays resolved Errors finding', async () => { + mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedErrors); + createComponent(); + + await waitForPromises(); expect(wrapper.text()).toBe( - i18n.degradedCopy(i18n.singularReport(codeQualityResponseNewErrors.new_errors)), + i18n + .singularCopy( + i18n.findings( + codeQualityResponseResolvedErrors.resolved_errors, + codeQualityPrefixes.fixed, + ), + ) + .replace(/%{strong_start}/g, '') + .replace(/%{strong_end}/g, ''), ); + expect(isCollapsable()).toBe(true); + expect(getSuccessIcon()).toBe(true); }); it('displays quality improvement and degradation', async () => { - mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors); + mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedAndNewErrors); createComponent(); await waitForPromises(); @@ -102,28 +143,38 @@ describe('Code Quality extension', () => { expect(wrapper.text()).toBe( i18n .improvementAndDegradationCopy( - i18n.pluralReport(codeQualityResponseResolvedAndNewErrors.resolved_errors), - i18n.pluralReport(codeQualityResponseResolvedAndNewErrors.new_errors), + i18n.findings( + codeQualityResponseResolvedAndNewErrors.resolved_errors, + codeQualityPrefixes.fixed, + ), + i18n.findings( + codeQualityResponseResolvedAndNewErrors.new_errors, + codeQualityPrefixes.new, + ), ) .replace(/%{strong_start}/g, '') .replace(/%{strong_end}/g, ''), ); + expect(isCollapsable()).toBe(true); + expect(getAlertIcon()).toBe(true); }); it('displays no detected errors', async () => { - mockApi(httpStatusCodes.OK, codeQualityResponseNoErrors); + mockApi(HTTP_STATUS_OK, codeQualityResponseNoErrors); createComponent(); await waitForPromises(); expect(wrapper.text()).toBe(i18n.noChanges); + expect(isCollapsable()).toBe(false); + expect(getNeutralIcon()).toBe(true); }); }); describe('expanded data', () => { beforeEach(async () => { - mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors); + mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedAndNewErrors); createComponent(); diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js index 2e8e70f25db..cb23b730a93 100644 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js +++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js @@ -17,9 +17,34 @@ export const codeQualityResponseNewErrors = { resolved_errors: [], existing_errors: [], summary: { - total: 2, + total: 12235, resolved: 0, - errored: 2, + errored: 12235, + }, +}; + +export const codeQualityResponseResolvedErrors = { + status: 'success', + new_errors: [], + resolved_errors: [ + { + description: "Parsing error: 'return' outside of function", + severity: 'minor', + file_path: 'index.js', + line: 12, + }, + { + description: 'TODO found', + severity: 'minor', + file_path: '.gitlab-ci.yml', + line: 73, + }, + ], + existing_errors: [], + summary: { + total: 12235, + resolved: 0, + errored: 12235, }, }; @@ -43,9 +68,9 @@ export const codeQualityResponseResolvedAndNewErrors = { ], existing_errors: [], summary: { - total: 2, + total: 12233, resolved: 1, - errored: 1, + errored: 12233, }, }; @@ -55,8 +80,8 @@ export const codeQualityResponseNoErrors = { resolved_errors: [], existing_errors: [], summary: { - total: 0, + total: 12234, resolved: 0, - errored: 0, + errored: 12234, }, }; diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js index d038660e6d3..015d394312a 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js @@ -34,7 +34,7 @@ describe('MRWidgetHowToMerge', () => { }); it('renders a selection of markdown fields', () => { - expect(findInstructionsFields().length).toBe(3); + expect(findInstructionsFields().length).toBe(2); }); it('renders a tip including a link to docs when a valid link is present', () => { @@ -48,23 +48,11 @@ describe('MRWidgetHowToMerge', () => { it('should render different instructions based on if the user can merge', () => { mountComponent({ props: { canMerge: true } }); - expect(findInstructionsFields().at(2).text()).toContain('git push origin'); - }); - - it('should render different instructions based on if the merge is based off a fork', () => { - mountComponent({ props: { isFork: true } }); - expect(findInstructionsFields().at(0).text()).toContain('FETCH_HEAD'); - }); - - it('escapes the target branch name shell-secure', () => { - mountComponent({ props: { targetBranch: '";echo$IFS"you_shouldnt_run_this' } }); - - expect(findInstructionsFields().at(1).text()).toContain('\'";echo$IFS"you_shouldnt_run_this\''); + expect(findInstructionsFields().at(1).text()).toContain('git push origin'); }); it('escapes the source branch name shell-secure', () => { mountComponent({ props: { sourceBranch: 'branch-of-$USER' } }); - expect(findInstructionsFields().at(0).text()).toContain("'branch-of-$USER'"); }); }); diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js index 07cbfe1e79b..4f24ec2d015 100644 --- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -1,6 +1,6 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -79,7 +79,7 @@ describe('CI Badge Link Component', () => { const findIcon = () => wrapper.findComponent(CiIcon); const createComponent = (propsData) => { - wrapper = shallowMount(CiBadge, { propsData }); + wrapper = shallowMount(CiBadgeLink, { propsData }); }; afterEach(() => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js index 66ef473f368..63c22aff3d5 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js @@ -4,7 +4,7 @@ import testAction from 'helpers/vuex_action_helper'; import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; import Api from '~/api'; import { createAlert } from '~/flash'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions'; import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; import initialState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state'; @@ -122,7 +122,7 @@ describe('Filters actions', () => { ':id', encodeURIComponent(projectEndpoint), ); - mock.onGet(url).replyOnce(httpStatusCodes.OK, mockBranches); + mock.onGet(url).replyOnce(HTTP_STATUS_OK, mockBranches); }); it('dispatches RECEIVE_BRANCHES_SUCCESS with received data', () => { @@ -143,7 +143,7 @@ describe('Filters actions', () => { describe('error', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); }); it('dispatches RECEIVE_BRANCHES_ERROR', () => { @@ -155,7 +155,7 @@ describe('Filters actions', () => { { type: types.REQUEST_BRANCHES }, { type: types.RECEIVE_BRANCHES_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -177,7 +177,7 @@ describe('Filters actions', () => { describe('success', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers); + mock.onAny().replyOnce(HTTP_STATUS_OK, filterUsers); }); it('dispatches RECEIVE_AUTHORS_SUCCESS with received data and groupEndpoint set', () => { @@ -215,7 +215,7 @@ describe('Filters actions', () => { describe('error', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); }); it('dispatches RECEIVE_AUTHORS_ERROR and groupEndpoint set', () => { @@ -227,7 +227,7 @@ describe('Filters actions', () => { { type: types.REQUEST_AUTHORS }, { type: types.RECEIVE_AUTHORS_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -246,7 +246,7 @@ describe('Filters actions', () => { { type: types.REQUEST_AUTHORS }, { type: types.RECEIVE_AUTHORS_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -261,7 +261,7 @@ describe('Filters actions', () => { describe('fetchMilestones', () => { describe('success', () => { beforeEach(() => { - mock.onGet(milestonesEndpoint).replyOnce(httpStatusCodes.OK, filterMilestones); + mock.onGet(milestonesEndpoint).replyOnce(HTTP_STATUS_OK, filterMilestones); }); it('dispatches RECEIVE_MILESTONES_SUCCESS with received data', () => { @@ -282,7 +282,7 @@ describe('Filters actions', () => { describe('error', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); }); it('dispatches RECEIVE_MILESTONES_ERROR', () => { @@ -294,7 +294,7 @@ describe('Filters actions', () => { { type: types.REQUEST_MILESTONES }, { type: types.RECEIVE_MILESTONES_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -307,7 +307,7 @@ describe('Filters actions', () => { describe('success', () => { let restoreVersion; beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers); + mock.onAny().replyOnce(HTTP_STATUS_OK, filterUsers); restoreVersion = gon.api_version; gon.api_version = 'v1'; }); @@ -352,7 +352,7 @@ describe('Filters actions', () => { describe('error', () => { let restoreVersion; beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); restoreVersion = gon.api_version; gon.api_version = 'v1'; }); @@ -370,7 +370,7 @@ describe('Filters actions', () => { { type: types.REQUEST_ASSIGNEES }, { type: types.RECEIVE_ASSIGNEES_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -389,7 +389,7 @@ describe('Filters actions', () => { { type: types.REQUEST_ASSIGNEES }, { type: types.RECEIVE_ASSIGNEES_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], @@ -404,7 +404,7 @@ describe('Filters actions', () => { describe('fetchLabels', () => { describe('success', () => { beforeEach(() => { - mock.onGet(labelsEndpoint).replyOnce(httpStatusCodes.OK, filterLabels); + mock.onGet(labelsEndpoint).replyOnce(HTTP_STATUS_OK, filterLabels); }); it('dispatches RECEIVE_LABELS_SUCCESS with received data', () => { @@ -425,7 +425,7 @@ describe('Filters actions', () => { describe('error', () => { beforeEach(() => { - mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + mock.onAny().replyOnce(HTTP_STATUS_SERVICE_UNAVAILABLE); }); it('dispatches RECEIVE_LABELS_ERROR', () => { @@ -437,7 +437,7 @@ describe('Filters actions', () => { { type: types.REQUEST_LABELS }, { type: types.RECEIVE_LABELS_ERROR, - payload: httpStatusCodes.SERVICE_UNAVAILABLE, + payload: HTTP_STATUS_SERVICE_UNAVAILABLE, }, ], [], diff --git a/spec/frontend/vue_shared/components/group_select/group_select_spec.js b/spec/frontend/vue_shared/components/group_select/group_select_spec.js index c10b32c6acc..87dd7795b98 100644 --- a/spec/frontend/vue_shared/components/group_select/group_select_spec.js +++ b/spec/frontend/vue_shared/components/group_select/group_select_spec.js @@ -1,20 +1,18 @@ import { nextTick } from 'vue'; -import { GlCollapsibleListbox } from '@gitlab/ui'; +import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; -import { createAlert } from '~/flash'; import GroupSelect from '~/vue_shared/components/group_select/group_select.vue'; import { TOGGLE_TEXT, + RESET_LABEL, FETCH_GROUPS_ERROR, FETCH_GROUP_ERROR, QUERY_TOO_SHORT_MESSAGE, } from '~/vue_shared/components/group_select/constants'; import waitForPromises from 'helpers/wait_for_promises'; -jest.mock('~/flash'); - describe('GroupSelect', () => { let wrapper; let mock; @@ -26,22 +24,34 @@ describe('GroupSelect', () => { }; const groupEndpoint = `/api/undefined/groups/${groupMock.id}`; + // Stubs + const GlAlert = { + template: '<div><slot /></div>', + }; + // Props + const label = 'label'; const inputName = 'inputName'; const inputId = 'inputId'; // Finders + const findFormGroup = () => wrapper.findComponent(GlFormGroup); const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findInput = () => wrapper.findByTestId('input'); + const findAlert = () => wrapper.findComponent(GlAlert); // Helpers const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(GroupSelect, { propsData: { + label, inputName, inputId, ...props, }, + stubs: { + GlAlert, + }, }); }; const openListbox = () => findListbox().vm.$emit('shown'); @@ -65,6 +75,12 @@ describe('GroupSelect', () => { mock.restore(); }); + it('passes the label to GlFormGroup', () => { + createComponent(); + + expect(findFormGroup().attributes('label')).toBe(label); + }); + describe('on mount', () => { it('fetches groups when the listbox is opened', async () => { createComponent(); @@ -94,13 +110,13 @@ describe('GroupSelect', () => { .reply(200, [{ full_name: 'notTheSelectedGroup', id: '2' }]); mock.onGet(groupEndpoint).reply(500); createComponent({ props: { initialSelection: groupMock.id } }); + + expect(findAlert().exists()).toBe(false); + await waitForPromises(); - expect(createAlert).toHaveBeenCalledWith({ - message: FETCH_GROUP_ERROR, - error: expect.any(Error), - parent: wrapper.vm.$el, - }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(FETCH_GROUP_ERROR); }); }); }); @@ -109,13 +125,12 @@ describe('GroupSelect', () => { mock.onGet('/api/undefined/groups.json').reply(500); createComponent(); openListbox(); + expect(findAlert().exists()).toBe(false); + await waitForPromises(); - expect(createAlert).toHaveBeenCalledWith({ - message: FETCH_GROUPS_ERROR, - error: expect.any(Error), - parent: wrapper.vm.$el, - }); + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(FETCH_GROUPS_ERROR); }); describe('selection', () => { @@ -186,7 +201,11 @@ describe('GroupSelect', () => { await waitForPromises(); expect(mock.history.get).toHaveLength(2); - expect(mock.history.get[1].params).toStrictEqual({ search: searchString }); + expect(mock.history.get[1].params).toStrictEqual({ + page: 1, + per_page: 20, + search: searchString, + }); }); it('shows a notice if the search query is too short', async () => { @@ -199,4 +218,105 @@ describe('GroupSelect', () => { expect(findListbox().props('noResultsText')).toBe(QUERY_TOO_SHORT_MESSAGE); }); }); + + describe('pagination', () => { + const searchString = 'searchString'; + + beforeEach(async () => { + let requestCount = 0; + mock.onGet('/api/undefined/groups.json').reply(({ params }) => { + requestCount += 1; + return [ + 200, + [ + { + full_name: `Group [page: ${params.page} - search: ${params.search}]`, + id: requestCount, + }, + ], + { + page: params.page, + 'x-total-pages': 3, + }, + ]; + }); + createComponent(); + openListbox(); + findListbox().vm.$emit('bottom-reached'); + return waitForPromises(); + }); + + it('fetches the next page when bottom is reached', async () => { + expect(mock.history.get).toHaveLength(2); + expect(mock.history.get[1].params).toStrictEqual({ + page: 2, + per_page: 20, + search: '', + }); + }); + + it('fetches the first page when the search query changes', async () => { + search(searchString); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(3); + expect(mock.history.get[2].params).toStrictEqual({ + page: 1, + per_page: 20, + search: searchString, + }); + }); + + it('retains the search query when infinite scrolling', async () => { + search(searchString); + await waitForPromises(); + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(4); + expect(mock.history.get[3].params).toStrictEqual({ + page: 2, + per_page: 20, + search: searchString, + }); + }); + + it('pauses infinite scroll after fetching the last page', async () => { + expect(findListbox().props('infiniteScroll')).toBe(true); + + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(false); + }); + + it('resumes infinite scroll when search query changes', async () => { + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(false); + + search(searchString); + await waitForPromises(); + + expect(findListbox().props('infiniteScroll')).toBe(true); + }); + }); + + it.each` + description | clearable | expectedLabel + ${'passes'} | ${true} | ${RESET_LABEL} + ${'does not pass'} | ${false} | ${''} + `( + '$description the reset button label to the listbox when clearable is $clearable', + ({ clearable, expectedLabel }) => { + createComponent({ + props: { + clearable, + }, + }); + + expect(findListbox().props('resetButtonLabel')).toBe(expectedLabel); + }, + ); }); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js index aea76f164f0..94e1ece8c6b 100644 --- a/spec/frontend/vue_shared/components/header_ci_component_spec.js +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -84,11 +84,12 @@ describe('Header CI Component', () => { expect(findUserLink().text()).toContain(defaultProps.user.username); }); - it('has the correct data attributes', () => { + it('has the correct HTML attributes', () => { expect(findUserLink().attributes()).toMatchObject({ 'data-user-id': defaultProps.user.id.toString(), 'data-username': defaultProps.user.username, 'data-name': defaultProps.user.name, + href: defaultProps.user.web_url, }); }); 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 cb7262b15e3..7ed6a59c844 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 @@ -1,11 +1,13 @@ import { shallowMount } from '@vue/test-utils'; -import { GlListbox } from '@gitlab/ui'; +import { GlFormGroup, GlListbox } from '@gitlab/ui'; import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue'; describe('ListboxInput', () => { let wrapper; // Props + const label = 'label'; + const decription = 'decription'; const name = 'name'; const defaultToggleText = 'defaultToggleText'; const items = [ @@ -21,30 +23,70 @@ describe('ListboxInput', () => { options: [{ text: 'Item 3', value: '3' }], }, ]; + const id = 'id'; // Finders + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); const findGlListbox = () => wrapper.findComponent(GlListbox); const findInput = () => wrapper.find('input'); const createComponent = (propsData) => { wrapper = shallowMount(ListboxInput, { propsData: { + label, + decription, name, defaultToggleText, items, ...propsData, }, + attrs: { + id, + }, }); }; - describe('input attributes', () => { + describe('wrapper', () => { + it.each` + description | labelProp | descriptionProp | rendersGlFormGroup + ${'does not render'} | ${''} | ${''} | ${false} + ${'renders'} | ${'labelProp'} | ${''} | ${true} + ${'renders'} | ${''} | ${'descriptionProp'} | ${true} + ${'renders'} | ${'labelProp'} | ${'descriptionProp'} | ${true} + `( + "$description a GlFormGroup when label is '$labelProp' and description is '$descriptionProp'", + ({ labelProp, descriptionProp, rendersGlFormGroup }) => { + createComponent({ label: labelProp, description: descriptionProp }); + + expect(findGlFormGroup().exists()).toBe(rendersGlFormGroup); + }, + ); + }); + + describe('options', () => { beforeEach(() => { createComponent(); }); + it('passes the label to the form group', () => { + expect(findGlFormGroup().attributes('label')).toBe(label); + }); + + it('passes the decription to the form group', () => { + expect(findGlFormGroup().attributes('decription')).toBe(decription); + }); + it('sets the input name', () => { expect(findInput().attributes('name')).toBe(name); }); + + it('is not filterable with few items', () => { + expect(findGlListbox().props('searchable')).toBe(false); + }); + + it('passes attributes to the root element', () => { + expect(findGlFormGroup().attributes('id')).toBe(id); + }); }); describe('toggle text', () => { @@ -91,12 +133,29 @@ describe('ListboxInput', () => { }); describe('search', () => { - beforeEach(() => { - createComponent(); + it('is searchable when there are more than 10 items', () => { + createComponent({ + items: [ + { + text: 'Group 1', + options: [...Array(10).keys()].map((index) => ({ + text: index + 1, + value: String(index + 1), + })), + }, + { + text: 'Group 2', + options: [{ text: 'Item 11', value: '11' }], + }, + ], + }); + + expect(findGlListbox().props('searchable')).toBe(true); }); it('passes all items to GlListbox by default', () => { createComponent(); + expect(findGlListbox().props('items')).toStrictEqual(items); }); 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 new file mode 100644 index 00000000000..34071775b9c --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js @@ -0,0 +1,58 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue'; + +describe('vue_shared/component/markdown/editor_mode_dropdown', () => { + let wrapper; + + const createComponent = ({ value, size } = {}) => { + wrapper = shallowMount(EditorModeDropdown, { + propsData: { + value, + size, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItem = (text) => + wrapper + .findAllComponents(GlDropdownItem) + .filter((item) => item.text().startsWith(text)) + .at(0); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + modeText | value | dropdownText | otherMode + ${'Rich text'} | ${'richText'} | ${'View markdown'} | ${'Markdown'} + ${'Markdown'} | ${'markdown'} | ${'View rich text'} | ${'Rich text'} + `('$modeText', ({ modeText, value, dropdownText, otherMode }) => { + beforeEach(() => { + createComponent({ value }); + }); + + it('shows correct dropdown label', () => { + expect(findDropdown().props('text')).toEqual(dropdownText); + }); + + it('checks correct checked dropdown item', () => { + expect(findDropdownItem(modeText).props().isChecked).toBe(true); + expect(findDropdownItem(otherMode).props().isChecked).toBe(false); + }); + + it('emits event on click', () => { + findDropdownItem(modeText).vm.$emit('click'); + + expect(wrapper.emitted().input).toEqual([[value]]); + }); + }); + + it('passes size to dropdown', () => { + createComponent({ size: 'small', value: 'markdown' }); + + expect(findDropdown().props('size')).toEqual('small'); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 285ea10c813..3b8e78bbadd 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -37,7 +37,7 @@ describe('Markdown field component', () => { axiosMock.restore(); }); - function createSubject({ lines = [], enablePreview = true } = {}) { + function createSubject({ lines = [], enablePreview = true, showContentEditorSwitcher } = {}) { // We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression // caused by mixing Vanilla JS and Vue. subject = mountExtended( @@ -68,6 +68,7 @@ describe('Markdown field component', () => { lines, enablePreview, restrictedToolBarItems, + showContentEditorSwitcher, }, }, ); @@ -191,6 +192,7 @@ describe('Markdown field component', () => { markdownDocsPath, quickActionsDocsPath: '', showCommentToolBar: true, + showContentEditorSwitcher: false, }); }); }); @@ -342,4 +344,18 @@ describe('Markdown field component', () => { restrictedToolBarItems, ); }); + + describe('showContentEditorSwitcher', () => { + it('defaults to false', () => { + createSubject(); + + expect(findMarkdownToolbar().props('showContentEditorSwitcher')).toBe(false); + }); + + it('passes showContentEditorSwitcher', () => { + createSubject({ showContentEditorSwitcher: true }); + + expect(findMarkdownToolbar().props('showContentEditorSwitcher')).toBe(true); + }); + }); }); 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 5f416db2676..e3df2cde1c1 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -1,4 +1,3 @@ -import { GlSegmentedControl } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; @@ -49,7 +48,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }, }); }; - const findSegmentedControl = () => wrapper.findComponent(GlSegmentedControl); const findMarkdownField = () => wrapper.findComponent(MarkdownField); const findTextarea = () => wrapper.find('textarea'); const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); @@ -97,36 +95,28 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findTextarea().element.value).toBe(value); }); - it('renders switch segmented control', () => { + it(`emits ${EDITING_MODE_CONTENT_EDITOR} event when enableContentEditor emitted from markdown editor`, async () => { buildWrapper(); - expect(findSegmentedControl().props()).toEqual({ - checked: EDITING_MODE_MARKDOWN_FIELD, - options: [ - { - text: expect.any(String), - value: EDITING_MODE_MARKDOWN_FIELD, - }, - { - text: expect.any(String), - value: EDITING_MODE_CONTENT_EDITOR, - }, - ], - }); - }); + findMarkdownField().vm.$emit('enableContentEditor'); - describe.each` - editingMode - ${EDITING_MODE_CONTENT_EDITOR} - ${EDITING_MODE_MARKDOWN_FIELD} - `('when segmented control emits change event with $editingMode value', ({ editingMode }) => { - it(`emits ${editingMode} event`, () => { - buildWrapper(); + await nextTick(); - findSegmentedControl().vm.$emit('change', editingMode); + expect(wrapper.emitted(EDITING_MODE_CONTENT_EDITOR)).toHaveLength(1); + }); - expect(wrapper.emitted(editingMode)).toHaveLength(1); + it(`emits ${EDITING_MODE_MARKDOWN_FIELD} event when enableMarkdownEditor emitted from content editor`, async () => { + buildWrapper({ + stubs: { ContentEditor: stubComponent(ContentEditor) }, }); + + findMarkdownField().vm.$emit('enableContentEditor'); + + await nextTick(); + + findContentEditor().vm.$emit('enableMarkdownEditor'); + + expect(wrapper.emitted(EDITING_MODE_MARKDOWN_FIELD)).toHaveLength(1); }); describe(`when editingMode is ${EDITING_MODE_MARKDOWN_FIELD}`, () => { @@ -159,11 +149,10 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(wrapper.emitted('keydown')).toHaveLength(1); }); - describe(`when segmented control triggers input event with ${EDITING_MODE_CONTENT_EDITOR} value`, () => { + describe(`when markdown field triggers enableContentEditor event`, () => { beforeEach(() => { buildWrapper(); - findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); - findSegmentedControl().vm.$emit('change', EDITING_MODE_CONTENT_EDITOR); + findMarkdownField().vm.$emit('enableContentEditor'); }); it('displays the content editor', () => { @@ -202,7 +191,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => { beforeEach(() => { buildWrapper(); - findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + findMarkdownField().vm.$emit('enableContentEditor'); }); describe('when autofocus is true', () => { @@ -234,9 +223,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(wrapper.emitted('keydown')).toEqual([[event]]); }); - describe(`when segmented control triggers input event with ${EDITING_MODE_MARKDOWN_FIELD} value`, () => { + describe(`when richText editor triggers enableMarkdownEditor event`, () => { beforeEach(() => { - findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD); + findContentEditor().vm.$emit('enableMarkdownEditor'); }); it('hides the content editor', () => { @@ -251,29 +240,5 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_MARKDOWN_FIELD); }); }); - - describe('when content editor emits loading event', () => { - beforeEach(() => { - findContentEditor().vm.$emit('loading'); - }); - - it('disables switch editing mode control', () => { - // This is the only way that I found to check the segmented control is disabled - expect(findSegmentedControl().find('input[disabled]').exists()).toBe(true); - }); - - describe.each` - event - ${'loadingSuccess'} - ${'loadingError'} - `('when content editor emits $event event', ({ event }) => { - beforeEach(() => { - findContentEditor().vm.$emit(event); - }); - it('enables the switch editing mode control', () => { - expect(findSegmentedControl().find('input[disabled]').exists()).toBe(false); - }); - }); - }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js index f698794b951..b1a1dbbeb7a 100644 --- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import Toolbar from '~/vue_shared/components/markdown/toolbar.vue'; +import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue'; describe('toolbar', () => { let wrapper; @@ -47,4 +48,18 @@ describe('toolbar', () => { expect(wrapper.find('.comment-toolbar').exists()).toBe(true); }); }); + + describe('with content editor switcher', () => { + beforeEach(() => { + createMountedWrapper({ + showContentEditorSwitcher: true, + }); + }); + + it('re-emits event from switcher', () => { + wrapper.findComponent(EditorModeDropdown).vm.$emit('input', 'richText'); + + expect(wrapper.emitted('enableContentEditor')).toEqual([[]]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap deleted file mode 100644 index 2ea8985b16a..00000000000 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap +++ /dev/null @@ -1,177 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` -<gl-modal-stub - actionprimary="[object Object]" - actionsecondary="[object Object]" - arialabel="" - dismisslabel="Close" - modalclass="" - modalid="runner-aws-deployments-modal" - size="sm" - title="Deploy GitLab Runner in AWS" - titletag="h4" -> - <p> - Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console. - </p> - - <gl-form-radio-group-stub - checked="[object Object]" - disabledfield="disabled" - htmlfield="html" - label="Choose your preferred GitLab Runner" - label-sr-only="" - options="" - textfield="text" - valuefield="value" - > - <gl-form-radio-stub - class="gl-py-5 gl-pl-8 gl-border-b" - value="[object Object]" - > - <div - class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold" - > - - Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. - - <gl-accordion-stub - class="gl-pt-3" - headerlevel="3" - > - <gl-accordion-item-stub - class="gl-font-weight-normal" - headerclass="" - title="More Details" - titlevisible="Less Details" - > - <p - class="gl-pt-2" - > - No spot. This is the default choice for Linux Docker executor. - </p> - - <p - class="gl-m-0" - > - A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet. - </p> - </gl-accordion-item-stub> - </gl-accordion-stub> - </div> - </gl-form-radio-stub> - <gl-form-radio-stub - class="gl-py-5 gl-pl-8 gl-border-b" - value="[object Object]" - > - <div - class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold" - > - - Amazon Linux 2 Docker HA with manual scaling and optional scheduling. 100% spot. - - <gl-accordion-stub - class="gl-pt-3" - headerlevel="3" - > - <gl-accordion-item-stub - class="gl-font-weight-normal" - headerclass="" - title="More Details" - titlevisible="Less Details" - > - <p - class="gl-pt-2" - > - 100% spot. - </p> - - <p - class="gl-m-0" - > - Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet. - </p> - </gl-accordion-item-stub> - </gl-accordion-stub> - </div> - </gl-form-radio-stub> - <gl-form-radio-stub - class="gl-py-5 gl-pl-8 gl-border-b" - value="[object Object]" - > - <div - class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold" - > - - Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. - - <gl-accordion-stub - class="gl-pt-3" - headerlevel="3" - > - <gl-accordion-item-stub - class="gl-font-weight-normal" - headerclass="" - title="More Details" - titlevisible="Less Details" - > - <p - class="gl-pt-2" - > - No spot. Default choice for Windows Shell executor. - </p> - - <p - class="gl-m-0" - > - Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet. - </p> - </gl-accordion-item-stub> - </gl-accordion-stub> - </div> - </gl-form-radio-stub> - <gl-form-radio-stub - class="gl-py-5 gl-pl-8" - value="[object Object]" - > - <div - class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold" - > - - Windows 2019 Shell with manual scaling and optional scheduling. 100% spot. - - <gl-accordion-stub - class="gl-pt-3" - headerlevel="3" - > - <gl-accordion-item-stub - class="gl-font-weight-normal" - headerclass="" - title="More Details" - titlevisible="Less Details" - > - <p - class="gl-pt-2" - > - 100% spot. - </p> - - <p - class="gl-m-0" - > - Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet. - </p> - </gl-accordion-item-stub> - </gl-accordion-stub> - </div> - </gl-form-radio-stub> - </gl-form-radio-group-stub> - - <p> - <gl-sprintf-stub - message="Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}." - /> - </p> -</gl-modal-stub> -`; diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js index a9ba4946358..c8ca75787f1 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js @@ -1,30 +1,28 @@ -import { GlModal, GlFormRadio } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { getBaseURL, visitUrl } from '~/lib/utils/url_utility'; -import { mockTracking } from 'helpers/tracking_helper'; -import { - CF_BASE_URL, - TEMPLATES_BASE_URL, - EASY_BUTTONS, -} from '~/vue_shared/components/runner_aws_deployments/constants'; +import { s__ } from '~/locale'; import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue'; +import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), visitUrl: jest.fn(), })); +const mockModalId = 'runner-aws-deployments-modal'; + describe('RunnerAwsDeploymentsModal', () => { let wrapper; const findModal = () => wrapper.findComponent(GlModal); - const findEasyButtons = () => wrapper.findAllComponents(GlFormRadio); + const findRunnerAwsInstructions = () => wrapper.findComponent(RunnerAwsInstructions); - const createComponent = () => { + const createComponent = (options) => { wrapper = shallowMount(RunnerAwsDeploymentsModal, { propsData: { - modalId: 'runner-aws-deployments-modal', + modalId: mockModalId, }, + ...options, }); }; @@ -36,39 +34,39 @@ describe('RunnerAwsDeploymentsModal', () => { wrapper.destroy(); }); - it('renders the modal', () => { - expect(wrapper.element).toMatchSnapshot(); + it('renders modal', () => { + expect(findModal().props()).toMatchObject({ + size: 'sm', + modalId: mockModalId, + title: s__('Runners|Deploy GitLab Runner in AWS'), + }); + expect(findModal().attributes()).toMatchObject({ + 'hide-footer': '', + }); }); - it('should contain all easy buttons', () => { - expect(findEasyButtons()).toHaveLength(EASY_BUTTONS.length); + it('renders modal contents', () => { + expect(findRunnerAwsInstructions().exists()).toBe(true); }); - describe('first easy button', () => { - it('should contain the correct description', () => { - expect(findEasyButtons().at(0).text()).toContain(EASY_BUTTONS[0].description); - }); - - it('should contain the correct link', () => { - const templateUrl = encodeURIComponent(TEMPLATES_BASE_URL + EASY_BUTTONS[0].templateName); - const { stackName } = EASY_BUTTONS[0]; - const instanceUrl = encodeURIComponent(getBaseURL()); - const url = `${CF_BASE_URL}templateURL=${templateUrl}&stackName=${stackName}¶m_3GITLABRunnerInstanceURL=${instanceUrl}`; - - findModal().vm.$emit('primary'); + it('when contents trigger closing, modal closes', () => { + const mockClose = jest.fn(); - expect(visitUrl).toHaveBeenCalledWith(url, true); + createComponent({ + stubs: { + GlModal: { + template: '<div><slot/></div>', + methods: { + close: mockClose, + }, + }, + }, }); - it('should track an event when clicked', () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + expect(mockClose).toHaveBeenCalledTimes(0); - findModal().vm.$emit('primary'); + findRunnerAwsInstructions().vm.$emit('close'); - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { - label: EASY_BUTTONS[0].stackName, - }); - }); + expect(mockClose).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap new file mode 100644 index 00000000000..d14f66df8a1 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_docker_instructions_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerDockerInstructions renders contents 1`] = `"To install Runner in a container follow the instructions described in the GitLab documentation View installation instructions Close"`; diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap new file mode 100644 index 00000000000..1172bf07dff --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/__snapshots__/runner_kubernetes_instructions_spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerKubernetesInstructions renders contents 1`] = `"To install Runner in Kubernetes follow the instructions described in the GitLab documentation. View installation instructions Close"`; diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js new file mode 100644 index 00000000000..4d566dbec0c --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_aws_instructions_spec.js @@ -0,0 +1,117 @@ +import { + GlAccordion, + GlAccordionItem, + GlButton, + GlFormRadio, + GlFormRadioGroup, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { getBaseURL, visitUrl } from '~/lib/utils/url_utility'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { + AWS_README_URL, + AWS_CF_BASE_URL, + AWS_TEMPLATES_BASE_URL, + AWS_EASY_BUTTONS, +} from '~/vue_shared/components/runner_instructions/constants'; +import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; +import { __ } from '~/locale'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('RunnerAwsInstructions', () => { + let wrapper; + + const findEasyButtonsRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + const findEasyButtons = () => wrapper.findAllComponents(GlFormRadio); + const findEasyButtonAt = (i) => findEasyButtons().at(i); + const findLink = () => wrapper.findComponent(GlLink); + const findOkButton = () => + wrapper + .findAllComponents(GlButton) + .filter((w) => w.props('variant') === 'confirm') + .at(0); + const findCloseButton = () => wrapper.findByText(__('Close')); + + const createComponent = () => { + wrapper = shallowMountExtended(RunnerAwsInstructions, { + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should contain every button', () => { + expect(findEasyButtons()).toHaveLength(AWS_EASY_BUTTONS.length); + }); + + const AWS_EASY_BUTTONS_PARAMS = AWS_EASY_BUTTONS.map((val, idx) => ({ ...val, idx })); + + describe.each(AWS_EASY_BUTTONS_PARAMS)( + 'easy button %#', + ({ idx, description, moreDetails1, moreDetails2, templateName, stackName }) => { + it('should contain button description', () => { + const text = findEasyButtonAt(idx).text(); + + expect(text).toContain(description); + expect(text).toContain(moreDetails1); + expect(text).toContain(moreDetails2); + }); + + it('should show more details', () => { + const accordion = findEasyButtonAt(idx).findComponent(GlAccordion); + const accordionItem = accordion.findComponent(GlAccordionItem); + + expect(accordion.props('headerLevel')).toBe(3); + expect(accordionItem.props('title')).toBe(__('More Details')); + expect(accordionItem.props('titleVisible')).toBe(__('Less Details')); + }); + + describe('when clicked', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findEasyButtonsRadioGroup().vm.$emit('input', idx); + findOkButton().vm.$emit('click'); + }); + + it('should contain the correct link', () => { + const templateUrl = encodeURIComponent(AWS_TEMPLATES_BASE_URL + templateName); + const instanceUrl = encodeURIComponent(getBaseURL()); + const url = `${AWS_CF_BASE_URL}templateURL=${templateUrl}&stackName=${stackName}¶m_3GITLABRunnerInstanceURL=${instanceUrl}`; + + expect(visitUrl).toHaveBeenCalledTimes(1); + expect(visitUrl).toHaveBeenCalledWith(url, true); + }); + + it('should track an event when clicked', () => { + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: stackName, + }); + }); + }); + }, + ); + + it('displays link with more information', () => { + expect(findLink().attributes('href')).toBe(AWS_README_URL); + }); + + it('triggers the modal to close', () => { + findCloseButton().vm.$emit('click'); + + expect(wrapper.emitted('close')).toHaveLength(1); + }); +}); 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 new file mode 100644 index 00000000000..f9d700fe67f --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js @@ -0,0 +1,169 @@ +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql'; +import RunnerCliInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue'; + +import { mockRunnerPlatforms, mockInstructions, mockInstructionsWindows } from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('@gitlab/ui/dist/utils'); + +const mockPlatforms = mockRunnerPlatforms.data.runnerPlatforms.nodes.map( + ({ name, humanReadableName, architectures }) => ({ + name, + humanReadableName, + architectures: architectures?.nodes || [], + }), +); + +const [mockPlatform, mockPlatform2] = mockPlatforms; +const mockArchitectures = mockPlatform.architectures; + +describe('RunnerCliInstructions component', () => { + let wrapper; + let fakeApollo; + let runnerSetupInstructionsHandler; + + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findComponent(GlAlert); + const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); + const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); + const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); + const findRegisterCommand = () => wrapper.findByTestId('register-command'); + + const createComponent = ({ props, ...options } = {}) => { + const requestHandlers = [[getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler]]; + + fakeApollo = createMockApollo(requestHandlers); + + wrapper = extendedWrapper( + shallowMount(RunnerCliInstructions, { + propsData: { + platform: mockPlatform, + registrationToken: 'MY_TOKEN', + ...props, + }, + apolloProvider: fakeApollo, + ...options, + }), + ); + }; + + beforeEach(() => { + runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockInstructions); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when the instructions are shown', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should not show alert', async () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should contain a number of dropdown items for the architecture options', () => { + expect(findArchitectureDropdownItems()).toHaveLength( + mockRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, + ); + }); + + describe('should display instructions', () => { + const { installInstructions } = mockInstructions.data.runnerSetup; + + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ + platform: 'linux', + architecture: 'amd64', + }); + }); + + it('binary instructions are shown', async () => { + const instructions = findBinaryInstructions().text(); + + expect(instructions).toBe(installInstructions.trim()); + }); + + it('register command is shown with a replaced token', async () => { + const command = findRegisterCommand().text(); + + expect(command).toBe( + 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN', + ); + }); + + it('architecture download link is shown', () => { + expect(findBinaryDownloadButton().attributes('href')).toBe( + mockArchitectures[0].downloadLocation, + ); + }); + }); + + describe('after another platform and architecture are selected', () => { + beforeEach(async () => { + runnerSetupInstructionsHandler.mockResolvedValue(mockInstructionsWindows); + + findArchitectureDropdownItems().at(1).vm.$emit('click'); + + wrapper.setProps({ platform: mockPlatform2 }); + await waitForPromises(); + }); + + it('runner instructions are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ + platform: mockPlatform2.name, + architecture: mockPlatform2.architectures[0].name, + }); + }); + }); + }); + + describe('when a register token is not known', () => { + beforeEach(async () => { + createComponent({ props: { registrationToken: undefined } }); + await waitForPromises(); + }); + + it('register command is shown without a defined registration token', () => { + const instructions = findRegisterCommand().text(); + + expect(instructions).toBe(mockInstructions.data.runnerSetup.registerInstructions); + }); + }); + + describe('when apollo is loading', () => { + it('should show a loading icon', async () => { + createComponent(); + + expect(findGlLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findGlLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when instructions cannot be loaded', () => { + beforeEach(async () => { + runnerSetupInstructionsHandler.mockRejectedValue(); + + createComponent(); + await waitForPromises(); + }); + + it('should show alert', () => { + expect(wrapper.emitted()).toEqual({ error: [[]] }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js new file mode 100644 index 00000000000..2922d261b24 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_docker_instructions_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlButton } from '@gitlab/ui'; +import RunnerDockerInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue'; + +describe('RunnerDockerInstructions', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(RunnerDockerInstructions, {}); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + createComponent(); + }); + + it('renders contents', () => { + expect(wrapper.text().replace(/\s+/g, ' ')).toMatchSnapshot(); + }); + + it('renders link', () => { + expect(findButton().attributes('href')).toBe( + 'https://docs.gitlab.com/runner/install/docker.html', + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js new file mode 100644 index 00000000000..0bfcc0e3d86 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlButton } from '@gitlab/ui'; +import RunnerKubernetesInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue'; + +describe('RunnerKubernetesInstructions', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(RunnerKubernetesInstructions, {}); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + createComponent(); + }); + + it('renders contents', () => { + expect(wrapper.text().replace(/\s+/g, ' ')).toMatchSnapshot(); + }); + + it('renders link', () => { + expect(findButton().attributes('href')).toBe( + 'https://docs.gitlab.com/runner/install/kubernetes.html', + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js index 79cacadd6af..add334f166c 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js +++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js @@ -1,5 +1,5 @@ -import mockGraphqlRunnerPlatforms from 'test_fixtures/graphql/runner_instructions/get_runner_platforms.query.graphql.json'; -import mockGraphqlInstructions from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.json'; -import mockGraphqlInstructionsWindows from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.windows.json'; +import mockRunnerPlatforms from 'test_fixtures/graphql/runner_instructions/get_runner_platforms.query.graphql.json'; +import mockInstructions from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.json'; +import mockInstructionsWindows from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.windows.json'; -export { mockGraphqlRunnerPlatforms, mockGraphqlInstructions, mockGraphqlInstructionsWindows }; +export { mockRunnerPlatforms, mockInstructions, mockInstructionsWindows }; 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 ae9157591c5..19f2dd137ff 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 @@ -1,4 +1,4 @@ -import { GlAlert, GlModal, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlModal, GlButton, GlSkeletonLoader } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -6,15 +6,13 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; -import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; +import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import RunnerCliInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue'; +import RunnerDockerInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue'; +import RunnerKubernetesInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue'; -import { - mockGraphqlRunnerPlatforms, - mockGraphqlInstructions, - mockGraphqlInstructionsWindows, -} from './mock_data'; +import { mockRunnerPlatforms } from './mock_data'; Vue.use(VueApollo); @@ -40,24 +38,16 @@ describe('RunnerInstructionsModal component', () => { let wrapper; let fakeApollo; let runnerPlatformsHandler; - let runnerSetupInstructionsHandler; const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); const findModal = () => wrapper.findComponent(GlModal); const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons'); const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton); - const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); - const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); - const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); - const findRegisterCommand = () => wrapper.findByTestId('register-command'); + const findRunnerCliInstructions = () => wrapper.findComponent(RunnerCliInstructions); const createComponent = ({ props, shown = true, ...options } = {}) => { - const requestHandlers = [ - [getRunnerPlatformsQuery, runnerPlatformsHandler], - [getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler], - ]; + const requestHandlers = [[getRunnerPlatformsQuery, runnerPlatformsHandler]]; fakeApollo = createMockApollo(requestHandlers); @@ -80,8 +70,7 @@ describe('RunnerInstructionsModal component', () => { }; beforeEach(() => { - runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms); - runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions); + runnerPlatformsHandler = jest.fn().mockResolvedValue(mockRunnerPlatforms); }); afterEach(() => { @@ -103,90 +92,15 @@ describe('RunnerInstructionsModal component', () => { const buttons = findPlatformButtons(); - expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); + expect(buttons).toHaveLength(mockRunnerPlatforms.data.runnerPlatforms.nodes.length); }); - it('should contain a number of dropdown items for the architecture options', () => { - expect(findArchitectureDropdownItems()).toHaveLength( - mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, - ); - }); - - describe('should display default instructions', () => { - const { installInstructions } = mockGraphqlInstructions.data.runnerSetup; - - it('runner instructions are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ - platform: 'linux', - architecture: 'amd64', - }); - }); - - it('binary instructions are shown', async () => { - const instructions = findBinaryInstructions().text(); - - expect(instructions).toBe(installInstructions.trim()); - }); - - it('register command is shown with a replaced token', async () => { - const command = findRegisterCommand().text(); - - expect(command).toBe( - 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN', - ); - }); - }); - - describe('after a platform and architecture are selected', () => { - const windowsIndex = 2; - const { installInstructions } = mockGraphqlInstructionsWindows.data.runnerSetup; - - beforeEach(async () => { - runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows); - - findPlatformButtons().at(windowsIndex).vm.$emit('click'); - await waitForPromises(); - }); + it('should display architecture options', () => { + const { architectures } = findRunnerCliInstructions().props('platform'); - it('runner instructions are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ - platform: 'windows', - architecture: 'amd64', - }); - }); - - it('architecture download link is updated', () => { - const architectures = - mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[windowsIndex].architectures.nodes; - - expect(findBinaryDownloadButton().attributes('href')).toBe( - architectures[0].downloadLocation, - ); - }); - - it('other binary instructions are shown', () => { - const instructions = findBinaryInstructions().text(); - - expect(instructions).toBe(installInstructions.trim()); - }); - - it('register command is shown', () => { - const command = findRegisterCommand().text(); - - expect(command).toBe( - './gitlab-runner.exe register --url http://localhost/ --registration-token MY_TOKEN', - ); - }); - - it('runner instructions are requested with another architecture', async () => { - findArchitectureDropdownItems().at(1).vm.$emit('click'); - await waitForPromises(); - - expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ - platform: 'windows', - architecture: '386', - }); - }); + expect(architectures).toEqual( + mockRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes, + ); }); describe('when the modal resizes', () => { @@ -206,16 +120,14 @@ describe('RunnerInstructionsModal component', () => { }); }); - describe('when a register token is not known', () => { + describe.each([null, 'DEFINED'])('when registration token is %p', (token) => { beforeEach(async () => { - createComponent({ props: { registrationToken: undefined } }); + createComponent({ props: { registrationToken: token } }); await waitForPromises(); }); it('register command is shown without a defined registration token', () => { - const instructions = findRegisterCommand().text(); - - expect(instructions).toBe(mockGraphqlInstructions.data.runnerSetup.registerInstructions); + expect(findRunnerCliInstructions().props('registrationToken')).toBe(token); }); }); @@ -225,21 +137,33 @@ describe('RunnerInstructionsModal component', () => { await waitForPromises(); }); - it('runner instructions for the default selected platform are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ - platform: 'osx', - architecture: 'amd64', - }); + it('should preselect', () => { + const selected = findPlatformButtons() + .filter((btn) => btn.props('selected')) + .at(0); + + expect(selected.text()).toBe('macOS'); }); - it('sets the focus on the default selected platform', () => { - const findOsxPlatformButton = () => wrapper.findComponent({ ref: 'osx' }); + it('runner instructions for the default selected platform are requested', () => { + const { name } = findRunnerCliInstructions().props('platform'); - findOsxPlatformButton().element.focus = jest.fn(); + expect(name).toBe('osx'); + }); + }); - findModal().vm.$emit('shown'); + describe.each` + platform | component + ${'docker'} | ${RunnerDockerInstructions} + ${'kubernetes'} | ${RunnerKubernetesInstructions} + `('with platform "$platform"', ({ platform, component }) => { + beforeEach(async () => { + createComponent({ props: { defaultPlatformName: platform } }); + await waitForPromises(); + }); - expect(findOsxPlatformButton().element.focus).toHaveBeenCalled(); + it(`runner instructions for ${platform} are shown`, () => { + expect(wrapper.findComponent(component).exists()).toBe(true); }); }); @@ -251,7 +175,6 @@ describe('RunnerInstructionsModal component', () => { it('does not fetch instructions', () => { expect(runnerPlatformsHandler).not.toHaveBeenCalled(); - expect(runnerSetupInstructionsHandler).not.toHaveBeenCalled(); }); }); @@ -259,43 +182,41 @@ describe('RunnerInstructionsModal component', () => { it('should show a skeleton loader', async () => { createComponent(); await nextTick(); - await nextTick(); expect(findSkeletonLoader().exists()).toBe(true); - expect(findGlLoadingIcon().exists()).toBe(false); - - // wait on fetch of both `platforms` and `instructions` - await nextTick(); - await nextTick(); - - expect(findGlLoadingIcon().exists()).toBe(true); }); it('once loaded, should not show a loading state', async () => { createComponent(); - await waitForPromises(); expect(findSkeletonLoader().exists()).toBe(false); - expect(findGlLoadingIcon().exists()).toBe(false); }); }); - describe('when instructions cannot be loaded', () => { - beforeEach(async () => { - runnerSetupInstructionsHandler.mockRejectedValue(); + describe('errors', () => { + it('should show an alert when platforms cannot be loaded', async () => { + runnerPlatformsHandler.mockRejectedValue(); createComponent(); await waitForPromises(); - }); - it('should show alert', () => { expect(findAlert().exists()).toBe(true); }); - it('should not show instructions', () => { - expect(findBinaryInstructions().exists()).toBe(false); - expect(findRegisterCommand().exists()).toBe(false); + it('should show alert when instructions cannot be loaded', async () => { + createComponent(); + await waitForPromises(); + + findRunnerCliInstructions().vm.$emit('error'); + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + + findAlert().vm.$emit('dismiss'); + await nextTick(); + + expect(findAlert().exists()).toBe(false); }); }); @@ -312,14 +233,16 @@ describe('RunnerInstructionsModal component', () => { describe('show()', () => { let mockShow; + let mockClose; beforeEach(() => { mockShow = jest.fn(); + mockClose = jest.fn(); createComponent({ shown: false, stubs: { - GlModal: getGlModalStub({ show: mockShow }), + GlModal: getGlModalStub({ show: mockShow, close: mockClose }), }, }); }); @@ -329,6 +252,12 @@ describe('RunnerInstructionsModal component', () => { expect(mockShow).toHaveBeenCalledTimes(1); }); + + it('delegates close()', () => { + wrapper.vm.close(); + + expect(mockClose).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index 33f370efdfa..5461d38599d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -90,6 +90,17 @@ describe('Source Viewer component', () => { }); }); + describe('legacy fallbacks', () => { + it('tracks a fallback event and emits an error when viewing python files', () => { + const fallbackLanguage = 'python'; + const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage }; + createComponent({ language: fallbackLanguage }); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData); + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + describe('highlight.js', () => { beforeEach(() => createComponent({ language: mappedLanguage })); @@ -114,10 +125,10 @@ describe('Source Viewer component', () => { }); it('correctly maps languages starting with uppercase', async () => { - await createComponent({ language: 'Python3' }); - const languageDefinition = await import(`highlight.js/lib/languages/python`); + await createComponent({ language: 'Ruby' }); + const languageDefinition = await import(`highlight.js/lib/languages/ruby`); - expect(hljs.registerLanguage).toHaveBeenCalledWith('python', languageDefinition.default); + expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default); }); it('highlights the first chunk', () => { diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js index e5f56c63031..c8351ed61d7 100644 --- a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js @@ -1,4 +1,5 @@ import { GlDropdownItem, GlDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; import { formatTimezone } from '~/lib/utils/datetime_utility'; @@ -105,7 +106,14 @@ describe('Deploy freeze timezone dropdown', () => { }); it('renders selected time zone as dropdown label', () => { - expect(wrapper.findComponent(GlDropdown).props().text).toBe('[UTC + 2] Berlin'); + expect(wrapper.findComponent(GlDropdown).props().text).toBe('[UTC+2] Berlin'); + }); + + it('adds a checkmark to the selected option', async () => { + const selectedTZOption = findAllDropdownItems().at(0); + selectedTZOption.vm.$emit('click'); + await nextTick(); + expect(selectedTZOption.attributes('ischecked')).toBe('true'); }); }); }); 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 3b0f0fe6e73..2a0d2089fe3 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -7,15 +7,19 @@ import WebIdeLink, { i18n, PREFERRED_EDITOR_RESET_KEY, PREFERRED_EDITOR_KEY, - KEY_WEB_IDE, } from '~/vue_shared/components/web_ide_link.vue'; import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { KEY_WEB_IDE } from '~/vue_shared/components/constants'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility'); + const TEST_EDIT_URL = '/gitlab-test/test/-/edit/main/'; const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/main/-/'; const TEST_GITPOD_URL = 'https://gitpod.test/'; @@ -52,6 +56,7 @@ const ACTION_WEB_IDE = { 'data-track-action': 'click_consolidated_edit_ide', 'data-track-label': 'web_ide', }, + handle: expect.any(Function), }; const ACTION_WEB_IDE_CONFIRM_FORK = { ...ACTION_WEB_IDE, @@ -258,6 +263,14 @@ describe('Web IDE link component', () => { selectedKey: ACTION_PIPELINE_EDITOR.key, }); }); + + it('when web ide button is clicked it opens in a new tab', async () => { + findActionsButton().props('actions')[1].handle({ + preventDefault: jest.fn(), + }); + await nextTick(); + expect(visitUrl).toHaveBeenCalledWith(ACTION_WEB_IDE.href, true); + }); }); describe('with multiple actions', () => { 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 e5594b6d37e..159be4cd1ef 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 @@ -5,9 +5,12 @@ import { nextTick } from 'vue'; import IssuableEditForm from '~/vue_shared/issuable/show/components/issuable_edit_form.vue'; import IssuableEventHub from '~/vue_shared/issuable/show/event_hub'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import Autosave from '~/autosave'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; +jest.mock('~/autosave'); + const issuableEditFormProps = { issuable: mockIssuable, ...mockIssuableShowProps, @@ -36,10 +39,12 @@ describe('IssuableEditForm', () => { beforeEach(() => { wrapper = createComponent(); + jest.spyOn(Autosave.prototype, 'reset'); }); afterEach(() => { wrapper.destroy(); + jest.resetAllMocks(); }); describe('watch', () => { @@ -100,21 +105,18 @@ describe('IssuableEditForm', () => { describe('methods', () => { describe('initAutosave', () => { - it('initializes `autosaveTitle` and `autosaveDescription` props', () => { - expect(wrapper.vm.autosaveTitle).toBeDefined(); - expect(wrapper.vm.autosaveDescription).toBeDefined(); + it('initializes autosave', () => { + expect(Autosave.mock.calls).toEqual([ + [expect.any(Element), ['/', '', 'title']], + [expect.any(Element), ['/', '', 'description']], + ]); }); }); describe('resetAutosave', () => { - it('calls `reset` on `autosaveTitle` and `autosaveDescription` props', () => { - jest.spyOn(wrapper.vm.autosaveTitle, 'reset').mockImplementation(jest.fn); - jest.spyOn(wrapper.vm.autosaveDescription, 'reset').mockImplementation(jest.fn); - - wrapper.vm.resetAutosave(); - - expect(wrapper.vm.autosaveTitle.reset).toHaveBeenCalled(); - expect(wrapper.vm.autosaveDescription.reset).toHaveBeenCalled(); + it('resets title and description on "update.issuable event"', () => { + IssuableEventHub.$emit('update.issuable'); + expect(Autosave.prototype.reset.mock.calls).toEqual([[], []]); }); }); }); diff --git a/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_body_spec.js.snap b/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_body_spec.js.snap new file mode 100644 index 00000000000..52838dcd0bc --- /dev/null +++ b/spec/frontend/work_items/components/notes/__snapshots__/work_item_note_body_spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Work Item Note Body should have the wrapper to show the note body 1`] = ` +"<div data-testid=\\"work-item-note-body\\" class=\\"note-text md\\"> + <p dir=\\"auto\\" data-sourcepos=\\"1:1-1:76\\"> + <gl-emoji data-unicode-version=\\"6.0\\" data-name=\\"wave\\" title=\\"waving hand sign\\">👋</gl-emoji> Hi <a title=\\"Sherie Nitzsche\\" class=\\"gfm gfm-project_member js-user-link\\" data-placement=\\"top\\" data-container=\\"body\\" data-user=\\"3\\" data-reference-type=\\"user\\" href=\\"/fredda.brekke\\">@fredda.brekke</a> How are you ? what do you think about this ? <gl-emoji data-unicode-version=\\"6.0\\" data-name=\\"pray\\" title=\\"person with folded hands\\">🙏</gl-emoji> + </p> +</div>" +`; diff --git a/spec/frontend/work_items/components/notes/activity_filter_spec.js b/spec/frontend/work_items/components/notes/activity_filter_spec.js new file mode 100644 index 00000000000..eb4bcbf942b --- /dev/null +++ b/spec/frontend/work_items/components/notes/activity_filter_spec.js @@ -0,0 +1,74 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { ASC, DESC } from '~/notes/constants'; + +import { mockTracking } from 'helpers/tracking_helper'; +import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; + +describe('Activity Filter', () => { + let wrapper; + + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findNewestFirstItem = () => wrapper.findByTestId('js-newest-first'); + + const createComponent = ({ sortOrder = ASC, loading = false, workItemType = 'Task' } = {}) => { + wrapper = shallowMountExtended(ActivityFilter, { + propsData: { + sortOrder, + loading, + workItemType, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('default', () => { + it('has a dropdown with 2 options', () => { + expect(findDropdown().exists()).toBe(true); + expect(findAllDropdownItems()).toHaveLength(ActivityFilter.SORT_OPTIONS.length); + }); + + it('has local storage sync with the correct props', () => { + expect(findLocalStorageSync().props('asString')).toBe(true); + }); + + it('emits `updateSavedSortOrder` event when update is emitted', async () => { + findLocalStorageSync().vm.$emit('input', ASC); + + await nextTick(); + expect(wrapper.emitted('updateSavedSortOrder')).toHaveLength(1); + expect(wrapper.emitted('updateSavedSortOrder')).toEqual([[ASC]]); + }); + }); + + describe('when asc', () => { + describe('when the dropdown is clicked', () => { + it('calls the right actions', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + findNewestFirstItem().vm.$emit('click'); + await nextTick(); + + expect(wrapper.emitted('changeSortOrder')).toHaveLength(1); + expect(wrapper.emitted('changeSortOrder')).toEqual([[DESC]]); + + expect(trackingSpy).toHaveBeenCalledWith( + TRACKING_CATEGORY_SHOW, + 'notes_sort_order_changed', + { + category: TRACKING_CATEGORY_SHOW, + label: 'item_track_notes_sorting', + property: 'type_Task', + }, + ); + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/notes/work_item_note_body_spec.js b/spec/frontend/work_items/components/notes/work_item_note_body_spec.js new file mode 100644 index 00000000000..4fcbcfcaf30 --- /dev/null +++ b/spec/frontend/work_items/components/notes/work_item_note_body_spec.js @@ -0,0 +1,32 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WorkItemNoteBody from '~/work_items/components/notes/work_item_note_body.vue'; +import NoteEditedText from '~/notes/components/note_edited_text.vue'; +import { mockWorkItemCommentNote } from 'jest/work_items/mock_data'; + +describe('Work Item Note Body', () => { + let wrapper; + + const findNoteBody = () => wrapper.findByTestId('work-item-note-body'); + const findNoteEditedText = () => wrapper.findComponent(NoteEditedText); + + const createComponent = ({ note = mockWorkItemCommentNote } = {}) => { + wrapper = shallowMountExtended(WorkItemNoteBody, { + propsData: { + note, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should have the wrapper to show the note body', () => { + expect(findNoteBody().exists()).toBe(true); + expect(findNoteBody().html()).toMatchSnapshot(); + }); + + it('should not show the edited text when the value is not present', () => { + expect(findNoteEditedText().exists()).toBe(false); + }); +}); 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 new file mode 100644 index 00000000000..7257d5c8023 --- /dev/null +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -0,0 +1,53 @@ +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; +import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; +import { mockWorkItemCommentNote } from 'jest/work_items/mock_data'; + +describe('Work Item Note', () => { + let wrapper; + + const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem); + const findNoteHeader = () => wrapper.findComponent(NoteHeader); + const findNoteBody = () => wrapper.findComponent(NoteBody); + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findAvatar = () => wrapper.findComponent(GlAvatar); + + const createComponent = ({ note = mockWorkItemCommentNote } = {}) => { + wrapper = shallowMount(WorkItemNote, { + propsData: { + note, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('Should be wrapped inside the timeline entry item', () => { + expect(findTimelineEntryItem().exists()).toBe(true); + }); + + it('should have the author avatar of the work item note', () => { + expect(findAvatarLink().exists()).toBe(true); + expect(findAvatarLink().attributes('href')).toBe(mockWorkItemCommentNote.author.webUrl); + + expect(findAvatar().exists()).toBe(true); + expect(findAvatar().props('src')).toBe(mockWorkItemCommentNote.author.avatarUrl); + expect(findAvatar().props('entityName')).toBe(mockWorkItemCommentNote.author.username); + }); + + it('has note header', () => { + expect(findNoteHeader().exists()).toBe(true); + expect(findNoteHeader().props('author')).toEqual(mockWorkItemCommentNote.author); + expect(findNoteHeader().props('createdAt')).toBe(mockWorkItemCommentNote.createdAt); + }); + + it('has note body', () => { + expect(findNoteBody().exists()).toBe(true); + expect(findNoteBody().props('note')).toEqual(mockWorkItemCommentNote); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_comment_form_spec.js b/spec/frontend/work_items/components/work_item_comment_form_spec.js new file mode 100644 index 00000000000..07c00119398 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_comment_form_spec.js @@ -0,0 +1,205 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { updateDraft } from '~/lib/utils/autosave'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue'; +import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue'; +import createNoteMutation from '~/work_items/graphql/create_work_item_note.mutation.graphql'; +import { TRACKING_CATEGORY_SHOW } 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 { + workItemResponseFactory, + workItemQueryResponse, + projectWorkItemResponse, + createWorkItemNoteResponse, +} from '../mock_data'; + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); +jest.mock('~/lib/utils/autosave'); + +const workItemId = workItemQueryResponse.data.workItem.id; + +describe('WorkItemCommentForm', () => { + let wrapper; + + Vue.use(VueApollo); + + const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemNoteResponse); + const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); + let workItemResponseHandler; + + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); + + const setText = (newText) => { + return findMarkdownEditor().vm.$emit('input', newText); + }; + + const clickSave = () => + wrapper + .findAllComponents(GlButton) + .filter((button) => button.text().startsWith('Comment')) + .at(0) + .vm.$emit('click', {}); + + const createComponent = async ({ + mutationHandler = mutationSuccessHandler, + canUpdate = true, + workItemResponse = workItemResponseFactory({ canUpdate }), + queryVariables = { id: workItemId }, + fetchByIid = false, + signedIn = true, + isEditing = true, + } = {}) => { + workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); + + if (signedIn) { + window.gon.current_user_id = '1'; + window.gon.current_user_avatar_url = 'avatar.png'; + } + + const { id } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemCommentForm, { + apolloProvider: createMockApollo([ + [workItemQuery, workItemResponseHandler], + [createNoteMutation, mutationHandler], + [workItemByIidQuery, workItemByIidResponseHandler], + ]), + propsData: { + workItemId: id, + fullPath: 'test-project-path', + queryVariables, + fetchByIid, + }, + stubs: { + MarkdownField, + WorkItemCommentLocked, + }, + }); + + await waitForPromises(); + + if (isEditing) { + wrapper.findComponent(GlButton).vm.$emit('click'); + } + }; + + describe('adding a comment', () => { + it('calls update widgets mutation', async () => { + const noteText = 'updated desc'; + + await createComponent({ + isEditing: true, + signedIn: true, + }); + + setText(noteText); + + clickSave(); + + await waitForPromises(); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + noteableId: workItemId, + body: noteText, + }, + }); + }); + + it('tracks adding comment', async () => { + await createComponent(); + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + setText('test'); + + clickSave(); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', { + category: TRACKING_CATEGORY_SHOW, + label: 'item_comment', + property: 'type_Task', + }); + }); + + it('emits error when mutation returns error', async () => { + const error = 'eror'; + + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockResolvedValue({ + data: { + createNote: { + note: null, + errors: [error], + }, + }, + }), + }); + + setText('updated desc'); + + clickSave(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[error]]); + }); + + it('emits error when mutation fails', async () => { + const error = 'eror'; + + await createComponent({ + isEditing: true, + mutationHandler: jest.fn().mockRejectedValue(new Error(error)), + }); + + setText('updated desc'); + + clickSave(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[error]]); + }); + + it('autosaves', async () => { + await createComponent({ + isEditing: true, + }); + + setText('updated'); + + expect(updateDraft).toHaveBeenCalled(); + }); + }); + + it('calls the global ID work item query when `fetchByIid` prop is false', async () => { + createComponent({ fetchByIid: false }); + await waitForPromises(); + + expect(workItemResponseHandler).toHaveBeenCalled(); + expect(workItemByIidResponseHandler).not.toHaveBeenCalled(); + }); + + it('calls the IID work item query when when `fetchByIid` prop is true', async () => { + await createComponent({ fetchByIid: true, isEditing: false }); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + expect(workItemByIidResponseHandler).toHaveBeenCalled(); + }); + + it('skips calling the handlers when missing the needed queryVariables', async () => { + await createComponent({ queryVariables: {}, fetchByIid: false, isEditing: false }); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_comment_locked_spec.js b/spec/frontend/work_items/components/work_item_comment_locked_spec.js new file mode 100644 index 00000000000..58491c4b09c --- /dev/null +++ b/spec/frontend/work_items/components/work_item_comment_locked_spec.js @@ -0,0 +1,41 @@ +import { GlLink, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import WorkItemCommentLocked from '~/work_items/components/work_item_comment_locked.vue'; + +const createComponent = ({ workItemType = 'Task', isProjectArchived = false } = {}) => + shallowMount(WorkItemCommentLocked, { + propsData: { + workItemType, + isProjectArchived, + }, + }); + +describe('WorkItemCommentLocked', () => { + let wrapper; + const findLockedIcon = () => wrapper.findComponent(GlIcon); + const findLearnMoreLink = () => wrapper.findComponent(GlLink); + + it('renders the locked icon', () => { + wrapper = createComponent(); + expect(findLockedIcon().props('name')).toBe('lock'); + }); + + it('has the learn more link', () => { + wrapper = createComponent(); + expect(findLearnMoreLink().attributes('href')).toBe( + WorkItemCommentLocked.constantOptions.lockedIssueDocsPath, + ); + }); + + describe('when the project is archived', () => { + beforeEach(() => { + wrapper = createComponent({ isProjectArchived: true }); + }); + + it('learn more link is directed to archived project docs path', () => { + expect(findLearnMoreLink().attributes('href')).toBe( + WorkItemCommentLocked.constantOptions.archivedProjectDocsPath, + ); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index 686641800b3..8976cd6e22b 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -4,10 +4,11 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; -import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import { stubComponent } from 'helpers/stub_component'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql'; import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import { deleteWorkItemFromTaskMutationErrorResponse, deleteWorkItemFromTaskMutationResponse, @@ -69,8 +70,14 @@ describe('WorkItemDetailModal component', () => { error, }; }, + provide: { + fullPath: 'group/project', + }, stubs: { GlModal, + WorkItemDetail: stubComponent(WorkItemDetail, { + apollo: {}, + }), }, }); }; @@ -126,6 +133,15 @@ describe('WorkItemDetailModal component', () => { expect(closeSpy).toHaveBeenCalled(); }); + it('updates the work item when WorkItemDetail emits `update-modal` event', async () => { + createComponent(); + + findWorkItemDetail().vm.$emit('update-modal', null, 'updatedId'); + await waitForPromises(); + + expect(findWorkItemDetail().props().workItemId).toEqual('updatedId'); + }); + describe('delete work item', () => { describe('when there is task data', () => { it('emits workItemDeleted and closes modal', async () => { 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 bbab45c7055..a50a48de921 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -12,6 +12,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import setWindowLocation from 'helpers/set_window_location_helper'; +import { stubComponent } from 'helpers/stub_component'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; @@ -22,6 +23,8 @@ import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; +import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; 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'; @@ -63,6 +66,7 @@ describe('WorkItemDetail component', () => { const assigneesSubscriptionHandler = jest .fn() .mockResolvedValue(workItemAssigneesSubscriptionResponse); + const showModalHandler = jest.fn(); const findAlert = () => wrapper.findComponent(GlAlert); const findEmptyState = () => wrapper.findComponent(GlEmptyState); @@ -81,6 +85,8 @@ describe('WorkItemDetail component', () => { const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]'); const findHierarchyTree = () => wrapper.findComponent(WorkItemTree); + const findNotesWidget = () => wrapper.findComponent(WorkItemNotes); + const findModal = () => wrapper.findComponent(WorkItemDetailModal); const createComponent = ({ isModal = false, @@ -129,6 +135,12 @@ describe('WorkItemDetail component', () => { stubs: { WorkItemWeight: true, WorkItemIteration: true, + WorkItemHealthStatus: true, + WorkItemDetailModal: stubComponent(WorkItemDetailModal, { + methods: { + show: showModalHandler, + }, + }), }, }); }; @@ -652,15 +664,89 @@ describe('WorkItemDetail component', () => { expect(findHierarchyTree().exists()).toBe(false); }); - it('renders children tree when work item is an Objective', async () => { + describe('work item has children', () => { const objectiveWorkItem = workItemResponseFactory({ workItemType: objectiveType, + confidential: true, }); const handler = jest.fn().mockResolvedValue(objectiveWorkItem); - createComponent({ handler }); + + it('renders children tree when work item is an Objective', async () => { + createComponent({ handler }); + await waitForPromises(); + + expect(findHierarchyTree().exists()).toBe(true); + }); + + it('renders a modal', async () => { + createComponent({ handler }); + await waitForPromises(); + + expect(findModal().exists()).toBe(true); + }); + + it('opens the modal with the child when `show-modal` is emitted', async () => { + createComponent({ handler }); + await waitForPromises(); + + const event = { + preventDefault: jest.fn(), + }; + + findHierarchyTree().vm.$emit('show-modal', event, { id: 'childWorkItemId' }); + await waitForPromises(); + + expect(wrapper.findComponent(WorkItemDetailModal).props().workItemId).toBe( + 'childWorkItemId', + ); + expect(showModalHandler).toHaveBeenCalled(); + }); + + describe('work item is rendered in a modal and has children', () => { + beforeEach(async () => { + createComponent({ + isModal: true, + handler, + }); + + await waitForPromises(); + }); + + it('does not render a new modal', () => { + expect(findModal().exists()).toBe(false); + }); + + it('emits `update-modal` when `show-modal` is emitted', async () => { + const event = { + preventDefault: jest.fn(), + }; + + findHierarchyTree().vm.$emit('show-modal', event, { id: 'childWorkItemId' }); + await waitForPromises(); + + expect(wrapper.emitted('update-modal')).toBeDefined(); + }); + }); + }); + }); + + describe('notes widget', () => { + it('does not render notes by default', async () => { + createComponent(); + await waitForPromises(); + + expect(findNotesWidget().exists()).toBe(false); + }); + + it('renders notes when the work_items_mvc flag is on', async () => { + const notesWorkItem = workItemResponseFactory({ + notesWidgetPresent: true, + }); + const handler = jest.fn().mockResolvedValue(notesWorkItem); + createComponent({ workItemsMvcEnabled: true, handler }); await waitForPromises(); - expect(findHierarchyTree().exists()).toBe(true); + expect(findNotesWidget().exists()).toBe(true); }); }); }); 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 47489d4796b..e693ccfb156 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 @@ -5,23 +5,22 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ItemMilestone from '~/issuable/components/issue_milestone.vue'; import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/work_item_link_child_metadata.vue'; -import { mockMilestone, mockAssignees, mockLabels } from '../../mock_data'; +import { workItemObjectiveMetadataWidgets } from '../../mock_data'; describe('WorkItemLinkChildMetadata', () => { + const { MILESTONE, ASSIGNEES, LABELS } = workItemObjectiveMetadataWidgets; + const mockMilestone = MILESTONE.milestone; + const mockAssignees = ASSIGNEES.assignees.nodes; + const mockLabels = LABELS.labels.nodes; let wrapper; - const createComponent = ({ - allowsScopedLabels = true, - milestone = mockMilestone, - assignees = mockAssignees, - labels = mockLabels, - } = {}) => { + const createComponent = ({ metadataWidgets = workItemObjectiveMetadataWidgets } = {}) => { wrapper = shallowMountExtended(WorkItemLinkChildMetadata, { propsData: { - allowsScopedLabels, - milestone, - assignees, - labels, + metadataWidgets, + }, + slots: { + default: `<div data-testid="default-slot">Foo</div>`, }, }); }; @@ -30,7 +29,11 @@ describe('WorkItemLinkChildMetadata', () => { createComponent(); }); - it('renders milestone link button', () => { + it('renders default slot contents', () => { + expect(wrapper.findByTestId('default-slot').text()).toBe('Foo'); + }); + + it('renders item milestone', () => { const milestoneLink = wrapper.findComponent(ItemMilestone); expect(milestoneLink.exists()).toBe(true); 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 73d498ad055..0470249d7ce 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 @@ -5,11 +5,12 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; + import { createAlert } from '~/flash'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql'; -import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/work_item_link_child_metadata.vue'; 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'; @@ -25,11 +26,9 @@ import { workItemObjectiveNoMetadata, confidentialWorkItemTask, closedWorkItemTask, - mockMilestone, - mockAssignees, - mockLabels, workItemHierarchyTreeResponse, workItemHierarchyTreeFailureResponse, + workItemObjectiveMetadataWidgets, } from '../../mock_data'; jest.mock('~/flash'); @@ -148,10 +147,7 @@ describe('WorkItemLinkChild', () => { const metadataEl = findMetadataComponent(); expect(metadataEl.exists()).toBe(true); expect(metadataEl.props()).toMatchObject({ - allowsScopedLabels: true, - milestone: mockMilestone, - assignees: mockAssignees, - labels: mockLabels, + metadataWidgets: workItemObjectiveMetadataWidgets, }); }); @@ -265,5 +261,14 @@ describe('WorkItemLinkChild', () => { message: 'Something went wrong while fetching children.', }); }); + + it('click event on child emits `click` event', async () => { + findExpandButton().vm.$emit('click'); + await waitForPromises(); + + findTreeChildren().vm.$emit('click', 'event'); + + expect(wrapper.emitted('click')).toEqual([['event']]); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index bbe460a55ba..5e1c46826cc 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -1,11 +1,18 @@ import Vue from 'vue'; -import { GlForm, GlFormInput, GlTokenSelector } from '@gitlab/ui'; +import { GlForm, GlFormInput, GlFormCheckbox, GlTooltip, GlTokenSelector } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; +import { sprintf, s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; -import { FORM_TYPES } from '~/work_items/constants'; +import { + FORM_TYPES, + WORK_ITEM_TYPE_ENUM_TASK, + WORK_ITEM_TYPE_VALUE_ISSUE, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, +} from '~/work_items/constants'; import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; @@ -36,6 +43,8 @@ describe('WorkItemLinksForm', () => { workItemsMvcEnabled = false, parentIteration = null, formType = FORM_TYPES.create, + parentWorkItemType = WORK_ITEM_TYPE_VALUE_ISSUE, + childrenType = WORK_ITEM_TYPE_ENUM_TASK, } = {}) => { wrapper = shallowMountExtended(WorkItemLinksForm, { apolloProvider: createMockApollo([ @@ -48,6 +57,8 @@ describe('WorkItemLinksForm', () => { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential, parentIteration, + parentWorkItemType, + childrenType, formType, }, provide: { @@ -65,6 +76,7 @@ describe('WorkItemLinksForm', () => { const findForm = () => wrapper.findComponent(GlForm); const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); const findInput = () => wrapper.findComponent(GlFormInput); + const findConfidentialCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findAddChildButton = () => wrapper.findByTestId('add-child-button'); afterEach(() => { @@ -90,6 +102,7 @@ describe('WorkItemLinksForm', () => { preventDefault: jest.fn(), }); await waitForPromises(); + expect(wrapper.vm.childWorkItemType).toEqual('gid://gitlab/WorkItems::Type/3'); expect(createMutationResolver).toHaveBeenCalledWith({ input: { title: 'Create task test', @@ -112,6 +125,7 @@ describe('WorkItemLinksForm', () => { preventDefault: jest.fn(), }); await waitForPromises(); + expect(wrapper.vm.childWorkItemType).toEqual('gid://gitlab/WorkItems::Type/3'); expect(createMutationResolver).toHaveBeenCalledWith({ input: { title: 'Create confidential task', @@ -124,9 +138,50 @@ describe('WorkItemLinksForm', () => { }, }); }); + + describe('confidentiality checkbox', () => { + it('renders confidentiality checkbox', () => { + const confidentialCheckbox = findConfidentialCheckbox(); + + expect(confidentialCheckbox.exists()).toBe(true); + expect(wrapper.findComponent(GlTooltip).exists()).toBe(false); + expect(confidentialCheckbox.text()).toBe( + sprintf(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, { + workItemType: WORK_ITEM_TYPE_ENUM_TASK.toLocaleLowerCase(), + }), + ); + }); + + it('renders confidentiality tooltip with checkbox checked and disabled when parent is confidential', () => { + createComponent({ parentConfidential: true }); + + const confidentialCheckbox = findConfidentialCheckbox(); + const confidentialTooltip = wrapper.findComponent(GlTooltip); + + expect(confidentialCheckbox.attributes('disabled')).toBe('true'); + expect(confidentialCheckbox.attributes('checked')).toBe('true'); + expect(confidentialTooltip.exists()).toBe(true); + expect(confidentialTooltip.text()).toBe( + sprintf(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, { + workItemType: WORK_ITEM_TYPE_ENUM_TASK.toLocaleLowerCase(), + parentWorkItemType: WORK_ITEM_TYPE_VALUE_ISSUE.toLocaleLowerCase(), + }), + ); + }); + }); }); describe('adding an existing work item', () => { + const selectAvailableWorkItemTokens = async () => { + findTokenSelector().vm.$emit( + 'input', + availableWorkItemsResponse.data.workspace.workItems.nodes, + ); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + + await waitForPromises(); + }; + beforeEach(async () => { await createComponent({ formType: FORM_TYPES.add }); }); @@ -136,6 +191,7 @@ describe('WorkItemLinksForm', () => { expect(findTokenSelector().exists()).toBe(true); expect(findAddChildButton().text()).toBe('Add task'); expect(findInput().exists()).toBe(false); + expect(findConfidentialCheckbox().exists()).toBe(false); }); it('searches for available work items as prop when typing in input', async () => { @@ -147,13 +203,7 @@ describe('WorkItemLinksForm', () => { }); it('selects and adds children', async () => { - findTokenSelector().vm.$emit( - 'input', - availableWorkItemsResponse.data.workspace.workItems.nodes, - ); - findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); - - await waitForPromises(); + await selectAvailableWorkItemTokens(); expect(findAddChildButton().text()).toBe('Add tasks'); findForm().vm.$emit('submit', { @@ -162,6 +212,31 @@ describe('WorkItemLinksForm', () => { await waitForPromises(); expect(updateMutationResolver).toHaveBeenCalled(); }); + + it('shows validation error when non-confidential child items are being added to confidential parent', async () => { + await createComponent({ formType: FORM_TYPES.add, parentConfidential: true }); + + await selectAvailableWorkItemTokens(); + + const validationEl = wrapper.findByTestId('work-items-invalid'); + expect(validationEl.exists()).toBe(true); + expect(validationEl.text().trim()).toBe( + sprintf( + s__( + 'WorkItem|%{invalidWorkItemsList} cannot be added: Cannot assign a non-confidential %{childWorkItemType} to a confidential parent %{parentWorkItemType}. Make the selected %{childWorkItemType} confidential and try again.', + ), + { + // Only non-confidential work items are shown in the error message + invalidWorkItemsList: availableWorkItemsResponse.data.workspace.workItems.nodes + .filter((wi) => !wi.confidential) + .map((wi) => wi.title) + .join(', '), + childWorkItemType: 'Task', + parentWorkItemType: 'Issue', + }, + ), + ); + }); }); describe('associate iteration with task', () => { diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js index 96211e12755..156f06a0d5e 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js @@ -34,6 +34,8 @@ describe('WorkItemTree', () => { const createComponent = ({ workItemType = 'Objective', + parentWorkItemType = 'Objective', + confidential = false, children = childrenWorkItems, apolloProvider = null, } = {}) => { @@ -55,7 +57,9 @@ describe('WorkItemTree', () => { apolloProvider || createMockApollo([[workItemQuery, getWorkItemQueryHandler]]), propsData: { workItemType, + parentWorkItemType, workItemId: 'gid://gitlab/WorkItem/515', + confidential, children, projectPath: 'test/project', }, @@ -90,7 +94,11 @@ describe('WorkItemTree', () => { }); it('renders all hierarchy widget children', () => { - expect(findWorkItemLinkChildItems()).toHaveLength(4); + const workItemLinkChildren = findWorkItemLinkChildItems(); + expect(workItemLinkChildren).toHaveLength(4); + expect(workItemLinkChildren.at(0).props().childItem.confidential).toBe( + childrenWorkItems[0].confidential, + ); }); it('does not display form by default', () => { @@ -110,8 +118,12 @@ describe('WorkItemTree', () => { await nextTick(); expect(findForm().exists()).toBe(true); - expect(findForm().props('formType')).toBe(formType); - expect(findForm().props('childrenType')).toBe(childType); + expect(findForm().props()).toMatchObject({ + formType, + childrenType: childType, + parentWorkItemType: 'Objective', + parentConfidential: false, + }); }, ); @@ -122,6 +134,17 @@ describe('WorkItemTree', () => { expect(wrapper.emitted('removeChild')).toEqual([['gid://gitlab/WorkItem/2']]); }); + it('emits `show-modal` on `click` event', () => { + const firstChild = findWorkItemLinkChildItems().at(0); + const event = { + childItem: 'gid://gitlab/WorkItem/2', + }; + + firstChild.vm.$emit('click', event); + + expect(wrapper.emitted('show-modal')).toEqual([[event, event.childItem]]); + }); + it.each` description | workItemType | prefetch ${'prefetches'} | ${'Issue'} | ${true} 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 ed68d214fc9..23dd2b6bacb 100644 --- a/spec/frontend/work_items/components/work_item_notes_spec.js +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -1,18 +1,22 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue 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 SystemNote from '~/work_items/components/notes/system_note.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; +import WorkItemCommentForm from '~/work_items/components/work_item_comment_form.vue'; +import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; import workItemNotesQuery from '~/work_items/graphql/work_item_notes.query.graphql'; import workItemNotesByIidQuery from '~/work_items/graphql/work_item_notes_by_iid.query.graphql'; -import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import { DEFAULT_PAGE_SIZE_NOTES, WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import { DESC } from '~/notes/constants'; import { mockWorkItemNotesResponse, workItemQueryResponse, mockWorkItemNotesByIidResponse, + mockMoreWorkItemNotesResponse, } from '../mock_data'; const mockWorkItemId = workItemQueryResponse.data.workItem.id; @@ -24,6 +28,12 @@ const mockNotesByIidWidgetResponse = mockWorkItemNotesByIidResponse.data.workspa (widget) => widget.type === WIDGET_TYPE_NOTES, ); +const mockMoreNotesWidgetResponse = mockMoreWorkItemNotesResponse.data.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_NOTES, +); + +const firstSystemNodeId = mockNotesWidgetResponse.discussions.nodes[0].notes.nodes[0].id; + describe('WorkItemNotes component', () => { let wrapper; @@ -31,16 +41,24 @@ describe('WorkItemNotes component', () => { const findAllSystemNotes = () => wrapper.findAllComponents(SystemNote); const findActivityLabel = () => wrapper.find('label'); + const findWorkItemCommentForm = () => wrapper.findComponent(WorkItemCommentForm); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findSortingFilter = () => wrapper.findComponent(ActivityFilter); + const findSystemNoteAtIndex = (index) => findAllSystemNotes().at(index); const workItemNotesQueryHandler = jest.fn().mockResolvedValue(mockWorkItemNotesResponse); const workItemNotesByIidQueryHandler = jest .fn() .mockResolvedValue(mockWorkItemNotesByIidResponse); + const workItemMoreNotesQueryHandler = jest.fn().mockResolvedValue(mockMoreWorkItemNotesResponse); - const createComponent = ({ workItemId = mockWorkItemId, fetchByIid = false } = {}) => { + const createComponent = ({ + workItemId = mockWorkItemId, + fetchByIid = false, + defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler, + } = {}) => { wrapper = shallowMount(WorkItemNotes, { apolloProvider: createMockApollo([ - [workItemNotesQuery, workItemNotesQueryHandler], + [workItemNotesQuery, defaultWorkItemNotesQueryHandler], [workItemNotesByIidQuery, workItemNotesByIidQueryHandler], ]), propsData: { @@ -50,6 +68,7 @@ describe('WorkItemNotes component', () => { }, fullPath: 'test-path', fetchByIid, + workItemType: 'task', }, provide: { glFeatures: { @@ -63,14 +82,17 @@ describe('WorkItemNotes component', () => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('renders activity label', () => { expect(findActivityLabel().exists()).toBe(true); }); + it('passes correct props to comment form component', async () => { + createComponent({ workItemId: mockWorkItemId, fetchByIid: false }); + await waitForPromises(); + + expect(findWorkItemCommentForm().props('fetchByIid')).toEqual(false); + }); + describe('when notes are loading', () => { it('renders skeleton loader', () => { expect(findSkeletonLoader().exists()).toBe(true); @@ -98,10 +120,65 @@ describe('WorkItemNotes component', () => { await waitForPromises(); }); - it('shows the notes list', () => { + it('renders the notes list to the length of the response', () => { expect(findAllSystemNotes()).toHaveLength( mockNotesByIidWidgetResponse.discussions.nodes.length, ); }); + + it('passes correct props to comment form component', () => { + expect(findWorkItemCommentForm().props('fetchByIid')).toEqual(true); + }); + }); + + describe('Pagination', () => { + describe('When there is no next page', () => { + it('fetch more notes is not called', async () => { + createComponent(); + await nextTick(); + expect(workItemMoreNotesQueryHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when there is next page', () => { + beforeEach(async () => { + createComponent({ defaultWorkItemNotesQueryHandler: workItemMoreNotesQueryHandler }); + await waitForPromises(); + }); + + it('fetch more notes should be called', async () => { + expect(workItemMoreNotesQueryHandler).toHaveBeenCalledWith({ + pageSize: DEFAULT_PAGE_SIZE_NOTES, + id: 'gid://gitlab/WorkItem/1', + }); + + await nextTick(); + + expect(workItemMoreNotesQueryHandler).toHaveBeenCalledWith({ + pageSize: 45, + id: 'gid://gitlab/WorkItem/1', + after: mockMoreNotesWidgetResponse.discussions.pageInfo.endCursor, + }); + }); + }); + }); + + describe('Sorting', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('filter exists', () => { + expect(findSortingFilter().exists()).toBe(true); + }); + + it('sorts the list when the `changeSortOrder` event is emitted', async () => { + expect(findSystemNoteAtIndex(0).props('note').id).toEqual(firstSystemNodeId); + + await findSortingFilter().vm.$emit('changeSortOrder', DESC); + + expect(findSystemNoteAtIndex(0).props('note').id).not.toEqual(firstSystemNodeId); + }); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 850672b68d0..67b477b6eb0 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -62,6 +62,7 @@ export const workItemQueryResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, workItemType: { __typename: 'WorkItemType', @@ -156,6 +157,7 @@ export const updateWorkItemMutationResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, workItemType: { __typename: 'WorkItemType', @@ -268,6 +270,7 @@ export const workItemResponseFactory = ({ milestoneWidgetPresent = true, iterationWidgetPresent = true, healthStatusWidgetPresent = true, + notesWidgetPresent = true, confidential = false, canInviteMembers = false, allowsScopedLabels = false, @@ -292,6 +295,7 @@ export const workItemResponseFactory = ({ __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, workItemType, userPermissions: { @@ -380,6 +384,23 @@ export const workItemResponseFactory = ({ healthStatus: 'onTrack', } : { type: 'MOCK TYPE' }, + notesWidgetPresent + ? { + __typename: 'WorkItemWidgetNotes', + type: 'NOTES', + discussions: { + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0xNCAwNDoxOTowMC4wOTkxMTcwMDAgKzAwMDAiLCJpZCI6IjQyNyIsIl9rZCI6Im4ifQ==', + __typename: 'PageInfo', + }, + nodes: [], + }, + } + : { type: 'MOCK TYPE' }, { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', @@ -409,6 +430,12 @@ export const workItemResponseFactory = ({ }, parent, }, + notesWidgetPresent + ? { + __typename: 'WorkItemWidgetNotes', + type: 'NOTES', + } + : { type: 'MOCK TYPE' }, ], }, }, @@ -448,6 +475,7 @@ export const createWorkItemMutationResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, workItemType: { __typename: 'WorkItemType', @@ -485,6 +513,7 @@ export const createWorkItemFromTaskMutationResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, workItemType: { __typename: 'WorkItemType', @@ -524,6 +553,7 @@ export const createWorkItemFromTaskMutationResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, workItemType: { __typename: 'WorkItemType', @@ -698,6 +728,20 @@ export const workItemIterationSubscriptionResponse = { }, }; +export const workItemHealthStatusSubscriptionResponse = { + data: { + issuableHealthStatusUpdated: { + id: 'gid://gitlab/WorkItem/1', + widgets: [ + { + __typename: 'WorkItemWidgetHealthStatus', + healthStatus: 'needsAttention', + }, + ], + }, + }, +}; + export const workItemMilestoneSubscriptionResponse = { data: { issuableMilestoneUpdated: { @@ -734,6 +778,7 @@ export const workItemHierarchyEmptyResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, userPermissions: { deleteWorkItem: false, @@ -780,6 +825,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, confidential: false, widgets: [ @@ -920,6 +966,7 @@ export const workItemHierarchyResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, widgets: [ { @@ -942,6 +989,43 @@ export const workItemHierarchyResponse = { }, }; +export const workItemObjectiveMetadataWidgets = { + ASSIGNEES: { + type: 'ASSIGNEES', + __typename: 'WorkItemWidgetAssignees', + canInviteMembers: true, + allowsMultipleAssignees: true, + assignees: { + __typename: 'UserCoreConnection', + nodes: mockAssignees, + }, + }, + HEALTH_STATUS: { + type: 'HEALTH_STATUS', + __typename: 'WorkItemWidgetHealthStatus', + healthStatus: 'onTrack', + }, + LABELS: { + type: 'LABELS', + __typename: 'WorkItemWidgetLabels', + allowsScopedLabels: true, + labels: { + __typename: 'LabelConnection', + nodes: mockLabels, + }, + }, + MILESTONE: { + type: 'MILESTONE', + __typename: 'WorkItemWidgetMilestone', + milestone: mockMilestone, + }, + PROGRESS: { + type: 'PROGRESS', + __typename: 'WorkItemWidgetProgress', + progress: 10, + }, +}; + export const workItemObjectiveWithChild = { id: 'gid://gitlab/WorkItem/12', iid: '12', @@ -955,6 +1039,7 @@ export const workItemObjectiveWithChild = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, userPermissions: { deleteWorkItem: true, @@ -976,30 +1061,11 @@ export const workItemObjectiveWithChild = { }, __typename: 'WorkItemWidgetHierarchy', }, - { - type: 'MILESTONE', - __typename: 'WorkItemWidgetMilestone', - milestone: mockMilestone, - }, - { - type: 'ASSIGNEES', - __typename: 'WorkItemWidgetAssignees', - canInviteMembers: true, - allowsMultipleAssignees: true, - assignees: { - __typename: 'UserCoreConnection', - nodes: mockAssignees, - }, - }, - { - type: 'LABELS', - __typename: 'WorkItemWidgetLabels', - allowsScopedLabels: true, - labels: { - __typename: 'LabelConnection', - nodes: mockLabels, - }, - }, + workItemObjectiveMetadataWidgets.PROGRESS, + workItemObjectiveMetadataWidgets.HEALTH_STATUS, + workItemObjectiveMetadataWidgets.MILESTONE, + workItemObjectiveMetadataWidgets.ASSIGNEES, + workItemObjectiveMetadataWidgets.LABELS, ], __typename: 'WorkItem', }; @@ -1012,6 +1078,16 @@ export const workItemObjectiveNoMetadata = { hasChildren: true, __typename: 'WorkItemWidgetHierarchy', }, + { + __typename: 'WorkItemWidgetProgress', + type: 'PROGRESS', + progress: null, + }, + { + __typename: 'WorkItemWidgetMilestone', + type: 'MILESTONE', + milestone: null, + }, ], }; @@ -1036,6 +1112,7 @@ export const workItemHierarchyTreeResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, widgets: [ { @@ -1118,6 +1195,7 @@ export const changeWorkItemParentMutationResponse = { __typename: 'Project', id: '1', fullPath: 'test-project-path', + archived: false, }, widgets: [ { @@ -1149,6 +1227,7 @@ export const availableWorkItemsResponse = { title: 'Task 1', state: 'OPEN', createdAt: '2022-08-03T12:41:54Z', + confidential: false, __typename: 'WorkItem', }, { @@ -1156,6 +1235,15 @@ export const availableWorkItemsResponse = { title: 'Task 2', state: 'OPEN', createdAt: '2022-08-03T12:41:54Z', + confidential: false, + __typename: 'WorkItem', + }, + { + id: 'gid://gitlab/WorkItem/460', + title: 'Task 3', + state: 'OPEN', + createdAt: '2022-08-03T12:41:54Z', + confidential: true, __typename: 'WorkItem', }, ], @@ -1514,11 +1602,16 @@ export const mockWorkItemNotesResponse = { nodes: [ { id: 'gid://gitlab/Note/2428', - body: 'added #31 as parent issue', bodyHtml: '<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>', systemNoteIconName: 'link', createdAt: '2022-11-14T04:18:59Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -1541,12 +1634,17 @@ export const mockWorkItemNotesResponse = { notes: { nodes: [ { - id: 'gid://gitlab/MilestoneNote/not-persisted', - body: 'changed milestone to %5', + id: 'gid://gitlab/MilestoneNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864', bodyHtml: '<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>', systemNoteIconName: 'clock', createdAt: '2022-11-14T04:18:59Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -1569,11 +1667,16 @@ export const mockWorkItemNotesResponse = { notes: { nodes: [ { - id: 'gid://gitlab/WeightNote/not-persisted', - body: 'changed weight to 89', + id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a9883864', bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>', systemNoteIconName: 'weight', createdAt: '2022-11-25T07:16:20Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, author: { avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', @@ -1656,11 +1759,16 @@ export const mockWorkItemNotesByIidResponse = { nodes: [ { id: 'gid://gitlab/Note/2428', - body: 'added #31 as parent issue', bodyHtml: '\u003cp data-sourcepos="1:1-1:25" dir="auto"\u003eadded \u003ca href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container="body" data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue"\u003e#31\u003c/a\u003e as parent issue\u003c/p\u003e', systemNoteIconName: 'link', createdAt: '2022-11-14T04:18:59Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, author: { id: 'gid://gitlab/User/1', avatarUrl: @@ -1685,11 +1793,16 @@ export const mockWorkItemNotesByIidResponse = { { id: 'gid://gitlab/MilestoneNote/7b08b89a728a5ceb7de8334246837ba1d07270dc', - body: 'changed milestone to %5', bodyHtml: '\u003cp data-sourcepos="1:1-1:23" dir="auto"\u003echanged milestone to \u003ca href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip"\u003e%v4.0\u003c/a\u003e\u003c/p\u003e', systemNoteIconName: 'clock', createdAt: '2022-11-14T04:18:59Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, author: { id: 'gid://gitlab/User/1', avatarUrl: @@ -1714,11 +1827,16 @@ export const mockWorkItemNotesByIidResponse = { { id: 'gid://gitlab/IterationNote/addbc177f7664699a135130ab05ffb78c57e4db3', - body: 'changed iteration to *iteration:5352', bodyHtml: '\u003cp data-sourcepos="1:1-1:36" dir="auto"\u003echanged iteration to \u003ca href="/groups/flightjs/-/iterations/5352" data-reference-type="iteration" data-original="*iteration:5352" data-link="false" data-link-reference="false" data-project="6" data-iteration="5352" data-container="body" data-placement="top" title="Iteration" class="gfm gfm-iteration has-tooltip"\u003eEt autem debitis nam suscipit eos ut. Jul 13, 2022 - Jul 19, 2022\u003c/a\u003e\u003c/p\u003e', systemNoteIconName: 'iteration', createdAt: '2022-11-14T04:19:00Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, author: { id: 'gid://gitlab/User/1', avatarUrl: @@ -1750,3 +1868,183 @@ export const mockWorkItemNotesByIidResponse = { }, }, }; +export const mockMoreWorkItemNotesResponse = { + data: { + workItem: { + id: 'gid://gitlab/WorkItem/600', + iid: '60', + widgets: [ + { + __typename: 'WorkItemWidgetIteration', + }, + { + __typename: 'WorkItemWidgetWeight', + }, + { + __typename: 'WorkItemWidgetAssignees', + }, + { + __typename: 'WorkItemWidgetLabels', + }, + { + __typename: 'WorkItemWidgetDescription', + }, + { + __typename: 'WorkItemWidgetHierarchy', + }, + { + __typename: 'WorkItemWidgetStartAndDueDate', + }, + { + __typename: 'WorkItemWidgetMilestone', + }, + { + type: 'NOTES', + discussions: { + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: 'endCursor', + __typename: 'PageInfo', + }, + nodes: [ + { + id: + 'gid://gitlab/IndividualNoteDiscussion/8bbc4890b6ff0f2cde93a5a0947cd2b8a13d3b6e', + notes: { + nodes: [ + { + id: 'gid://gitlab/Note/2428', + bodyHtml: + '<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>', + systemNoteIconName: 'link', + createdAt: '2022-11-14T04:18:59Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, + author: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + { + id: + 'gid://gitlab/IndividualNoteDiscussion/7b08b89a728a5ceb7de8334246837ba1d07270dc', + notes: { + nodes: [ + { + id: 'gid://gitlab/MilestoneNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83823', + bodyHtml: + '<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>', + systemNoteIconName: 'clock', + createdAt: '2022-11-14T04:18:59Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, + author: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + { + id: + 'gid://gitlab/IndividualNoteDiscussion/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864', + notes: { + nodes: [ + { + id: 'gid://gitlab/WeightNote/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864', + bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>', + systemNoteIconName: 'weight', + createdAt: '2022-11-25T07:16:20Z', + system: true, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, + author: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + ], + __typename: 'DiscussionConnection', + }, + __typename: 'WorkItemWidgetNotes', + }, + ], + __typename: 'WorkItem', + }, + }, +}; + +export const createWorkItemNoteResponse = { + data: { + createNote: { + errors: [], + __typename: 'CreateNotePayload', + }, + }, +}; + +export const mockWorkItemCommentNote = { + id: 'gid://gitlab/Note/158', + bodyHtml: + '<p data-sourcepos="1:1-1:76" dir="auto"><gl-emoji title="waving hand sign" data-name="wave" data-unicode-version="6.0">👋</gl-emoji> Hi <a href="/fredda.brekke" data-reference-type="user" data-user="3" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Sherie Nitzsche">@fredda.brekke</a> How are you ? what do you think about this ? <gl-emoji title="person with folded hands" data-name="pray" data-unicode-version="6.0">🙏</gl-emoji></p>', + systemNoteIconName: false, + createdAt: '2022-11-25T07:16:20Z', + system: false, + internal: false, + userPermissions: { + adminNote: false, + __typename: 'NotePermissions', + }, + author: { + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, +}; diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index b503d819435..ef9ae4a2eab 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -74,6 +74,7 @@ describe('Work items router', () => { stubs: { WorkItemWeight: true, WorkItemIteration: true, + WorkItemHealthStatus: true, }, }); }; |