diff options
Diffstat (limited to 'spec/frontend/vue_merge_request_widget')
16 files changed, 486 insertions, 404 deletions
diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js index c81f4328d2a..c3ed131d6e3 100644 --- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js @@ -1,11 +1,11 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlButton, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client'; import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/alert'; @@ -29,11 +29,6 @@ jest.mock('~/alert', () => ({ dismiss: mockAlertDismiss, })), })); -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - visitUrl: jest.fn(), -})); - const TEST_HELP_PATH = 'help/path'; const testApprovedBy = () => [1, 7, 10].map((id) => ({ id })); const testApprovals = () => ({ @@ -53,6 +48,9 @@ describe('MRWidget approvals', () => { let wrapper; let service; let mr; + const submitSpy = jest.fn().mockImplementation((e) => { + e.preventDefault(); + }); const createComponent = (options = {}, responses = { query: approvedByCurrentUser }) => { mockedSubscription = createMockApolloSubscription(); @@ -68,7 +66,7 @@ describe('MRWidget approvals', () => { apolloProvider.defaultClient.setRequestHandler(query, stream); }); - wrapper = shallowMount(Approvals, { + wrapper = shallowMountExtended(Approvals, { apolloProvider, propsData: { mr, @@ -78,7 +76,18 @@ describe('MRWidget approvals', () => { provide, stubs: { GlSprintf, + GlForm: { + data() { + return { submitSpy }; + }, + // Workaround jsdom not implementing form submit + template: '<form @submit="submitSpy"><slot></slot></form>', + }, + GlButton: stubComponent(GlButton, { + template: '<button><slot></slot></button>', + }), }, + attachTo: document.body, }); }; @@ -257,11 +266,11 @@ describe('MRWidget approvals', () => { }); describe('when SAML auth is required and user clicks Approve with SAML', () => { - const fakeGroupSamlPath = '/example_group_saml'; + const fakeSamlPath = '/example_group_saml'; beforeEach(async () => { mr.requireSamlAuthToApprove = true; - mr.samlApprovalPath = fakeGroupSamlPath; + mr.samlApprovalPath = fakeSamlPath; createComponent({}, { query: createCanApproveResponse() }); await waitForPromises(); @@ -269,9 +278,10 @@ describe('MRWidget approvals', () => { it('redirects the user to the group SAML path', async () => { const action = findAction(); - action.vm.$emit('click'); - await nextTick(); - expect(visitUrl).toHaveBeenCalledWith(fakeGroupSamlPath); + + await action.trigger('click'); + + expect(submitSpy).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js new file mode 100644 index 00000000000..cc605c8c83d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js @@ -0,0 +1,196 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json'; + +import { createAlert } from '~/alert'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import MergeRequest from '~/merge_request'; + +import DraftCheck from '~/vue_merge_request_widget/components/checks/draft.vue'; +import { + DRAFT_CHECK_READY, + DRAFT_CHECK_ERROR, +} from '~/vue_merge_request_widget/components/checks/i18n'; +import { FAILURE_REASONS } from '~/vue_merge_request_widget/components/checks/message.vue'; + +import draftQuery from '~/vue_merge_request_widget/queries/states/draft.query.graphql'; +import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql'; +import removeDraftMutation from '~/vue_merge_request_widget/queries/toggle_draft.mutation.graphql'; + +Vue.use(VueApollo); + +const TEST_PROJECT_ID = getStateQueryResponse.data.project.id; +const TEST_MR_ID = getStateQueryResponse.data.project.mergeRequest.id; +const TEST_MR_IID = '23'; +const TEST_MR_TITLE = 'Test MR Title'; +const TEST_PROJECT_PATH = 'lorem/ipsum'; + +jest.mock('~/alert'); +jest.mock('~/merge_request', () => ({ toggleDraftStatus: jest.fn() })); + +describe('~/vue_merge_request_widget/components/checks/draft.vue', () => { + let wrapper; + let apolloProvider; + + let draftQuerySpy; + let removeDraftMutationSpy; + + const findMarkReadyButton = () => wrapper.findByTestId('mark-as-ready-button'); + + const createDraftQueryResponse = (canUpdateMergeRequest) => ({ + data: { + project: { + __typename: 'Project', + id: TEST_PROJECT_ID, + mergeRequest: { + __typename: 'MergeRequest', + id: TEST_MR_ID, + draft: true, + title: TEST_MR_TITLE, + mergeableDiscussionsState: false, + userPermissions: { + updateMergeRequest: canUpdateMergeRequest, + }, + }, + }, + }, + }); + const createRemoveDraftMutationResponse = () => ({ + data: { + mergeRequestSetDraft: { + __typename: 'MergeRequestSetWipPayload', + errors: [], + mergeRequest: { + __typename: 'MergeRequest', + id: TEST_MR_ID, + title: TEST_MR_TITLE, + draft: false, + mergeableDiscussionsState: true, + }, + }, + }, + }); + + const createComponent = async () => { + wrapper = mountExtended(DraftCheck, { + apolloProvider, + propsData: { + mr: { + issuableId: TEST_MR_ID, + title: TEST_MR_TITLE, + iid: TEST_MR_IID, + targetProjectFullPath: TEST_PROJECT_PATH, + }, + check: { + identifier: 'draft_status', + status: 'FAILED', + }, + }, + }); + + await waitForPromises(); + + // why: draft.vue has some coupling that this query has been read before + // for some reason this has to happen **after** the component has mounted + // or apollo throws errors. + apolloProvider.defaultClient.cache.writeQuery({ + query: getStateQuery, + variables: { + projectPath: TEST_PROJECT_PATH, + iid: TEST_MR_IID, + }, + data: getStateQueryResponse.data, + }); + }; + + beforeEach(() => { + draftQuerySpy = jest.fn().mockResolvedValue(createDraftQueryResponse(true)); + removeDraftMutationSpy = jest.fn().mockResolvedValue(createRemoveDraftMutationResponse()); + + apolloProvider = createMockApollo([ + [draftQuery, draftQuerySpy], + [removeDraftMutation, removeDraftMutationSpy], + ]); + }); + + describe('when user can update MR', () => { + beforeEach(async () => { + await createComponent(); + }); + + it('renders text', () => { + const message = wrapper.text(); + expect(message).toContain(FAILURE_REASONS.draft_status); + }); + + it('renders mark ready button', () => { + expect(findMarkReadyButton().text()).toBe(DRAFT_CHECK_READY); + }); + + it('does not call remove draft mutation', () => { + expect(removeDraftMutationSpy).not.toHaveBeenCalled(); + }); + + describe('when mark ready button is clicked', () => { + beforeEach(async () => { + findMarkReadyButton().vm.$emit('click'); + + await waitForPromises(); + }); + + it('calls mutation spy', () => { + expect(removeDraftMutationSpy).toHaveBeenCalledWith({ + draft: false, + iid: TEST_MR_IID, + projectPath: TEST_PROJECT_PATH, + }); + }); + + it('does not create alert', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('calls toggleDraftStatus', () => { + expect(MergeRequest.toggleDraftStatus).toHaveBeenCalledWith(TEST_MR_TITLE, true); + }); + }); + + describe('when mutation fails and ready button is clicked', () => { + beforeEach(async () => { + removeDraftMutationSpy.mockRejectedValue(new Error('TEST FAIL')); + findMarkReadyButton().vm.$emit('click'); + + await waitForPromises(); + }); + + it('creates alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: DRAFT_CHECK_ERROR, + }); + }); + + it('does not call toggleDraftStatus', () => { + expect(MergeRequest.toggleDraftStatus).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when user cannot update MR', () => { + beforeEach(async () => { + draftQuerySpy.mockResolvedValue(createDraftQueryResponse(false)); + + createComponent(); + + await waitForPromises(); + }); + + it('does not render mark ready button', () => { + expect(findMarkReadyButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js index d6c01aee3b1..d621999337d 100644 --- a/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js @@ -28,7 +28,7 @@ const mockPipelineNodes = [ const mockQueryHandler = ({ rebaseInProgress = false, targetBranch = '', - pushToSourceBranch = false, + pushToSourceBranch = true, nodes = mockPipelineNodes, } = {}) => jest.fn().mockResolvedValue({ @@ -279,7 +279,7 @@ describe('Merge request merge checks rebase component', () => { await waitForPromises(); - expect(findRebaseWithoutCiButton().exists()).toBe(true); + expect(findRebaseWithoutCiButton().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js index d39098b27c2..b19095cc686 100644 --- a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js @@ -138,7 +138,7 @@ describe('Merge request merge checks component', () => { it.each` identifier ${'conflict'} - ${'unresolved_discussions'} + ${'discussions_not_resolved'} ${'need_rebase'} ${'default'} `('renders $identifier merge check', async ({ identifier }) => { diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js index 8eaed998eb5..5a5d29d3194 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js @@ -39,7 +39,7 @@ describe('MrWidgetExpanableSection', () => { const collapse = findCollapse(); expect(collapse.exists()).toBe(true); - expect(collapse.attributes('visible')).toBeUndefined(); + expect(collapse.props('visible')).toBe(false); }); }); @@ -60,7 +60,7 @@ describe('MrWidgetExpanableSection', () => { const collapse = findCollapse(); expect(collapse.exists()).toBe(true); - expect(collapse.attributes('visible')).toBe('true'); + expect(collapse.props('visible')).toBe(true); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js index 35b4e222e01..3f0eb946194 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js @@ -8,6 +8,7 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import MRWidgetPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; import { SUCCESS } from '~/vue_merge_request_widget/constants'; +import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat'; import mockData from '../mock_data'; describe('MRWidgetPipeline', () => { @@ -93,7 +94,7 @@ describe('MRWidgetPipeline', () => { it('should render pipeline finished timestamp', () => { expect(findPipelineFinishedAt().attributes()).toMatchObject({ - title: 'Apr 7, 2017 2:00pm UTC', + title: localeDateFormat.asDateTimeFull.format(mockData.pipeline.details.finished_at), datetime: mockData.pipeline.details.finished_at, }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js index b210327aa31..65c4970bc76 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js @@ -54,5 +54,12 @@ describe('MR widget status icon component', () => { expect(findIcon().exists()).toBe(true); expect(findIcon().props().name).toBe('merge-request-close'); }); + + it('renders empty status icon', () => { + createWrapper({ status: 'empty' }); + + expect(findStatusIcon().exists()).toBe(true); + expect(findStatusIcon().props().iconName).toBe('neutral'); + }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap index ecf4040cbda..ec0af7c8a7b 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap +++ b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap @@ -8,7 +8,7 @@ exports[`New ready to merge state component renders permission text if canMerge status="success" /> <p - class="gl-font-weight-bold gl-m-0! gl-text-gray-900! media-body" + class="gl-font-weight-bold gl-mb-0! gl-mt-1 gl-text-gray-900! media-body" > Ready to merge by members who can write to the target branch. </p> @@ -23,7 +23,7 @@ exports[`New ready to merge state component renders permission text if canMerge status="success" /> <p - class="gl-font-weight-bold gl-m-0! gl-text-gray-900! media-body" + class="gl-font-weight-bold gl-mb-0! gl-mt-1 gl-text-gray-900! media-body" > Ready to merge! </p> diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js index 7f0a171d712..af10d7d5eb7 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js @@ -1,10 +1,17 @@ import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; import { removeBreakLine } from 'helpers/text_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import userPermissionsQuery from '~/vue_merge_request_widget/queries/permissions.query.graphql'; +import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql'; import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; +Vue.use(VueApollo); + describe('MRWidgetConflicts', () => { let wrapper; const path = '/conflicts'; @@ -20,34 +27,57 @@ describe('MRWidgetConflicts', () => { const resolveConflictsBtnText = 'Resolve conflicts'; const mergeLocallyBtnText = 'Resolve locally'; - async function createComponent(propsData = {}) { - wrapper = extendedWrapper( - mount(ConflictsComponent, { - propsData, - data() { - return { + const defaultApolloProvider = (mockData = {}) => { + const userData = { + data: { + project: { + id: 234, + mergeRequest: { + id: 234, userPermissions: { - canMerge: propsData.mr.canMerge, - pushToSourceBranch: propsData.mr.canPushToSourceBranch, - }, - state: { - shouldBeRebased: propsData.mr.shouldBeRebased, - sourceBranchProtected: propsData.mr.sourceBranchProtected, + canMerge: mockData.canMerge || false, + pushToSourceBranch: mockData.canPushToSourceBranch || false, }, - }; + }, }, - mocks: { - $apollo: { - queries: { - userPermissions: { loading: false }, - stateData: { loading: false }, + }, + }; + + const mrData = { + data: { + project: { + id: 234, + mergeRequest: { + id: 234, + shouldBeRebased: mockData.shouldBeRebased || false, + sourceBranchProtected: mockData.sourceBranchProtected || false, + userPermissions: { + pushToSourceBranch: mockData.canPushToSourceBranch || false, }, }, }, + }, + }; + + return createMockApollo([ + [userPermissionsQuery, jest.fn().mockResolvedValue(userData)], + [conflictsStateQuery, jest.fn().mockResolvedValue(mrData)], + ]); + }; + + async function createComponent({ + propsData, + queryData, + apolloProvider = defaultApolloProvider(queryData), + } = {}) { + wrapper = extendedWrapper( + mount(ConflictsComponent, { + apolloProvider, + propsData, }), ); - await nextTick(); + await waitForPromises(); } // There are two permissions we need to consider: @@ -62,11 +92,15 @@ describe('MRWidgetConflicts', () => { describe('when allowed to merge but not allowed to push to source branch', () => { beforeEach(async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: true, canPushToSourceBranch: false, conflictResolutionPath: path, - conflictsDocsPath: '', }, }); }); @@ -89,11 +123,15 @@ describe('MRWidgetConflicts', () => { describe('when not allowed to merge but allowed to push to source branch', () => { beforeEach(async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictResolutionPath: path, + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: false, canPushToSourceBranch: true, - conflictResolutionPath: path, - conflictsDocsPath: '', }, }); }); @@ -116,11 +154,15 @@ describe('MRWidgetConflicts', () => { describe('when allowed to merge and push to source branch', () => { beforeEach(async () => { await createComponent({ - mr: { + queryData: { canMerge: true, canPushToSourceBranch: true, - conflictResolutionPath: path, - conflictsDocsPath: '', + }, + propsData: { + mr: { + conflictResolutionPath: path, + conflictsDocsPath: '', + }, }, }); }); @@ -144,10 +186,14 @@ describe('MRWidgetConflicts', () => { describe('when user does not have permission to push to source branch', () => { it('should show proper message', async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: false, canPushToSourceBranch: false, - conflictsDocsPath: '', }, }); @@ -156,10 +202,14 @@ describe('MRWidgetConflicts', () => { it('should not have action buttons', async () => { await createComponent({ - mr: { + queryData: { canMerge: false, canPushToSourceBranch: false, - conflictsDocsPath: '', + }, + propsData: { + mr: { + conflictsDocsPath: '', + }, }, }); @@ -169,10 +219,14 @@ describe('MRWidgetConflicts', () => { it('should not have resolve button when no conflict resolution path', async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictResolutionPath: null, + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: true, - conflictResolutionPath: null, - conflictsDocsPath: '', }, }); @@ -183,9 +237,13 @@ describe('MRWidgetConflicts', () => { describe('when fast-forward or semi-linear merge enabled', () => { it('should tell you to rebase locally', async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictsDocsPath: '', + }, + }, + queryData: { shouldBeRebased: true, - conflictsDocsPath: '', }, }); @@ -196,12 +254,16 @@ describe('MRWidgetConflicts', () => { describe('when source branch protected', () => { beforeEach(async () => { await createComponent({ - mr: { + propsData: { + mr: { + conflictResolutionPath: TEST_HOST, + conflictsDocsPath: '', + }, + }, + queryData: { canMerge: true, - canPushToSourceBranch: true, - conflictResolutionPath: TEST_HOST, sourceBranchProtected: true, - conflictsDocsPath: '', + canPushToSourceBranch: true, }, }); }); @@ -214,12 +276,16 @@ describe('MRWidgetConflicts', () => { describe('when source branch not protected', () => { beforeEach(async () => { await createComponent({ - mr: { - canMerge: true, + propsData: { + mr: { + conflictResolutionPath: TEST_HOST, + conflictsDocsPath: '', + }, + }, + queryData: { canPushToSourceBranch: true, - conflictResolutionPath: TEST_HOST, + canMerge: true, sourceBranchProtected: false, - conflictsDocsPath: '', }, }); }); @@ -229,4 +295,21 @@ describe('MRWidgetConflicts', () => { expect(findResolveButton().attributes('href')).toEqual(TEST_HOST); }); }); + + describe('error states', () => { + it('when project is null due to expired session it does not throw', async () => { + const fn = async () => { + await createComponent({ + propsData: { mr: {} }, + apolloProvider: createMockApollo([ + [conflictsStateQuery, jest.fn().mockResolvedValue({ data: { project: null } })], + [userPermissionsQuery, jest.fn().mockResolvedValue({ data: { project: null } })], + ]), + }); + await waitForPromises(); + }; + + await expect(fn()).resolves.not.toThrow(); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js index 85acd5f9a9e..328c0134368 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js @@ -1,8 +1,12 @@ import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import simplePoll from '~/lib/utils/simple_poll'; import MrWidgetMerging from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue'; import BoldText from '~/vue_merge_request_widget/components/bold_text.vue'; +import { STATUS_MERGED } from '~/issues/constants'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; +jest.mock('~/super_sidebar/user_counts_fetch'); jest.mock('~/lib/utils/simple_poll', () => jest.fn().mockImplementation(jest.requireActual('~/lib/utils/simple_poll').default), ); @@ -13,7 +17,7 @@ describe('MRWidgetMerging', () => { const pollMock = jest.fn().mockResolvedValue(); const GlEmoji = { template: '<img />' }; - beforeEach(() => { + const createComponent = () => { wrapper = shallowMount(MrWidgetMerging, { propsData: { mr: { @@ -29,14 +33,18 @@ describe('MRWidgetMerging', () => { GlEmoji, }, }); - }); + }; it('renders information about merge request being merged', () => { + createComponent(); + const message = wrapper.findComponent(BoldText).props('message'); expect(message).toContain('Merging!'); }); describe('initiateMergePolling', () => { + beforeEach(createComponent); + it('should call simplePoll', () => { expect(simplePoll).toHaveBeenCalledWith(expect.any(Function), { timeout: 0 }); }); @@ -45,4 +53,15 @@ describe('MRWidgetMerging', () => { expect(pollMock).toHaveBeenCalled(); }); }); + + describe('on successful merge', () => { + it('should re-fetch user counts', async () => { + pollMock.mockResolvedValueOnce({ data: { state: STATUS_MERGED } }); + createComponent(); + + await nextTick(); + + expect(fetchUserCounts).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js index 016eac05727..d8eec165395 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -1,5 +1,6 @@ -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing_to_merge.vue'; describe('NothingToMerge', () => { @@ -14,6 +15,7 @@ describe('NothingToMerge', () => { }; const findNothingToMergeTextBody = () => wrapper.findByTestId('nothing-to-merge-body'); + const findHelpLink = () => wrapper.findComponent(GlLink); describe('With Blob link', () => { beforeEach(() => { @@ -26,5 +28,9 @@ describe('NothingToMerge', () => { 'Use merge requests to propose changes to your project and discuss them with your team. To make changes, use the Code dropdown list above, then test them with CI/CD before merging.', ); }); + + it('renders text with link to CI Help Page', () => { + expect(findHelpLink().attributes('href')).toBe(helpPagePath('ci/quick_start/index.html')); + }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js index 9239807ae71..1b7338744e8 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -1,9 +1,10 @@ -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import { GlSprintf } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import produce from 'immer'; +import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client'; import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; @@ -15,13 +16,11 @@ import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squa import MergeFailedPipelineConfirmationDialog from '~/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue'; import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; +import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql'; jest.mock('~/lib/utils/simple_poll', () => jest.fn().mockImplementation(jest.requireActual('~/lib/utils/simple_poll').default), ); -jest.mock('~/commons/nav/user_merge_requests', () => ({ - refreshUserMergeRequestCounts: jest.fn(), -})); const commitMessage = readyToMergeResponse.data.project.mergeRequest.defaultMergeCommitMessage; const squashCommitMessage = @@ -82,6 +81,7 @@ Vue.use(VueApollo); let service; let wrapper; let readyToMergeResponseSpy; +let mockedSubscription; const createReadyToMergeResponse = (customMr) => { return produce(readyToMergeResponse, (draft) => { @@ -90,7 +90,21 @@ const createReadyToMergeResponse = (customMr) => { }; const createComponent = (customConfig = {}, createState = true) => { - wrapper = shallowMount(ReadyToMerge, { + mockedSubscription = createMockApolloSubscription(); + const apolloProvider = createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]); + const subscriptionResponse = { + data: { mergeRequestMergeStatusUpdated: { ...readyToMergeResponse.data.project.mergeRequest } }, + }; + subscriptionResponse.data.mergeRequestMergeStatusUpdated.defaultMergeCommitMessage = + 'New default merge commit message'; + + const subscriptionHandlers = [[readyToMergeSubscription, () => mockedSubscription]]; + + subscriptionHandlers.forEach(([query, stream]) => { + apolloProvider.defaultClient.setRequestHandler(query, stream); + }); + + wrapper = shallowMountExtended(ReadyToMerge, { propsData: { mr: createTestMr(customConfig), service, @@ -112,7 +126,7 @@ const createComponent = (customConfig = {}, createState = true) => { CommitEdit, GlSprintf, }, - apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]), + apolloProvider, }); }; @@ -843,4 +857,60 @@ describe('ReadyToMerge', () => { expect(wrapper.text()).not.toContain('Auto-merge enabled'); }); }); + + describe('commit message', () => { + it('updates commit message from subscription', async () => { + createComponent({ mr: { id: 1 } }); + + await waitForPromises(); + + await wrapper.findByTestId('widget_edit_commit_message').vm.$emit('input', true); + + expect(wrapper.findByTestId('merge-commit-message').props('value')).not.toEqual( + 'Updated commit message', + ); + + mockedSubscription.next({ + data: { + mergeRequestMergeStatusUpdated: { + ...readyToMergeResponse.data.project.mergeRequest, + defaultMergeCommitMessage: 'Updated commit message', + }, + }, + }); + + await waitForPromises(); + + expect(wrapper.findByTestId('merge-commit-message').props('value')).toEqual( + 'Updated commit message', + ); + }); + + it('does not update commit message from subscription if commit message has been manually changed', async () => { + createComponent({ mr: { id: 1 } }); + + await waitForPromises(); + + await wrapper.findByTestId('widget_edit_commit_message').vm.$emit('input', true); + + await wrapper + .findByTestId('merge-commit-message') + .vm.$emit('input', 'Manually updated commit message'); + + mockedSubscription.next({ + data: { + mergeRequestMergeStatusUpdated: { + ...readyToMergeResponse.data.project.mergeRequest, + defaultMergeCommitMessage: 'Updated commit message', + }, + }, + }); + + await waitForPromises(); + + expect(wrapper.findByTestId('merge-commit-message').props('value')).toEqual( + 'Manually updated commit message', + ); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js b/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js index f46829539a8..f01df2ca419 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js @@ -42,6 +42,9 @@ describe('~/vue_merge_request_widget/components/states/work_in_progress.vue', () mergeRequest: { __typename: 'MergeRequest', id: TEST_MR_ID, + draft: true, + title: TEST_MR_TITLE, + mergeableDiscussionsState: false, userPermissions: { updateMergeRequest: canUpdateMergeRequest, }, @@ -179,4 +182,17 @@ describe('~/vue_merge_request_widget/components/states/work_in_progress.vue', () expect(findWIPButton().exists()).toBe(false); }); }); + + describe('when project is null', () => { + beforeEach(async () => { + draftQuerySpy.mockResolvedValue({ data: { project: null } }); + createComponent(); + await waitForPromises(); + }); + + // This is to mitigate https://gitlab.com/gitlab-org/gitlab/-/issues/413627 + it('does not throw any error', () => { + expect(wrapper.exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap index d5d3f56e451..f2a66ad2ff2 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap +++ b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap @@ -49,7 +49,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render name="MyWidget" /> <div - class="gl-display-flex gl-w-full" + class="gl-display-flex gl-flex-direction-column gl-w-full" > <div class="gl-display-flex gl-flex-grow-1" @@ -88,8 +88,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render > <li> <div - class="gl-align-items-center gl-display-flex" - data-qa-selector="child_content" + class="gl-align-items-baseline gl-display-flex" > <div class="gl-min-w-0 gl-w-full" @@ -111,7 +110,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render class="gl-align-items-baseline gl-display-flex" > <div - class="gl-display-flex gl-w-full" + class="gl-display-flex gl-flex-direction-column gl-w-full" > <div class="gl-display-flex gl-flex-grow-1" 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 deleted file mode 100644 index d5e04c666e0..00000000000 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js +++ /dev/null @@ -1,224 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { GlBadge } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { trimText } from 'helpers/text_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality/index.vue'; -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'; - -describe('Code Quality extension', () => { - let wrapper; - let mock; - const endpoint = '/root/repo/-/merge_requests/4/codequality_reports.json'; - - const mockApi = (statusCode, data) => { - mock.onGet(endpoint).reply(statusCode, data); - }; - - 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(codeQualityExtension, { - propsData: { - mr: { - codequality: endpoint, - codequalityReportsPath: endpoint, - blobPath: { - head_path: 'example/path', - base_path: 'example/path', - }, - }, - }, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('summary', () => { - it('displays loading text', () => { - mockApi(HTTP_STATUS_OK, codeQualityResponseNewErrors); - - createComponent(); - - expect(wrapper.text()).toBe(i18n.loading); - }); - - it('with a 204 response, continues to display loading state', async () => { - mockApi(HTTP_STATUS_NO_CONTENT, ''); - createComponent(); - - await waitForPromises(); - - expect(wrapper.text()).toBe(i18n.loading); - }); - - it('displays failed loading text', async () => { - mockApi(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - createComponent(); - - await waitForPromises(); - - expect(wrapper.text()).toBe(i18n.error); - expect(isCollapsable()).toBe(false); - }); - - 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 - .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(HTTP_STATUS_OK, codeQualityResponseResolvedAndNewErrors); - - createComponent(); - await waitForPromises(); - - // replacing strong tags because they will not be found in the rendered text - expect(wrapper.text()).toBe( - i18n - .improvementAndDegradationCopy( - 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(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(HTTP_STATUS_OK, codeQualityResponseResolvedAndNewErrors); - - createComponent(); - - await waitForPromises(); - - findToggleCollapsedButton().trigger('click'); - - await waitForPromises(); - }); - - it('displays all report list items in viewport', () => { - expect(findAllExtensionListItems()).toHaveLength(4); - }); - - it('displays report list item formatted', () => { - const text = { - newError: trimText(findAllExtensionListItems().at(0).text().replace(/\s+/g, ' ').trim()), - resolvedError: findAllExtensionListItems().at(2).text().replace(/\s+/g, ' ').trim(), - }; - - expect(text.newError).toContain( - "Minor - Parsing error: 'return' outside of function in index.js:12", - ); - expect(text.resolvedError).toContain( - "Minor - Parsing error: 'return' outside of function in index.js:12 Fixed", - ); - }); - - it('displays report list item formatted with check_name', () => { - const text = { - newError: trimText(findAllExtensionListItems().at(1).text().replace(/\s+/g, ' ').trim()), - resolvedError: findAllExtensionListItems().at(3).text().replace(/\s+/g, ' ').trim(), - }; - - expect(text.newError).toContain( - 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3', - ); - expect(text.resolvedError).toContain( - 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3 Fixed', - ); - }); - - it('adds fixed indicator (badge) when error is resolved', () => { - expect(findAllExtensionListItems().at(3).findComponent(GlBadge).exists()).toBe(true); - expect(findAllExtensionListItems().at(3).findComponent(GlBadge).text()).toEqual(i18n.fixed); - }); - - it('should not add fixed indicator (badge) when error is new', () => { - expect(findAllExtensionListItems().at(0).findComponent(GlBadge).exists()).toBe(false); - }); - }); -}); 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 deleted file mode 100644 index e66c1521ff5..00000000000 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js +++ /dev/null @@ -1,101 +0,0 @@ -export const codeQualityResponseNewErrors = { - status: 'failed', - new_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, - }, - ], - resolved_errors: [], - existing_errors: [], - summary: { - total: 12235, - resolved: 0, - 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, - }, -}; - -export const codeQualityResponseResolvedAndNewErrors = { - status: 'failed', - new_errors: [ - { - description: "Parsing error: 'return' outside of function", - severity: 'minor', - file_path: 'index.js', - line: 12, - }, - { - description: 'Avoid parameter lists longer than 5 parameters. [12/5]', - check_name: 'Rubocop/Metrics/ParameterLists', - severity: 'minor', - file_path: 'main.rb', - line: 3, - }, - ], - resolved_errors: [ - { - description: "Parsing error: 'return' outside of function", - severity: 'minor', - file_path: 'index.js', - line: 12, - }, - { - description: 'Avoid parameter lists longer than 5 parameters. [12/5]', - check_name: 'Rubocop/Metrics/ParameterLists', - severity: 'minor', - file_path: 'main.rb', - line: 3, - }, - ], - existing_errors: [], - summary: { - total: 12233, - resolved: 1, - errored: 12233, - }, -}; - -export const codeQualityResponseNoErrors = { - status: 'failed', - new_errors: [], - resolved_errors: [], - existing_errors: [], - summary: { - total: 12234, - resolved: 0, - errored: 12234, - }, -}; |