import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlSkeletonLoader, GlIcon, GlLink, GlSprintf, GlButton, GlLoadingIcon } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createAlert, VARIANT_INFO } from '~/alert'; import ForkInfo, { i18n } from '~/repository/components/fork_info.vue'; import ConflictsModal from '~/repository/components/fork_sync_conflicts_modal.vue'; import forkDetailsQuery from '~/repository/queries/fork_details.query.graphql'; import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql'; import eventHub from '~/repository/event_hub'; import { POLLING_INTERVAL_DEFAULT, POLLING_INTERVAL_BACKOFF, FORK_UPDATED_EVENT, } from '~/repository/constants'; import { propsForkInfo } from '../mock_data'; jest.mock('~/alert'); describe('ForkInfo component', () => { let wrapper; let mockForkDetailsQuery; const forkInfoError = new Error('Something went wrong'); const projectId = 'gid://gitlab/Project/1'; const showMock = jest.fn(); const synchronizeFork = true; Vue.use(VueApollo); const waitForPolling = async (interval = POLLING_INTERVAL_DEFAULT) => { jest.advanceTimersByTime(interval); await waitForPromises(); }; const mockResolvedForkDetailsQuery = ( forkDetails = { ahead: 3, behind: 7, isSyncing: false, hasConflicts: false }, ) => { mockForkDetailsQuery.mockResolvedValue({ data: { project: { id: projectId, forkDetails }, }, }); }; const createSyncForkDetailsData = ( forkDetails = { ahead: 3, behind: 7, isSyncing: false, hasConflicts: false }, ) => { return { data: { projectSyncFork: { details: forkDetails, errors: [] }, }, }; }; const createComponent = (props = {}, mutationData = {}) => { wrapper = shallowMountExtended(ForkInfo, { apolloProvider: createMockApollo([ [forkDetailsQuery, mockForkDetailsQuery], [syncForkMutation, jest.fn().mockResolvedValue(createSyncForkDetailsData(mutationData))], ]), propsData: { ...propsForkInfo, ...props }, stubs: { GlSprintf, GlButton, ConflictsModal: stubComponent(ConflictsModal, { template: '
', methods: { show: showMock }, }), }, provide: { glFeatures: { synchronizeFork, }, }, }); return waitForPromises(); }; const findLink = () => wrapper.findComponent(GlLink); const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); const findIcon = () => wrapper.findComponent(GlIcon); const findUpdateForkButton = () => wrapper.findByTestId('update-fork-button'); const findCreateMrButton = () => wrapper.findByTestId('create-mr-button'); const findViewMrButton = () => wrapper.findByTestId('view-mr-button'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDivergenceMessage = () => wrapper.findByTestId('divergence-message'); const findInaccessibleMessage = () => wrapper.findByTestId('inaccessible-project'); const findCompareLinks = () => findDivergenceMessage().findAllComponents(GlLink); const startForkUpdate = async () => { findUpdateForkButton().vm.$emit('click'); await waitForPromises(); }; beforeEach(() => { mockForkDetailsQuery = jest.fn(); mockResolvedForkDetailsQuery(); }); it('displays a skeleton while loading data', () => { createComponent(); expect(findSkeleton().exists()).toBe(true); }); 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(mockForkDetailsQuery).toHaveBeenCalled(); }); it('does not query the data when sourceName is empty', async () => { await createComponent({ sourceName: null }); expect(mockForkDetailsQuery).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 Create MR Button with correct path', async () => { await createComponent(); expect(findCreateMrButton().attributes('href')).toBe(propsForkInfo.createMrPath); }); it('renders View MR Button with correct path', async () => { const viewMrPath = 'path/to/view/mr'; await createComponent({ viewMrPath }); expect(findViewMrButton().attributes('href')).toBe(viewMrPath); }); it('does not render create MR button if create MR path is blank', async () => { await createComponent({ createMrPath: '' }); expect(findCreateMrButton().exists()).toBe(false); }); it('renders alert with error message when request fails', async () => { mockForkDetailsQuery.mockRejectedValue(forkInfoError); await createComponent({}); expect(createAlert).toHaveBeenCalledWith({ message: i18n.error, captureError: true, error: forkInfoError, }); }); describe('Unknown divergence', () => { it('renders unknown divergence message when divergence is unknown', async () => { mockResolvedForkDetailsQuery({ ahead: null, behind: null, isSyncing: false, hasConflicts: false, }); await createComponent({}); expect(findDivergenceMessage().text()).toBe(i18n.unknown); }); it('renders Update Fork button', async () => { mockResolvedForkDetailsQuery({ ahead: null, behind: null, isSyncing: false, hasConflicts: false, }); await createComponent({}); expect(findUpdateForkButton().exists()).toBe(true); expect(findUpdateForkButton().text()).toBe(i18n.updateFork); }); }); describe('Up to date divergence', () => { beforeEach(async () => { mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false }); await createComponent({}, { ahead: 0, behind: 0, isSyncing: false, hasConflicts: false }); }); it('renders up to date message when fork is up to date', () => { expect(findDivergenceMessage().text()).toBe(i18n.upToDate); }); it('does not render Update Fork button', () => { expect(findUpdateForkButton().exists()).toBe(false); }); }); describe('Limited visibility project', () => { beforeEach(async () => { mockResolvedForkDetailsQuery(null); await createComponent({}, null); }); it('renders limited visibility message when forkDetails are empty', () => { expect(findDivergenceMessage().text()).toBe(i18n.limitedVisibility); }); it('does not render Update Fork button', () => { expect(findUpdateForkButton().exists()).toBe(false); }); }); describe('User cannot sync the branch', () => { beforeEach(async () => { mockResolvedForkDetailsQuery({ ahead: 0, behind: 7, isSyncing: false, hasConflicts: false }); await createComponent( { canSyncBranch: false }, { ahead: 0, behind: 7, isSyncing: false, hasConflicts: false }, ); }); it('does not render Update Fork button', () => { expect(findUpdateForkButton().exists()).toBe(false); }); }); describe.each([ { ahead: 7, behind: 3, message: '3 commits behind, 7 commits ahead of the upstream repository.', firstLink: propsForkInfo.behindComparePath, secondLink: propsForkInfo.aheadComparePath, hasUpdateButton: true, hasCreateMrButton: true, }, { ahead: 7, behind: 0, message: '7 commits ahead of the upstream repository.', firstLink: propsForkInfo.aheadComparePath, secondLink: '', hasUpdateButton: false, hasCreateMrButton: true, }, { ahead: 0, behind: 3, message: '3 commits behind the upstream repository.', firstLink: propsForkInfo.behindComparePath, secondLink: '', hasUpdateButton: true, hasCreateMrButton: false, }, ])( 'renders correct divergence message for ahead: $ahead, behind: $behind divergence commits', ({ ahead, behind, message, firstLink, secondLink, hasUpdateButton, hasCreateMrButton }) => { beforeEach(async () => { mockResolvedForkDetailsQuery({ ahead, behind, isSyncing: false, hasConflicts: false }); await createComponent({}); }); it('displays correct text', () => { expect(findDivergenceMessage().text()).toBe(message); }); it('adds correct links', () => { const links = findCompareLinks(); expect(links.at(0).attributes('href')).toBe(firstLink); if (secondLink) { expect(links.at(1).attributes('href')).toBe(secondLink); } }); it('renders Update Fork button when fork is behind', () => { expect(findUpdateForkButton().exists()).toBe(hasUpdateButton); if (hasUpdateButton) { expect(findUpdateForkButton().text()).toBe(i18n.updateFork); } }); it('renders Create Merge Request button when fork is ahead', () => { expect(findCreateMrButton().exists()).toBe(hasCreateMrButton); if (hasCreateMrButton) { expect(findCreateMrButton().text()).toBe(i18n.createMergeRequest); } }); }, ); describe('when sync is not possible due to conflicts', () => { it('Opens Conflicts Modal', async () => { mockResolvedForkDetailsQuery({ ahead: 7, behind: 3, isSyncing: false, hasConflicts: true }); await createComponent({}); findUpdateForkButton().vm.$emit('click'); expect(showMock).toHaveBeenCalled(); }); }); describe('projectSyncFork mutation', () => { it('changes button to have loading state', async () => { await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }); mockResolvedForkDetailsQuery({ ahead: 0, behind: 3, isSyncing: false, hasConflicts: false }); expect(findLoadingIcon().exists()).toBe(false); await startForkUpdate(); expect(findLoadingIcon().exists()).toBe(true); }); }); describe('polling', () => { beforeEach(async () => { await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }); mockResolvedForkDetailsQuery({ ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }); }); it('fetches data on the initial load', () => { expect(mockForkDetailsQuery).toHaveBeenCalledTimes(1); }); it('starts polling after sync button is clicked', async () => { await startForkUpdate(); await waitForPolling(); expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2); await waitForPolling(POLLING_INTERVAL_DEFAULT * POLLING_INTERVAL_BACKOFF); expect(mockForkDetailsQuery).toHaveBeenCalledTimes(3); }); it('stops polling once sync is finished', async () => { mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false }); await startForkUpdate(); await waitForPolling(); expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2); await waitForPolling(POLLING_INTERVAL_DEFAULT * POLLING_INTERVAL_BACKOFF); expect(mockForkDetailsQuery).toHaveBeenCalledTimes(2); await nextTick(); }); }); describe('once fork is updated', () => { beforeEach(async () => { await createComponent({}, { ahead: 0, behind: 3, isSyncing: true, hasConflicts: false }); mockResolvedForkDetailsQuery({ ahead: 0, behind: 0, isSyncing: false, hasConflicts: false }); }); it('shows info alert once the fork is updated', async () => { await startForkUpdate(); await waitForPolling(); expect(createAlert).toHaveBeenCalledWith({ message: i18n.successMessage, variant: VARIANT_INFO, }); }); it('emits fork:updated event to eventHub', async () => { jest.spyOn(eventHub, '$emit').mockImplementation(); await startForkUpdate(); await waitForPolling(); expect(eventHub.$emit).toHaveBeenCalledWith(FORK_UPDATED_EVENT); }); it('hides update fork button', async () => { jest.spyOn(eventHub, '$emit').mockImplementation(); await startForkUpdate(); await waitForPolling(); expect(findUpdateForkButton().exists()).toBe(false); }); }); });