diff options
Diffstat (limited to 'spec/frontend/vue_merge_request_widget')
83 files changed, 10556 insertions, 0 deletions
diff --git a/spec/frontend/vue_merge_request_widget/components/action_buttons.js b/spec/frontend/vue_merge_request_widget/components/action_buttons.js new file mode 100644 index 00000000000..6d714aeaf18 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/action_buttons.js @@ -0,0 +1,47 @@ +import { GlButton, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Actions from '~/vue_merge_request_widget/components/action_buttons.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(Actions, { + propsData: { ...propsData, widget: 'test' }, + }); +} + +describe('MR widget extension actions', () => { + afterEach(() => { + wrapper.destroy(); + }); + + describe('tertiaryButtons', () => { + it('renders buttons', () => { + factory({ + tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], + }); + + expect(wrapper.findAllComponents(GlButton)).toHaveLength(1); + }); + + it('calls action click handler', async () => { + const onClick = jest.fn(); + + factory({ + tertiaryButtons: [{ text: 'hello world', onClick }], + }); + + await wrapper.findComponent(GlButton).vm.$emit('click'); + + expect(onClick).toHaveBeenCalled(); + }); + + it('renders tertiary actions in dropdown', () => { + factory({ + tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], + }); + + expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js new file mode 100644 index 00000000000..cb53dc1fb61 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue'; + +let wrapper; + +function factory(propsData) { + wrapper = shallowMount(AddedCommentMessage, { + propsData: { + isFastForwardEnabled: false, + targetBranch: 'main', + ...propsData, + }, + }); +} + +describe('Widget added commit message', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('displays changes where not merged when state is closed', () => { + factory({ state: 'closed' }); + + expect(wrapper.element.outerHTML).toContain('The changes were not merged'); + }); +}); 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 new file mode 100644 index 00000000000..05cd1bb5b3d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js @@ -0,0 +1,424 @@ +import { nextTick } from 'vue'; +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createFlash from '~/flash'; +import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; +import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; +import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; +import { + FETCH_LOADING, + FETCH_ERROR, + APPROVE_ERROR, + UNAPPROVE_ERROR, +} from '~/vue_merge_request_widget/components/approvals/messages'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +jest.mock('~/flash'); + +const RULE_NAME = 'first_rule'; +const TEST_HELP_PATH = 'help/path'; +const testApprovedBy = () => [1, 7, 10].map((id) => ({ id })); +const testApprovals = () => ({ + approved: false, + approved_by: testApprovedBy().map((user) => ({ user })), + approval_rules_left: [], + approvals_left: 4, + suggested_approvers: [], + user_can_approve: true, + user_has_approved: true, + require_password_to_approve: false, + invalid_approvers_rules: [], +}); +const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] }); + +describe('MRWidget approvals', () => { + let wrapper; + let service; + let mr; + + const createComponent = (props = {}) => { + wrapper = shallowMount(Approvals, { + propsData: { + mr, + service, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + const findAction = () => wrapper.find(GlButton); + const findActionData = () => { + const action = findAction(); + + return !action.exists() + ? null + : { + variant: action.props('variant'), + category: action.props('category'), + text: action.text(), + }; + }; + const findSummary = () => wrapper.find(ApprovalsSummary); + const findOptionalSummary = () => wrapper.find(ApprovalsSummaryOptional); + const findInvalidRules = () => wrapper.find('[data-testid="invalid-rules"]'); + + beforeEach(() => { + service = { + ...{ + fetchApprovals: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + fetchApprovalSettings: jest + .fn() + .mockReturnValue(Promise.resolve(testApprovalRulesResponse())), + approveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + unapproveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + approveMergeRequestWithAuth: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + }, + }; + mr = { + ...{ + setApprovals: jest.fn(), + setApprovalRules: jest.fn(), + }, + approvalsHelpPath: TEST_HELP_PATH, + approvals: testApprovals(), + approvalRules: [], + isOpen: true, + state: 'open', + }; + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when created', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows loading message', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ fetchingApprovals: true }); + + return nextTick().then(() => { + expect(wrapper.text()).toContain(FETCH_LOADING); + }); + }); + + it('fetches approvals', () => { + expect(service.fetchApprovals).toHaveBeenCalled(); + }); + }); + + describe('when fetch approvals error', () => { + beforeEach(() => { + jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject()); + createComponent(); + return nextTick(); + }); + + it('still shows loading message', () => { + expect(wrapper.text()).toContain(FETCH_LOADING); + }); + + it('flashes error', () => { + expect(createFlash).toHaveBeenCalledWith({ message: FETCH_ERROR }); + }); + }); + + describe('action button', () => { + describe('when mr is closed', () => { + beforeEach(() => { + mr.isOpen = false; + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = true; + + createComponent(); + return nextTick(); + }); + + it('action is not rendered', () => { + expect(findActionData()).toBe(null); + }); + }); + + describe('when user cannot approve', () => { + beforeEach(() => { + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = false; + + createComponent(); + return nextTick(); + }); + + it('action is not rendered', () => { + expect(findActionData()).toBe(null); + }); + }); + + describe('when user can approve', () => { + beforeEach(() => { + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = true; + }); + + describe('and MR is unapproved', () => { + beforeEach(() => { + createComponent(); + return nextTick(); + }); + + it('approve action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'confirm', + text: 'Approve', + category: 'primary', + }); + }); + }); + + describe('and MR is approved', () => { + beforeEach(() => { + mr.approvals.approved = true; + }); + + describe('with no approvers', () => { + beforeEach(() => { + mr.approvals.approved_by = []; + createComponent(); + return nextTick(); + }); + + it('approve action (with inverted style) is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'confirm', + text: 'Approve', + category: 'secondary', + }); + }); + }); + + describe('with approvers', () => { + beforeEach(() => { + mr.approvals.approved_by = [{ user: { id: 7 } }]; + createComponent(); + return nextTick(); + }); + + it('approve additionally action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'confirm', + text: 'Approve additionally', + category: 'secondary', + }); + }); + }); + }); + + describe('when approve action is clicked', () => { + beforeEach(() => { + createComponent(); + return nextTick(); + }); + + it('shows loading icon', () => { + jest.spyOn(service, 'approveMergeRequest').mockReturnValue(new Promise(() => {})); + const action = findAction(); + + expect(action.props('loading')).toBe(false); + + action.vm.$emit('click'); + + return nextTick().then(() => { + expect(action.props('loading')).toBe(true); + }); + }); + + describe('and after loading', () => { + beforeEach(() => { + findAction().vm.$emit('click'); + return nextTick(); + }); + + it('calls service approve', () => { + expect(service.approveMergeRequest).toHaveBeenCalled(); + }); + + it('emits to eventHub', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + }); + + it('calls store setApprovals', () => { + expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals()); + }); + }); + + describe('and error', () => { + beforeEach(() => { + jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject()); + findAction().vm.$emit('click'); + return nextTick(); + }); + + it('flashes error message', () => { + expect(createFlash).toHaveBeenCalledWith({ message: APPROVE_ERROR }); + }); + }); + }); + }); + + describe('when user has approved', () => { + beforeEach(() => { + mr.approvals.user_has_approved = true; + mr.approvals.user_can_approve = false; + + createComponent(); + return nextTick(); + }); + + it('revoke action is rendered', () => { + expect(findActionData()).toEqual({ + category: 'primary', + variant: 'default', + text: 'Revoke approval', + }); + }); + + describe('when revoke action is clicked', () => { + describe('and successful', () => { + beforeEach(() => { + findAction().vm.$emit('click'); + return nextTick(); + }); + + it('calls service unapprove', () => { + expect(service.unapproveMergeRequest).toHaveBeenCalled(); + }); + + it('emits to eventHub', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + }); + + it('calls store setApprovals', () => { + expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals()); + }); + }); + + describe('and error', () => { + beforeEach(() => { + jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject()); + findAction().vm.$emit('click'); + return nextTick(); + }); + + it('flashes error message', () => { + expect(createFlash).toHaveBeenCalledWith({ message: UNAPPROVE_ERROR }); + }); + }); + }); + }); + }); + + describe('approvals optional summary', () => { + describe('when no approvals required and no approvers', () => { + beforeEach(() => { + mr.approvals.approved_by = []; + mr.approvals.approvals_required = 0; + mr.approvals.user_has_approved = false; + }); + + describe('and can approve', () => { + beforeEach(() => { + mr.approvals.user_can_approve = true; + + createComponent(); + return nextTick(); + }); + + it('is shown', () => { + expect(findSummary().exists()).toBe(false); + expect(findOptionalSummary().props()).toEqual({ + canApprove: true, + helpPath: TEST_HELP_PATH, + }); + }); + }); + + describe('and cannot approve', () => { + beforeEach(() => { + mr.approvals.user_can_approve = false; + + createComponent(); + return nextTick(); + }); + + it('is shown', () => { + expect(findSummary().exists()).toBe(false); + expect(findOptionalSummary().props()).toEqual({ + canApprove: false, + helpPath: TEST_HELP_PATH, + }); + }); + }); + }); + }); + + describe('approvals summary', () => { + beforeEach(() => { + createComponent(); + return nextTick(); + }); + + it('is rendered with props', () => { + const expected = testApprovals(); + const summary = findSummary(); + + expect(findOptionalSummary().exists()).toBe(false); + expect(summary.exists()).toBe(true); + expect(summary.props()).toMatchObject({ + approvalsLeft: expected.approvals_left, + rulesLeft: expected.approval_rules_left, + approvers: testApprovedBy(), + }); + }); + }); + + describe('invalid rules', () => { + beforeEach(() => { + mr.approvals.merge_request_approvers_available = true; + createComponent(); + }); + + it('does not render related components', () => { + expect(findInvalidRules().exists()).toBe(false); + }); + + describe('when invalid rules are present', () => { + beforeEach(() => { + mr.approvals.invalid_approvers_rules = [{ name: RULE_NAME }]; + createComponent(); + }); + + it('renders related components', () => { + const invalidRules = findInvalidRules(); + + expect(invalidRules.exists()).toBe(true); + + const invalidRulesText = invalidRules.text(); + + expect(invalidRulesText).toContain(RULE_NAME); + expect(invalidRulesText).toContain( + 'GitLab has approved this rule automatically to unblock the merge request.', + ); + expect(invalidRulesText).toContain('Learn more.'); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js new file mode 100644 index 00000000000..65cafc647e0 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_optional_spec.js @@ -0,0 +1,45 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; + +const TEST_HELP_PATH = 'help/path'; + +describe('MRWidget approvals summary optional', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ApprovalsSummaryOptional, { + propsData: props, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findHelpLink = () => wrapper.find(GlLink); + + describe('when can approve', () => { + beforeEach(() => { + createComponent({ canApprove: true, helpPath: TEST_HELP_PATH }); + }); + + it('shows help link', () => { + const link = findHelpLink(); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(TEST_HELP_PATH); + }); + }); + + describe('when cannot approve', () => { + beforeEach(() => { + createComponent({ canApprove: false, helpPath: TEST_HELP_PATH }); + }); + + it('does not show help link', () => { + expect(findHelpLink().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js new file mode 100644 index 00000000000..c2606346292 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_summary_spec.js @@ -0,0 +1,142 @@ +import { shallowMount } from '@vue/test-utils'; +import { toNounSeriesText } from '~/lib/utils/grammar'; +import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; +import { + APPROVED_BY_OTHERS, + APPROVED_BY_YOU, + APPROVED_BY_YOU_AND_OTHERS, +} from '~/vue_merge_request_widget/components/approvals/messages'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; + +const exampleUserId = 1; +const testApprovers = () => Array.from({ length: 5 }, (_, i) => i).map((id) => ({ id })); +const testRulesLeft = () => ['Lorem', 'Ipsum', 'dolar & sit']; +const TEST_APPROVALS_LEFT = 3; + +describe('MRWidget approvals summary', () => { + const originalUserId = gon.current_user_id; + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ApprovalsSummary, { + propsData: { + approved: false, + approvers: testApprovers(), + approvalsLeft: TEST_APPROVALS_LEFT, + rulesLeft: testRulesLeft(), + ...props, + }, + }); + }; + + const findAvatars = () => wrapper.find(UserAvatarList); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + gon.current_user_id = originalUserId; + }); + + describe('when approved', () => { + beforeEach(() => { + createComponent({ + approved: true, + }); + }); + + it('shows approved message', () => { + expect(wrapper.text()).toContain(APPROVED_BY_OTHERS); + }); + + it('renders avatar list for approvers', () => { + const avatars = findAvatars(); + + expect(avatars.exists()).toBe(true); + expect(avatars.props()).toEqual( + expect.objectContaining({ + items: testApprovers(), + }), + ); + }); + + describe('by the current user', () => { + beforeEach(() => { + gon.current_user_id = exampleUserId; + createComponent({ + approvers: [{ id: exampleUserId }], + approved: true, + }); + }); + + it('shows "Approved by you" message', () => { + expect(wrapper.text()).toContain(APPROVED_BY_YOU); + }); + }); + + describe('by the current user and others', () => { + beforeEach(() => { + gon.current_user_id = exampleUserId; + createComponent({ + approvers: [{ id: exampleUserId }, { id: exampleUserId + 1 }], + approved: true, + }); + }); + + it('shows "Approved by you and others" message', () => { + expect(wrapper.text()).toContain(APPROVED_BY_YOU_AND_OTHERS); + }); + }); + + describe('by other users than the current user', () => { + beforeEach(() => { + gon.current_user_id = exampleUserId; + createComponent({ + approvers: [{ id: exampleUserId + 1 }], + approved: true, + }); + }); + + it('shows "Approved by others" message', () => { + expect(wrapper.text()).toContain(APPROVED_BY_OTHERS); + }); + }); + }); + + describe('when not approved', () => { + beforeEach(() => { + createComponent(); + }); + + it('render message', () => { + const names = toNounSeriesText(testRulesLeft()); + + expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} approvals from ${names}.`); + }); + }); + + describe('when no rulesLeft', () => { + beforeEach(() => { + createComponent({ + rulesLeft: [], + }); + }); + + it('renders message', () => { + expect(wrapper.text()).toContain( + `Requires ${TEST_APPROVALS_LEFT} approvals from eligible users`, + ); + }); + }); + + describe('when no approvers', () => { + beforeEach(() => { + createComponent({ + approvers: [], + }); + }); + + it('does not render avatar list', () => { + expect(wrapper.find(UserAvatarList).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/humanized_text_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/humanized_text_spec.js new file mode 100644 index 00000000000..d6776c00b29 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/approvals/humanized_text_spec.js @@ -0,0 +1,18 @@ +import { humanizeInvalidApproversRules } from '~/vue_merge_request_widget/components/approvals/humanized_text'; + +const testRules = [{ name: 'Lorem' }, { name: 'Ipsum' }, { name: 'Dolar' }]; + +describe('humanizeInvalidApproversRules', () => { + it('returns text in regards to a single rule', () => { + const [singleRule] = testRules; + expect(humanizeInvalidApproversRules([singleRule])).toBe('"Lorem"'); + }); + + it('returns empty text when there is no rule', () => { + expect(humanizeInvalidApproversRules([])).toBe(''); + }); + + it('returns text in regards to multiple rules', () => { + expect(humanizeInvalidApproversRules(testRules)).toBe('"Lorem", "Ipsum" and "Dolar"'); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js b/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js new file mode 100644 index 00000000000..e2386bc7f2b --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/artifacts_list_app_spec.js @@ -0,0 +1,126 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { TEST_HOST as FAKE_ENDPOINT } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import ArtifactsListApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; +import { getStoreConfig } from '~/vue_merge_request_widget/stores/artifacts_list'; +import { artifacts } from '../mock_data'; + +Vue.use(Vuex); + +describe('Merge Requests Artifacts list app', () => { + let wrapper; + let store; + let mock; + + const actionSpies = { + fetchArtifacts: jest.fn(), + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + const createComponent = () => { + const storeConfig = getStoreConfig(); + store = new Vuex.Store({ + ...storeConfig, + actions: { + ...storeConfig.actions, + ...actionSpies, + }, + }); + + wrapper = mount(ArtifactsListApp, { + propsData: { + endpoint: FAKE_ENDPOINT, + }, + store, + }); + }; + + const findButtons = () => wrapper.findAll('button'); + const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]'); + const findErrorMessage = () => wrapper.find('.js-error-state'); + const findTableRows = () => wrapper.findAll('tbody tr'); + + describe('while loading', () => { + beforeEach(() => { + createComponent(); + store.dispatch('requestArtifacts'); + return nextTick(); + }); + + it('renders a loading icon', () => { + const loadingIcon = wrapper.find(GlLoadingIcon); + expect(loadingIcon.exists()).toBe(true); + }); + + it('renders loading text', () => { + expect(findTitle().text()).toBe('Loading artifacts'); + }); + + it('renders disabled buttons', () => { + const buttons = findButtons(); + expect(buttons.at(0).attributes('disabled')).toBe('disabled'); + expect(buttons.at(1).attributes('disabled')).toBe('disabled'); + }); + }); + + describe('with results', () => { + beforeEach(() => { + createComponent(); + mock.onGet(FAKE_ENDPOINT).reply(200, artifacts, {}); + store.dispatch('receiveArtifactsSuccess', { + data: artifacts, + status: 200, + }); + return nextTick(); + }); + + it('renders a title with the number of artifacts', () => { + expect(findTitle().text()).toBe('View 2 exposed artifacts'); + }); + + it('renders both buttons enabled', () => { + const buttons = findButtons(); + expect(buttons.at(0).attributes('disabled')).toBe(undefined); + expect(buttons.at(1).attributes('disabled')).toBe(undefined); + }); + + describe('on click', () => { + it('renders the list of artifacts', async () => { + findTitle().trigger('click'); + await nextTick(); + + expect(findTableRows().length).toEqual(2); + }); + }); + }); + + describe('with error', () => { + beforeEach(() => { + createComponent(); + mock.onGet(FAKE_ENDPOINT).reply(500, {}, {}); + store.dispatch('receiveArtifactsError'); + return nextTick(); + }); + + it('renders the error state', () => { + expect(findErrorMessage().text()).toBe('An error occurred while fetching the artifacts'); + }); + + it('does not render buttons', () => { + const buttons = findButtons(); + expect(buttons.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js b/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js new file mode 100644 index 00000000000..712abfe228a --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js @@ -0,0 +1,48 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ArtifactsList from '~/vue_merge_request_widget/components/artifacts_list.vue'; +import { artifacts } from '../mock_data'; + +describe('Artifacts List', () => { + let wrapper; + + const data = { + artifacts, + }; + + const mountComponent = (props) => { + wrapper = shallowMount(ArtifactsList, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + mountComponent(data); + }); + + it('renders list of artifacts', () => { + expect(wrapper.findAll('tbody tr').length).toEqual(data.artifacts.length); + }); + + it('renders link for the artifact', () => { + expect(wrapper.find(GlLink).attributes('href')).toEqual(data.artifacts[0].url); + }); + + it('renders artifact name', () => { + expect(wrapper.find(GlLink).text()).toEqual(data.artifacts[0].text); + }); + + it('renders job url', () => { + expect(wrapper.findAll(GlLink).at(1).attributes('href')).toEqual(data.artifacts[0].job_path); + }); + + it('renders job name', () => { + expect(wrapper.findAll(GlLink).at(1).text()).toEqual(data.artifacts[0].job_name); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/extensions/child_content_spec.js b/spec/frontend/vue_merge_request_widget/components/extensions/child_content_spec.js new file mode 100644 index 00000000000..198a4c2823a --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/extensions/child_content_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import ChildContent from '~/vue_merge_request_widget/components/extensions/child_content.vue'; + +let wrapper; +const mockData = () => ({ + header: 'Test header', + text: 'Test content', + icon: { + name: 'error', + }, +}); + +function factory(propsData) { + wrapper = shallowMount(ChildContent, { + propsData: { + ...propsData, + widgetLabel: 'Test', + }, + }); +} + +describe('MR widget extension child content', () => { + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders child components', () => { + factory({ + data: { + ...mockData(), + children: [mockData()], + }, + level: 2, + }); + + expect(wrapper.find('[data-testid="child-content"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="child-content"]').props('level')).toBe(3); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/extensions/index_spec.js b/spec/frontend/vue_merge_request_widget/components/extensions/index_spec.js new file mode 100644 index 00000000000..dc25596655a --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/extensions/index_spec.js @@ -0,0 +1,34 @@ +import { + registerExtension, + registeredExtensions, +} from '~/vue_merge_request_widget/components/extensions'; +import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue'; + +describe('MR widget extension registering', () => { + it('registers a extension', () => { + registerExtension({ + name: 'Test', + props: ['helloWorld'], + computed: { + test() {}, + }, + methods: { + test() {}, + }, + }); + + expect(registeredExtensions.extensions[0]).toEqual( + expect.objectContaining({ + extends: ExtensionBase, + name: 'Test', + computed: { + helloWorld: expect.any(Function), + test: expect.any(Function), + }, + methods: { + test: expect.any(Function), + }, + }), + ); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/extensions/status_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/extensions/status_icon_spec.js new file mode 100644 index 00000000000..f3aa5bb774f --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/extensions/status_icon_spec.js @@ -0,0 +1,36 @@ +import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(StatusIcon, { + propsData, + }); +} + +describe('MR widget extensions status icon', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('renders loading icon', () => { + factory({ name: 'test', isLoading: true, iconName: 'failed' }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders status icon', () => { + factory({ name: 'test', isLoading: false, iconName: 'failed' }); + + expect(wrapper.findComponent(GlIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlIcon).props('name')).toBe('status-failed'); + }); + + it('sets aria-label for status icon', () => { + factory({ name: 'test', isLoading: false, iconName: 'failed' }); + + expect(wrapper.findComponent(GlIcon).props('ariaLabel')).toBe('Failed test'); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/extensions/utils_spec.js b/spec/frontend/vue_merge_request_widget/components/extensions/utils_spec.js new file mode 100644 index 00000000000..5799799ad5e --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/extensions/utils_spec.js @@ -0,0 +1,20 @@ +import { generateText } from '~/vue_merge_request_widget/components/extensions/utils'; + +describe('generateText', () => { + it.each` + text | expectedText + ${'%{strong_start}Hello world%{strong_end}'} | ${'<span class="gl-font-weight-bold">Hello world</span>'} + ${'%{success_start}Hello world%{success_end}'} | ${'<span class="gl-font-weight-bold gl-text-green-500">Hello world</span>'} + ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'} + ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'} + ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'} + ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm gl-text-gray-700">Hello world</span>'} + ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'} + ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'} + ${{ text: 'Hello world', href: 'http://www.example.com' }} | ${'<a class="gl-text-decoration-underline" href="http://www.example.com">Hello world</a>'} + ${{ prependText: 'Hello', text: 'world', href: 'http://www.example.com' }} | ${'Hello <a class="gl-text-decoration-underline" href="http://www.example.com">world</a>'} + ${['array']} | ${null} + `('generates $expectedText from $text', ({ text, expectedText }) => { + expect(generateText(text)).toBe(expectedText); + }); +}); 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 new file mode 100644 index 00000000000..01fbcb2154f --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_collapsible_extension_spec.js @@ -0,0 +1,97 @@ +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue'; + +describe('Merge Request Collapsible Extension', () => { + let wrapper; + const data = { + title: 'View artifacts', + }; + + const mountComponent = (props) => { + wrapper = mount(MrCollapsibleSection, { + propsData: { + ...props, + }, + slots: { + default: '<div class="js-slot">Foo</div>', + header: '<span data-testid="collapsed-header">hello there</span>', + }, + }); + }; + + const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]'); + const findErrorMessage = () => wrapper.find('.js-error-state'); + const findIcon = () => wrapper.find(GlIcon); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('while collapsed', () => { + beforeEach(() => { + mountComponent(data); + }); + + it('renders provided title', () => { + expect(findTitle().text()).toBe(data.title); + }); + + it('renders the header slot', () => { + 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'); + }); + + describe('onClick', () => { + beforeEach(async () => { + wrapper.find('button').trigger('click'); + await nextTick(); + }); + + it('rendes the provided slot', () => { + expect(wrapper.find('.js-slot').isVisible()).toBe(true); + }); + + it('renders `Collapse` as the title', () => { + expect(findTitle().text()).toBe('Collapse'); + }); + + it('renders chevron-lg-down icon', () => { + expect(findIcon().props('name')).toBe('chevron-lg-down'); + }); + }); + }); + + describe('while loading', () => { + beforeEach(() => { + mountComponent({ ...data, isLoading: true }); + }); + + it('renders the buttons disabled', () => { + expect(wrapper.findAll('button').at(0).attributes('disabled')).toEqual('disabled'); + expect(wrapper.findAll('button').at(1).attributes('disabled')).toEqual('disabled'); + }); + + it('renders loading spinner', () => { + expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true); + }); + }); + + describe('with error', () => { + beforeEach(() => { + mountComponent({ ...data, hasError: true }); + }); + + it('does not render the buttons', () => { + expect(wrapper.findAll('button').exists()).toBe(false); + }); + + it('renders title message provided', () => { + expect(findErrorMessage().text()).toBe(data.title); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_alert_message_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_alert_message_spec.js new file mode 100644 index 00000000000..5d923d0383f --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_alert_message_spec.js @@ -0,0 +1,45 @@ +import { GlLink, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MrWidgetAlertMessage from '~/vue_merge_request_widget/components/mr_widget_alert_message.vue'; + +let wrapper; + +function createComponent(propsData = {}) { + wrapper = shallowMount(MrWidgetAlertMessage, { + propsData, + }); +} + +describe('MrWidgetAlertMessage', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('should render a GlAert', () => { + createComponent({ type: 'danger' }); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(true); + expect(wrapper.findComponent(GlAlert).props('variant')).toBe('danger'); + }); + + describe('when helpPath is not provided', () => { + it('should not render a help link', () => { + createComponent({ type: 'info' }); + + const link = wrapper.findComponent(GlLink); + + expect(link.exists()).toBe(false); + }); + }); + + describe('when helpPath is provided', () => { + it('should render a help link', () => { + createComponent({ type: 'info', helpPath: 'https://gitlab.com' }); + + const link = wrapper.findComponent(GlLink); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('https://gitlab.com'); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_author_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_spec.js new file mode 100644 index 00000000000..8a42e2e2ce7 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_spec.js @@ -0,0 +1,62 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue'; + +window.gl = window.gl || {}; + +describe('MrWidgetAuthor', () => { + let wrapper; + let oldWindowGl; + const mockAuthor = { + name: 'Administrator', + username: 'root', + webUrl: 'http://localhost:3000/root', + avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + beforeEach(() => { + oldWindowGl = window.gl; + window.gl = { + mrWidgetData: { + defaultAvatarUrl: 'no_avatar.png', + }, + }; + wrapper = shallowMount(MrWidgetAuthor, { + propsData: { + author: mockAuthor, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + window.gl = oldWindowGl; + }); + + it('renders link with the author web url', () => { + expect(wrapper.attributes('href')).toBe('http://localhost:3000/root'); + }); + + it('renders image with avatar url', () => { + expect(wrapper.find('img').attributes('src')).toBe( + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + ); + }); + + it('renders image with default avatar url when no avatarUrl is present in author', async () => { + wrapper.setProps({ + author: { + ...mockAuthor, + avatarUrl: null, + }, + }); + + await nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('no_avatar.png'); + }); + + it('renders author name', () => { + expect(wrapper.find('span').text()).toBe('Administrator'); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js new file mode 100644 index 00000000000..8fd93809e01 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_author_time_spec.js @@ -0,0 +1,43 @@ +import { shallowMount } from '@vue/test-utils'; +import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue'; +import MrWidgetAuthorTime from '~/vue_merge_request_widget/components/mr_widget_author_time.vue'; + +describe('MrWidgetAuthorTime', () => { + let wrapper; + + const defaultProps = { + actionText: 'Merged by', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'http://localhost:3000/root', + avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + dateTitle: '2017-03-23T23:02:00.807Z', + dateReadable: '12 hours ago', + }; + + beforeEach(() => { + wrapper = shallowMount(MrWidgetAuthorTime, { + propsData: defaultProps, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders provided action text', () => { + expect(wrapper.text()).toContain('Merged by'); + }); + + it('renders author', () => { + expect(wrapper.find(MrWidgetAuthor).props('author')).toStrictEqual(defaultProps.author); + }); + + it('renders provided time', () => { + expect(wrapper.find('time').attributes('title')).toBe('2017-03-23T23:02:00.807Z'); + + expect(wrapper.find('time').text().trim()).toBe('12 hours ago'); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_container_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_container_spec.js new file mode 100644 index 00000000000..4e3e918f7fb --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_container_spec.js @@ -0,0 +1,48 @@ +import { shallowMount } from '@vue/test-utils'; +import MrWidgetContainer from '~/vue_merge_request_widget/components/mr_widget_container.vue'; + +const BODY_HTML = '<div class="test-body">Hello World</div>'; +const FOOTER_HTML = '<div class="test-footer">Goodbye!</div>'; + +describe('MrWidgetContainer', () => { + let wrapper; + + const factory = (options = {}) => { + wrapper = shallowMount(MrWidgetContainer, { + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('has layout', () => { + factory(); + + expect(wrapper.classes()).toContain('mr-widget-heading'); + expect(wrapper.find('.mr-widget-content').exists()).toBe(true); + }); + + it('accepts default slot', () => { + factory({ + slots: { + default: BODY_HTML, + }, + }); + + expect(wrapper.find('.mr-widget-content .test-body').exists()).toBe(true); + }); + + it('accepts footer slot', () => { + factory({ + slots: { + default: BODY_HTML, + footer: FOOTER_HTML, + }, + }); + + expect(wrapper.find('.mr-widget-content .test-body').exists()).toBe(true); + expect(wrapper.find('.test-footer').exists()).toBe(true); + }); +}); 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 new file mode 100644 index 00000000000..631aef412a6 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js @@ -0,0 +1,66 @@ +import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_widget_expandable_section.vue'; + +describe('MrWidgetExpanableSection', () => { + let wrapper; + + const findButton = () => wrapper.find(GlButton); + const findCollapse = () => wrapper.find(GlCollapse); + + beforeEach(() => { + wrapper = shallowMount(MrCollapsibleSection, { + slots: { + content: '<span>Collapsable Content</span>', + header: '<span>Header Content</span>', + }, + }); + }); + + it('renders Icon', () => { + expect(wrapper.find(GlIcon).exists()).toBe(true); + }); + + it('renders header slot', () => { + expect(wrapper.text()).toContain('Header Content'); + }); + + it('renders content slot', () => { + expect(wrapper.text()).toContain('Collapsable Content'); + }); + + describe('when collapse section is closed', () => { + it('renders button with expand text', () => { + expect(findButton().text()).toBe('Expand'); + }); + + it('renders a collpased section with no visibility', () => { + const collapse = findCollapse(); + + expect(collapse.exists()).toBe(true); + expect(collapse.attributes('visible')).toBeUndefined(); + }); + }); + + describe('when collapse section is open', () => { + beforeEach(async () => { + findButton().vm.$emit('click'); + await nextTick(); + }); + + it('renders button with collapse text', () => { + const button = findButton(); + + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Collapse'); + }); + + it('renders a collpased section with visible content', () => { + const collapse = findCollapse(); + + expect(collapse.exists()).toBe(true); + expect(collapse.attributes('visible')).toBe('true'); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js new file mode 100644 index 00000000000..ebd10f31fa7 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_icon_spec.js @@ -0,0 +1,26 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; + +const TEST_ICON = 'commit'; + +describe('MrWidgetIcon', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(MrWidgetIcon, { + propsData: { + name: TEST_ICON, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders icon and container', () => { + expect(wrapper.element.className).toContain('circle-icon-container'); + expect(wrapper.find(GlIcon).props('name')).toEqual(TEST_ICON); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js new file mode 100644 index 00000000000..193a16bae8d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_memory_usage_spec.js @@ -0,0 +1,227 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import MemoryUsage from '~/vue_merge_request_widget/components/deployment/memory_usage.vue'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; + +const url = '/root/acets-review-apps/environments/15/deployments/1/metrics'; +const monitoringUrl = '/root/acets-review-apps/environments/15/metrics'; + +const metricsMockData = { + success: true, + metrics: { + memory_before: [ + { + metric: {}, + value: [1495785220.607, '9572875.906976745'], + }, + ], + memory_after: [ + { + metric: {}, + value: [1495787020.607, '4485853.130206379'], + }, + ], + memory_values: [ + { + metric: {}, + values: [[1493716685, '4.30859375']], + }, + ], + }, + last_update: '2017-05-02T12:34:49.628Z', + deployment_time: 1493718485, +}; + +const createComponent = () => { + const Component = Vue.extend(MemoryUsage); + + return new Component({ + el: document.createElement('div'), + propsData: { + metricsUrl: url, + metricsMonitoringUrl: monitoringUrl, + memoryMetrics: [], + deploymentTime: 0, + hasMetrics: false, + loadFailed: false, + loadingMetrics: true, + backOffRequestCounter: 0, + }, + }); +}; + +const messages = { + loadingMetrics: 'Loading deployment statistics', + hasMetrics: 'Memory usage is unchanged at 0MB', + loadFailed: 'Failed to load deployment statistics', + metricsUnavailable: 'Deployment statistics are not available currently', +}; + +describe('MemoryUsage', () => { + let vm; + let el; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(`${url}.json`).reply(200); + + vm = createComponent(); + el = vm.$el; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('data', () => { + it('should have default data', () => { + const data = MemoryUsage.data(); + + expect(Array.isArray(data.memoryMetrics)).toBe(true); + expect(data.memoryMetrics.length).toBe(0); + + expect(typeof data.deploymentTime).toBe('number'); + expect(data.deploymentTime).toBe(0); + + expect(typeof data.hasMetrics).toBe('boolean'); + expect(data.hasMetrics).toBe(false); + + expect(typeof data.loadFailed).toBe('boolean'); + expect(data.loadFailed).toBe(false); + + expect(typeof data.loadingMetrics).toBe('boolean'); + expect(data.loadingMetrics).toBe(true); + + expect(typeof data.backOffRequestCounter).toBe('number'); + expect(data.backOffRequestCounter).toBe(0); + }); + }); + + describe('computed', () => { + describe('memoryChangeMessage', () => { + it('should contain "increased" if memoryFrom value is less than memoryTo value', () => { + vm.memoryFrom = 4.28; + vm.memoryTo = 9.13; + + expect(vm.memoryChangeMessage.indexOf('increased')).not.toEqual('-1'); + }); + + it('should contain "decreased" if memoryFrom value is less than memoryTo value', () => { + vm.memoryFrom = 9.13; + vm.memoryTo = 4.28; + + expect(vm.memoryChangeMessage.indexOf('decreased')).not.toEqual('-1'); + }); + + it('should contain "unchanged" if memoryFrom value equal to memoryTo value', () => { + vm.memoryFrom = 1; + vm.memoryTo = 1; + + expect(vm.memoryChangeMessage.indexOf('unchanged')).not.toEqual('-1'); + }); + }); + }); + + describe('methods', () => { + const { metrics, deployment_time } = metricsMockData; + + describe('getMegabytes', () => { + it('should return Megabytes from provided Bytes value', () => { + const memoryInBytes = '9572875.906976745'; + + expect(vm.getMegabytes(memoryInBytes)).toEqual('9.13'); + }); + }); + + describe('computeGraphData', () => { + it('should populate sparkline graph', () => { + // ignore BoostrapVue warnings + jest.spyOn(console, 'warn').mockImplementation(); + + vm.computeGraphData(metrics, deployment_time); + const { hasMetrics, memoryMetrics, deploymentTime, memoryFrom, memoryTo } = vm; + + expect(hasMetrics).toBe(true); + expect(memoryMetrics.length).toBeGreaterThan(0); + expect(deploymentTime).toEqual(deployment_time); + expect(memoryFrom).toEqual('9.13'); + expect(memoryTo).toEqual('4.28'); + }); + }); + + describe('loadMetrics', () => { + it('should load metrics data using MRWidgetService', async () => { + jest.spyOn(MRWidgetService, 'fetchMetrics').mockResolvedValue({ + data: metricsMockData, + }); + jest.spyOn(vm, 'computeGraphData').mockImplementation(() => {}); + + vm.loadMetrics(); + + await waitForPromises(); + + expect(MRWidgetService.fetchMetrics).toHaveBeenCalledWith(url); + expect(vm.computeGraphData).toHaveBeenCalledWith(metrics, deployment_time); + }); + }); + }); + + describe('template', () => { + it('should render template elements correctly', () => { + expect(el.classList.contains('mr-memory-usage')).toBe(true); + expect(el.querySelector('.js-usage-info')).toBeDefined(); + }); + + it('should show loading metrics message while metrics are being loaded', async () => { + vm.loadingMetrics = true; + vm.hasMetrics = false; + vm.loadFailed = false; + + await nextTick(); + + expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); + }); + + it('should show deployment memory usage when metrics are loaded', async () => { + // ignore BoostrapVue warnings + jest.spyOn(console, 'warn').mockImplementation(); + + vm.loadingMetrics = false; + vm.hasMetrics = true; + vm.loadFailed = false; + vm.memoryMetrics = metricsMockData.metrics.memory_values[0].values; + + await nextTick(); + + expect(el.querySelector('.memory-graph-container')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); + }); + + it('should show failure message when metrics loading failed', async () => { + vm.loadingMetrics = false; + vm.hasMetrics = false; + vm.loadFailed = true; + + await nextTick(); + + expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); + }); + + it('should show metrics unavailable message when metrics loading failed', async () => { + vm.loadingMetrics = false; + vm.hasMetrics = false; + vm.loadFailed = false; + + await nextTick(); + + expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js new file mode 100644 index 00000000000..efe2bf75c3f --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_container_spec.js @@ -0,0 +1,131 @@ +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; +import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue'; +import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; +import { mockStore } from '../mock_data'; + +describe('MrWidgetPipelineContainer', () => { + let wrapper; + let mock; + + const factory = (props = {}) => { + wrapper = extendedWrapper( + mount(MrWidgetPipelineContainer, { + propsData: { + mr: { ...mockStore }, + ...props, + }, + }), + ); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet().reply(200, {}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDeploymentList = () => wrapper.findComponent(DeploymentList); + const findCIErrorMessage = () => wrapper.findByTestId('ci-error-message'); + + describe('when pre merge', () => { + beforeEach(() => { + factory(); + }); + + it('renders pipeline', () => { + expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + pipeline: mockStore.pipeline, + pipelineCoverageDelta: mockStore.pipelineCoverageDelta, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.sourceBranch, + sourceBranchLink: mockStore.sourceBranchLink, + }); + }); + + it('renders deployments', () => { + const expectedProps = mockStore.deployments.map((dep) => + expect.objectContaining({ + deployment: dep, + showMetrics: false, + }), + ); + + const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment'); + + expect(findDeploymentList().exists()).toBe(true); + expect(findDeploymentList().props('deployments')).toBe(mockStore.deployments); + + expect(deployments.wrappers.map((x) => x.props())).toEqual(expectedProps); + }); + }); + + describe('when post merge', () => { + beforeEach(() => { + factory({ + isPostMerge: true, + mr: { + ...mockStore, + pipeline: {}, + ciStatus: undefined, + }, + }); + }); + + it('renders pipeline', () => { + expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(findCIErrorMessage().exists()).toBe(false); + expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + pipeline: mockStore.mergePipeline, + pipelineCoverageDelta: mockStore.pipelineCoverageDelta, + ciStatus: mockStore.mergePipeline.details.status.text, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.targetBranch, + sourceBranchLink: mockStore.targetBranch, + }); + }); + + it('sanitizes the targetBranch', () => { + factory({ + isPostMerge: true, + mr: { + ...mockStore, + targetBranch: 'Foo<script>alert("XSS")</script>', + }, + }); + expect(wrapper.find(MrWidgetPipeline).props().sourceBranchLink).toBe('Foo'); + }); + + it('renders deployments', () => { + const expectedProps = mockStore.postMergeDeployments.map((dep) => + expect.objectContaining({ + deployment: dep, + showMetrics: true, + }), + ); + + const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment'); + + expect(findDeploymentList().exists()).toBe(true); + expect(findDeploymentList().props('deployments')).toBe(mockStore.postMergeDeployments); + expect(deployments.wrappers.map((x) => x.props())).toEqual(expectedProps); + }); + }); + + describe('with artifacts path', () => { + it('renders the artifacts app', () => { + factory(); + + expect(wrapper.find(ArtifactsApp).isVisible()).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 new file mode 100644 index 00000000000..6347e3c3be3 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js @@ -0,0 +1,282 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { trimText } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; +import PipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import { SUCCESS } from '~/vue_merge_request_widget/constants'; +import mockData from '../mock_data'; + +describe('MRWidgetPipeline', () => { + let wrapper; + + const defaultProps = { + pipeline: mockData.pipeline, + ciStatus: SUCCESS, + hasCi: true, + mrTroubleshootingDocsPath: 'help', + ciTroubleshootingDocsPath: 'ci-help', + }; + + const ciErrorMessage = + 'Could not retrieve the pipeline status. For troubleshooting steps, read the documentation.'; + const monitoringMessage = 'Checking pipeline status.'; + + const findCIErrorMessage = () => wrapper.findByTestId('ci-error-message'); + const findPipelineID = () => wrapper.findByTestId('pipeline-id'); + const findPipelineInfoContainer = () => wrapper.findByTestId('pipeline-info-container'); + const findCommitLink = () => wrapper.findByTestId('commit-link'); + const findPipelineFinishedAt = () => wrapper.findByTestId('finished-at'); + const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + const findAllPipelineStages = () => wrapper.findAllComponents(PipelineStage); + const findPipelineCoverage = () => wrapper.findByTestId('pipeline-coverage'); + const findPipelineCoverageDelta = () => wrapper.findByTestId('pipeline-coverage-delta'); + const findPipelineCoverageTooltipText = () => + wrapper.findByTestId('pipeline-coverage-tooltip').text(); + const findPipelineCoverageDeltaTooltipText = () => + wrapper.findByTestId('pipeline-coverage-delta-tooltip').text(); + const findMonitoringPipelineMessage = () => wrapper.findByTestId('monitoring-pipeline-message'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const mockArtifactsRequest = () => new MockAdapter(axios).onGet().reply(200, []); + + const createWrapper = (props = {}, mountFn = shallowMount) => { + wrapper = extendedWrapper( + mountFn(PipelineComponent, { + propsData: { + ...defaultProps, + ...props, + }, + }), + ); + }; + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('should render CI error if there is a pipeline, but no status', () => { + createWrapper({ ciStatus: null }, mount); + expect(findCIErrorMessage().text()).toBe(ciErrorMessage); + }); + + it('should render a loading state when no pipeline is found', () => { + createWrapper({ pipeline: {} }, mount); + + expect(findMonitoringPipelineMessage().text()).toBe(monitoringMessage); + expect(findLoadingIcon().exists()).toBe(true); + }); + + describe('with a pipeline', () => { + beforeEach(() => { + mockArtifactsRequest(); + + createWrapper( + { + pipelineCoverageDelta: mockData.pipelineCoverageDelta, + buildsWithCoverage: mockData.buildsWithCoverage, + }, + mount, + ); + }); + + it('should render pipeline ID', () => { + expect(findPipelineID().text().trim()).toBe(`#${mockData.pipeline.id}`); + }); + + it('should render pipeline status and commit id', () => { + expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); + + expect(findCommitLink().text().trim()).toBe(mockData.pipeline.commit.short_id); + + expect(findCommitLink().attributes('href')).toBe(mockData.pipeline.commit.commit_path); + }); + + it('should render pipeline finished timestamp', () => { + expect(findPipelineFinishedAt().attributes()).toMatchObject({ + title: 'Apr 7, 2017 2:00pm UTC', + datetime: mockData.pipeline.details.finished_at, + }); + }); + + it('should render pipeline graph', () => { + expect(findPipelineMiniGraph().exists()).toBe(true); + expect(findAllPipelineStages()).toHaveLength(mockData.pipeline.details.stages.length); + }); + + describe('should render pipeline coverage information', () => { + it('should render coverage percentage', () => { + expect(findPipelineCoverage().text()).toMatch( + `Test coverage ${mockData.pipeline.coverage}%`, + ); + }); + + it('should render coverage delta', () => { + expect(findPipelineCoverageDelta().exists()).toBe(true); + expect(findPipelineCoverageDelta().text()).toBe(`(${mockData.pipelineCoverageDelta}%)`); + }); + + it('should render tooltip for jobs contributing to code coverage', () => { + const tooltipText = findPipelineCoverageTooltipText(); + const expectedDescription = `Test coverage value for this pipeline was calculated by averaging the resulting coverage values of ${mockData.buildsWithCoverage.length} jobs.`; + + expect(tooltipText).toContain(expectedDescription); + }); + + it.each(mockData.buildsWithCoverage)( + 'should have name and coverage for build %s listed in tooltip', + (build) => { + const tooltipText = findPipelineCoverageTooltipText(); + + expect(tooltipText).toContain(`${build.name} (${build.coverage}%)`); + }, + ); + + describe.each` + style | coverageState | coverageChangeText | styleClass | pipelineCoverageDelta + ${'no special'} | ${'the same'} | ${'not change'} | ${''} | ${'0'} + ${'success'} | ${'increased'} | ${'increase'} | ${'text-success'} | ${'10'} + ${'danger'} | ${'decreased'} | ${'decrease'} | ${'text-danger'} | ${'-10'} + `( + 'if test coverage is $coverageState', + ({ style, styleClass, coverageChangeText, pipelineCoverageDelta }) => { + it(`coverage delta should have ${style}`, () => { + createWrapper({ pipelineCoverageDelta }); + expect(findPipelineCoverageDelta().classes()).toEqual(styleClass ? [styleClass] : []); + }); + + it(`coverage delta tooltip should say that the coverage will ${coverageChangeText}`, () => { + createWrapper({ pipelineCoverageDelta }); + expect(findPipelineCoverageDeltaTooltipText()).toContain(coverageChangeText); + }); + }, + ); + }); + }); + + describe('without commit path', () => { + beforeEach(() => { + const mockCopy = JSON.parse(JSON.stringify(mockData)); + delete mockCopy.pipeline.commit; + + createWrapper({}, mount); + }); + + it('should render pipeline ID', () => { + expect(findPipelineID().text().trim()).toBe(`#${mockData.pipeline.id}`); + }); + + it('should render pipeline status', () => { + expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); + }); + + it('should render pipeline graph with correct styles', () => { + const stagesCount = mockData.pipeline.details.stages.length; + + expect(findPipelineMiniGraph().exists()).toBe(true); + expect(findPipelineMiniGraph().findAll('.mr-widget-pipeline-stages')).toHaveLength( + stagesCount, + ); + + expect(findAllPipelineStages()).toHaveLength(stagesCount); + }); + + it('should render coverage information', () => { + expect(findPipelineCoverage().text()).toMatch(`Test coverage ${mockData.pipeline.coverage}%`); + }); + }); + + describe('without coverage', () => { + beforeEach(() => { + const mockCopy = JSON.parse(JSON.stringify(mockData)); + delete mockCopy.pipeline.coverage; + + createWrapper({ pipeline: mockCopy.pipeline }); + }); + + it('should not render a coverage component', () => { + expect(findPipelineCoverage().exists()).toBe(false); + }); + }); + + describe('without a pipeline graph', () => { + beforeEach(() => { + const mockCopy = JSON.parse(JSON.stringify(mockData)); + delete mockCopy.pipeline.details.stages; + + createWrapper({ + pipeline: mockCopy.pipeline, + }); + }); + + it('should not render a pipeline graph', () => { + expect(findPipelineMiniGraph().exists()).toBe(false); + }); + }); + + describe('for each type of pipeline', () => { + let pipeline; + + beforeEach(() => { + ({ pipeline } = JSON.parse(JSON.stringify(mockData))); + + pipeline.details.name = 'Pipeline'; + pipeline.merge_request_event_type = undefined; + pipeline.ref.tag = false; + pipeline.ref.branch = false; + }); + + const factory = () => { + createWrapper({ + pipeline, + sourceBranchLink: mockData.source_branch_link, + }); + }; + + describe('for a branch pipeline', () => { + it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => { + pipeline.ref.branch = true; + + factory(); + + const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockData.source_branch_link}`; + const actual = trimText(findPipelineInfoContainer().text()); + + expect(actual).toBe(expected); + }); + }); + + describe('for a tag pipeline', () => { + it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => { + pipeline.ref.tag = true; + + factory(); + + const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; + const actual = trimText(findPipelineInfoContainer().text()); + + expect(actual).toBe(expected); + }); + }); + + describe('for a detached merge request pipeline', () => { + it('renders a pipeline widget that reads "Detached merge request pipeline <ID> <status> for <SHA>"', () => { + pipeline.details.name = 'Detached merge request pipeline'; + pipeline.merge_request_event_type = 'detached'; + + factory(); + + const expected = `Detached merge request pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; + const actual = trimText(findPipelineInfoContainer().text()); + + expect(actual).toBe(expected); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js new file mode 100644 index 00000000000..534c0baf35d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js @@ -0,0 +1,245 @@ +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue'; +import eventHub from '~/vue_merge_request_widget/event_hub'; +import toast from '~/vue_shared/plugins/global_toast'; + +jest.mock('~/vue_shared/plugins/global_toast'); + +let wrapper; + +function createWrapper(propsData, mergeRequestWidgetGraphql) { + wrapper = mount(WidgetRebase, { + propsData, + data() { + return { + state: { + rebaseInProgress: propsData.mr.rebaseInProgress, + targetBranch: propsData.mr.targetBranch, + userPermissions: { + pushToSourceBranch: propsData.mr.canPushToSourceBranch, + }, + }, + }; + }, + provide: { glFeatures: { mergeRequestWidgetGraphql } }, + mocks: { + $apollo: { + queries: { + state: { loading: false }, + }, + }, + }, + }); +} + +describe('Merge request widget rebase component', () => { + const findRebaseMessage = () => wrapper.find('[data-testid="rebase-message"]'); + const findRebaseMessageText = () => findRebaseMessage().text(); + const findStandardRebaseButton = () => wrapper.find('[data-testid="standard-rebase-button"]'); + const findRebaseWithoutCiButton = () => wrapper.find('[data-testid="rebase-without-ci-button"]'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + [true, false].forEach((mergeRequestWidgetGraphql) => { + describe(`widget graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'disabled'}`, () => { + describe('while rebasing', () => { + it('should show progress message', () => { + createWrapper( + { + mr: { rebaseInProgress: true }, + service: {}, + }, + mergeRequestWidgetGraphql, + ); + + expect(findRebaseMessageText()).toContain('Rebase in progress'); + }); + }); + + describe('with permissions', () => { + const rebaseMock = jest.fn().mockResolvedValue(); + const pollMock = jest.fn().mockResolvedValue({}); + + it('renders the warning message', () => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + ); + + const text = findRebaseMessageText(); + + expect(text).toContain('Merge blocked'); + expect(text.replace(/\s\s+/g, ' ')).toContain( + 'the source branch must be rebased onto the target branch', + ); + }); + + it('renders an error message when rebasing has failed', async () => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + ); + + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ rebasingError: 'Something went wrong!' }); + + await nextTick(); + expect(findRebaseMessageText()).toContain('Something went wrong!'); + }); + + describe('Rebase buttons with', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('renders both buttons', () => { + expect(findRebaseWithoutCiButton().exists()).toBe(true); + expect(findStandardRebaseButton().exists()).toBe(true); + }); + + it('starts the rebase when clicking', async () => { + findStandardRebaseButton().vm.$emit('click'); + + await nextTick(); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false }); + }); + + it('starts the CI-skipping rebase when clicking on "Rebase without CI"', async () => { + findRebaseWithoutCiButton().vm.$emit('click'); + + await nextTick(); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: true }); + }); + }); + }); + + describe('without permissions', () => { + const exampleTargetBranch = 'fake-branch-to-test-with'; + + describe('UI text', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: false, + targetBranch: exampleTargetBranch, + }, + service: {}, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('renders a message explaining user does not have permissions', () => { + const text = findRebaseMessageText(); + + expect(text).toContain( + 'Merge blocked: the source branch must be rebased onto the target branch.', + ); + expect(text).toContain('the source branch must be rebased'); + }); + + it('renders the correct target branch name', () => { + const elem = findRebaseMessage(); + + expect(elem.text()).toContain( + 'Merge blocked: the source branch must be rebased onto the target branch.', + ); + }); + }); + + it('does render the "Rebase without pipeline" button', () => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: false, + targetBranch: exampleTargetBranch, + }, + service: {}, + }, + mergeRequestWidgetGraphql, + ); + + expect(findRebaseWithoutCiButton().exists()).toBe(true); + }); + }); + + describe('methods', () => { + it('checkRebaseStatus', async () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + createWrapper( + { + mr: {}, + service: { + rebase() { + return Promise.resolve(); + }, + poll() { + return Promise.resolve({ + data: { + rebase_in_progress: false, + should_be_rebased: false, + merge_error: null, + }, + }); + }, + }, + }, + mergeRequestWidgetGraphql, + ); + + wrapper.vm.rebase(); + + // Wait for the rebase request + await nextTick(); + // Wait for the polling request + await nextTick(); + // Wait for the eventHub to be called + await nextTick(); + + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetRebaseSuccess'); + expect(toast).toHaveBeenCalledWith('Rebase completed'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_related_links_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_related_links_spec.js new file mode 100644 index 00000000000..15522f7ac1d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_related_links_spec.js @@ -0,0 +1,114 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RelatedLinks from '~/vue_merge_request_widget/components/mr_widget_related_links.vue'; + +describe('MRWidgetRelatedLinks', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(RelatedLinks, { propsData }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('closesText', () => { + it('returns Closes text for open merge request', () => { + createComponent({ state: 'open', relatedLinks: {} }); + + expect(wrapper.vm.closesText).toBe('Closes issues'); + }); + + it('returns correct text for closed merge request', () => { + createComponent({ state: 'closed', relatedLinks: {} }); + + expect(wrapper.vm.closesText).toBe('Did not close'); + }); + + it('returns correct tense for merged request', () => { + createComponent({ state: 'merged', relatedLinks: {} }); + + expect(wrapper.vm.closesText).toBe('Closed'); + }); + }); + }); + + it('should have only have closing issues text', () => { + createComponent({ + relatedLinks: { + closing: '<a href="#">#23</a> and <a>#42</a>', + closingCount: 2, + }, + }); + const content = wrapper + .text() + .replace(/\n(\s)+/g, ' ') + .trim(); + + expect(content).toContain('Closes issues #23 and #42'); + expect(content).not.toContain('Mentions'); + }); + + it('should have only have mentioned issues text', () => { + createComponent({ + relatedLinks: { + mentioned: '<a href="#">#7</a>', + mentionedCount: 1, + }, + }); + + const content = wrapper + .text() + .replace(/\n(\s)+/g, ' ') + .trim(); + + expect(content).toContain('Mentions issue #7'); + expect(content).not.toContain('Closes issues'); + }); + + it('should have closing and mentioned issues at the same time', () => { + createComponent({ + relatedLinks: { + closing: '<a href="#">#7</a>', + mentioned: '<a href="#">#23</a> and <a>#42</a>', + closingCount: 1, + mentionedCount: 2, + }, + }); + const content = wrapper + .text() + .replace(/\n(\s)+/g, ' ') + .trim(); + + expect(content).toContain('Closes issue #7'); + expect(content).toContain('Mentions issues #23 and #42'); + }); + + describe('should have correct assign issues link', () => { + it.each([ + [1, 'Assign yourself to this issue'], + [2, 'Assign yourself to these issues'], + ])('when issue count is %s, link displays correct text', (unassignedCount, text) => { + const assignToMe = '/assign'; + + createComponent({ + relatedLinks: { assignToMe, unassignedCount }, + }); + + const glLinkWrapper = wrapper.findComponent(GlLink); + + expect(glLinkWrapper.attributes('href')).toBe(assignToMe); + expect(glLinkWrapper.text()).toBe(text); + }); + + it('when no link is present', () => { + createComponent({ + relatedLinks: { assignToMe: '#', unassignedCount: 0 }, + }); + + expect(wrapper.findComponent(GlLink).exists()).toBe(false); + }); + }); +}); 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 new file mode 100644 index 00000000000..11373be578a --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js @@ -0,0 +1,43 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import mrStatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; + +describe('MR widget status icon component', () => { + let wrapper; + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const createWrapper = (props, mountFn = shallowMount) => { + wrapper = mountFn(mrStatusIcon, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('while loading', () => { + it('renders loading icon', () => { + createWrapper({ status: 'loading' }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('with status icon', () => { + it('renders success status icon', () => { + createWrapper({ status: 'success' }, mount); + + expect(wrapper.find('[data-testid="status_success-icon"]').exists()).toBe(true); + }); + + it('renders failed status icon', () => { + createWrapper({ status: 'failed' }, mount); + + expect(wrapper.find('[data-testid="status_failed-icon"]').exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js new file mode 100644 index 00000000000..352bc1a08ea --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_suggest_pipeline_spec.js @@ -0,0 +1,135 @@ +import { GlSprintf } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; +import axios from '~/lib/utils/axios_utils'; +import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; +import suggestPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue'; +import { + SP_TRACK_LABEL, + SP_SHOW_TRACK_EVENT, + SP_SHOW_TRACK_VALUE, + SP_HELP_URL, +} from '~/vue_merge_request_widget/constants'; +import dismissibleContainer from '~/vue_shared/components/dismissible_container.vue'; +import { suggestProps, iconName } from './pipeline_tour_mock_data'; + +describe('MRWidgetSuggestPipeline', () => { + describe('template', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('core functionality', () => { + const findOkBtn = () => wrapper.find('[data-testid="ok"]'); + let trackingSpy; + let mockAxios; + + const mockTrackingOnWrapper = () => { + unmockTracking(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + document.body.dataset.page = 'projects:merge_requests:show'; + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + + wrapper = mount(suggestPipelineComponent, { + propsData: suggestProps, + stubs: { + GlSprintf, + }, + }); + }); + + afterEach(() => { + unmockTracking(); + mockAxios.restore(); + }); + + it('renders the expected text', () => { + const messageText = /Looks like there's no pipeline here./; + + expect(wrapper.text()).toMatch(messageText); + }); + + it('renders widget icon', () => { + const icon = wrapper.find(MrWidgetIcon); + + expect(icon.exists()).toBe(true); + expect(icon.props()).toEqual( + expect.objectContaining({ + name: iconName, + }), + ); + }); + + it('renders the show me how button', () => { + const button = findOkBtn(); + + expect(button.exists()).toBe(true); + expect(button.classes('btn-confirm')).toEqual(true); + expect(button.attributes('href')).toBe(suggestProps.pipelinePath); + }); + + it('renders the help link', () => { + const link = wrapper.find('[data-testid="help"]'); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(SP_HELP_URL); + }); + + it('renders the empty pipelines image', () => { + const image = wrapper.find('[data-testid="pipeline-image"]'); + + expect(image.exists()).toBe(true); + expect(image.attributes().src).toBe(suggestProps.pipelineSvgPath); + }); + + describe('tracking', () => { + it('send event for basic view of the suggest pipeline widget', () => { + const expectedCategory = undefined; + const expectedAction = undefined; + + expect(trackingSpy).toHaveBeenCalledWith(expectedCategory, expectedAction, { + label: SP_TRACK_LABEL, + property: suggestProps.humanAccess, + }); + }); + + it('send an event when ok button is clicked', () => { + mockTrackingOnWrapper(); + const okBtn = findOkBtn(); + triggerEvent(okBtn.element); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', SP_SHOW_TRACK_EVENT, { + label: SP_TRACK_LABEL, + property: suggestProps.humanAccess, + value: SP_SHOW_TRACK_VALUE.toString(), + }); + }); + }); + }); + + describe('dismissible', () => { + const findDismissContainer = () => wrapper.find(dismissibleContainer); + + beforeEach(() => { + wrapper = shallowMount(suggestPipelineComponent, { propsData: suggestProps }); + }); + + it('renders the dismissal container', () => { + expect(findDismissContainer().exists()).toBe(true); + }); + + it('emits dismiss upon dismissal button click', () => { + findDismissContainer().vm.$emit('dismiss'); + + expect(wrapper.emitted().dismiss).toBeTruthy(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/pipeline_tour_mock_data.js b/spec/frontend/vue_merge_request_widget/components/pipeline_tour_mock_data.js new file mode 100644 index 00000000000..eef087d62b8 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/pipeline_tour_mock_data.js @@ -0,0 +1,9 @@ +export const suggestProps = { + pipelinePath: '/foo/bar/add/pipeline/path', + pipelineSvgPath: 'assets/illustrations/something.svg', + humanAccess: 'maintainer', + userCalloutsPath: 'some/callout/path', + userCalloutFeatureId: 'suggest_pipeline', +}; + +export const iconName = 'status_notfound'; diff --git a/spec/frontend/vue_merge_request_widget/components/review_app_link_spec.js b/spec/frontend/vue_merge_request_widget/components/review_app_link_spec.js new file mode 100644 index 00000000000..e393b56034d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/review_app_link_spec.js @@ -0,0 +1,48 @@ +import { shallowMount } from '@vue/test-utils'; +import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; +import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue'; + +describe('review app link', () => { + const props = { + link: '/review', + cssClass: 'js-link', + display: { + text: 'View app', + tooltip: '', + }, + }; + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(ReviewAppLink, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders provided link as href attribute', () => { + expect(wrapper.attributes('href')).toBe(props.link); + }); + + it('renders provided cssClass as class attribute', () => { + expect(wrapper.classes('js-link')).toBe(true); + }); + + it('renders View app text', () => { + expect(wrapper.text().trim()).toBe('View app'); + }); + + it('renders svg icon', () => { + expect(wrapper.find('svg')).not.toBeNull(); + }); + + it('tracks an event when clicked', () => { + const spy = mockTracking('_category_', wrapper.element, jest.spyOn); + triggerEvent(wrapper.element); + + expect(spy).toHaveBeenCalledWith('_category_', 'open_review_app', { + label: 'review_app', + }); + }); +}); 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 new file mode 100644 index 00000000000..de25e2a0450 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap @@ -0,0 +1,241 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have correct elements 1`] = ` +<div + class="mr-widget-body media" +> + <svg + aria-hidden="true" + class="gl-text-blue-500 gl-mr-3 gl-mt-1 gl-icon s24" + data-testid="status_scheduled-icon" + role="img" + > + <use + href="#status_scheduled" + /> + </svg> + + <div + class="media-body gl-display-flex" + > + + <h4 + class="gl-mr-3" + data-testid="statusText" + > + Set by + <a + class="author-link inline" + > + <img + class="avatar avatar-inline s16" + src="no_avatar.png" + /> + + <span + class="author" + > + + </span> + </a> + to be merged automatically when the pipeline succeeds + </h4> + + <div + class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto gl-mt-1" + > + <div> + <div + class="dropdown b-dropdown gl-new-dropdown gl-display-block gl-md-display-none! btn-group" + lazy="" + no-caret="" + > + <!----> + <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-new-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> +`; + +exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have correct elements 1`] = ` +<div + class="mr-widget-body media" +> + <svg + aria-hidden="true" + class="gl-text-blue-500 gl-mr-3 gl-mt-1 gl-icon s24" + data-testid="status_scheduled-icon" + role="img" + > + <use + href="#status_scheduled" + /> + </svg> + + <div + class="media-body gl-display-flex" + > + + <h4 + class="gl-mr-3" + data-testid="statusText" + > + Set by + <a + class="author-link inline" + > + <img + class="avatar avatar-inline s16" + src="no_avatar.png" + /> + + <span + class="author" + > + + </span> + </a> + to be merged automatically when the pipeline succeeds + </h4> + + <div + class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto gl-mt-1" + > + <div> + <div + class="dropdown b-dropdown gl-new-dropdown gl-display-block gl-md-display-none! btn-group" + lazy="" + no-caret="" + > + <!----> + <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-new-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> +`; diff --git a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_pipeline_failed_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_pipeline_failed_spec.js.snap new file mode 100644 index 00000000000..7e741bf4660 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_pipeline_failed_spec.js.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelineFailed should render error message with a disabled merge button 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + show-disabled-button="true" + status="warning" + /> + + <div + class="media-body space-children" + > + <span + class="gl-ml-0! gl-text-body! bold" + > + <gl-sprintf-stub + message="Merge blocked: pipeline must succeed. Push a commit that fixes the failure, or %{linkStart}learn about other solutions.%{linkEnd}" + /> + </span> + </div> +</div> +`; 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 new file mode 100644 index 00000000000..f9936f22ea3 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`New ready to merge state component renders permission text if canMerge (false) is false 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + status="success" + /> + + <p + class="media-body gl-m-0! gl-font-weight-bold gl-text-gray-900!" + > + + Ready to merge by members who can write to the target branch. + + </p> +</div> +`; + +exports[`New ready to merge state component renders permission text if canMerge (true) is false 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + status="success" + /> + + <p + class="media-body gl-m-0! gl-font-weight-bold gl-text-gray-900!" + > + + Ready to merge! + + </p> +</div> +`; diff --git a/spec/frontend/vue_merge_request_widget/components/states/commit_edit_spec.js b/spec/frontend/vue_merge_request_widget/components/states/commit_edit_spec.js new file mode 100644 index 00000000000..c0add94e6ed --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/commit_edit_spec.js @@ -0,0 +1,76 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue'; + +const testCommitMessage = 'Test commit message'; +const testLabel = 'Test label'; +const testInputId = 'test-input-id'; + +describe('Commits edit component', () => { + let wrapper; + + const createComponent = (slots = {}) => { + wrapper = shallowMount(CommitEdit, { + propsData: { + value: testCommitMessage, + label: testLabel, + inputId: testInputId, + }, + slots: { + ...slots, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findTextarea = () => wrapper.find('.form-control'); + + it('has a correct label', () => { + const labelElement = wrapper.find('.col-form-label'); + + expect(labelElement.text()).toBe(testLabel); + }); + + describe('textarea', () => { + it('has a correct ID', () => { + expect(findTextarea().attributes('id')).toBe(testInputId); + }); + + it('has a correct value', () => { + expect(findTextarea().element.value).toBe(testCommitMessage); + }); + + it('emits an input event and receives changed value', async () => { + const changedCommitMessage = 'Changed commit message'; + + findTextarea().element.value = changedCommitMessage; + findTextarea().trigger('input'); + + await nextTick(); + expect(wrapper.emitted().input[0]).toEqual([changedCommitMessage]); + expect(findTextarea().element.value).toBe(changedCommitMessage); + }); + }); + + describe('when slots are present', () => { + beforeEach(() => { + createComponent({ + header: `<div class="test-header">${testCommitMessage}</div>`, + }); + }); + + it('renders header slot correctly', () => { + const headerSlotElement = wrapper.find('.test-header'); + + expect(headerSlotElement.exists()).toBe(true); + expect(headerSlotElement.text()).toBe(testCommitMessage); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js new file mode 100644 index 00000000000..1900b53ac11 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/merge_checks_failed_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import MergeChecksFailed from '~/vue_merge_request_widget/components/states/merge_checks_failed.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(MergeChecksFailed, { + propsData, + }); +} + +describe('Merge request widget merge checks failed state component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + mrState | displayText + ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} + ${{ blockingMergeRequests: { total_count: 1 } }} | ${'blockingMergeRequests'} + `('display $displayText text for $mrState', ({ mrState, displayText }) => { + factory({ mr: mrState }); + + expect(wrapper.text()).toContain(MergeChecksFailed.i18n[displayText]); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog_spec.js b/spec/frontend/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog_spec.js new file mode 100644 index 00000000000..c9aca01083d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog_spec.js @@ -0,0 +1,78 @@ +import { shallowMount } from '@vue/test-utils'; +import MergeFailedPipelineConfirmationDialog from '~/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue'; +import { trimText } from 'helpers/text_helper'; + +describe('MergeFailedPipelineConfirmationDialog', () => { + let wrapper; + + const GlModal = { + template: ` + <div> + <slot></slot> + <slot name="modal-footer"></slot> + </div> + `, + methods: { + hide: jest.fn(), + }, + }; + + const createComponent = () => { + wrapper = shallowMount(MergeFailedPipelineConfirmationDialog, { + propsData: { + visible: true, + }, + stubs: { + GlModal, + }, + attachTo: document.body, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + const findMergeBtn = () => wrapper.find('[data-testid="merge-unverified-changes"]'); + const findCancelBtn = () => wrapper.find('[data-testid="merge-cancel-btn"]'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render informational text explaining why merging immediately can be dangerous', () => { + expect(trimText(wrapper.text())).toContain( + 'The latest pipeline for this merge request did not succeed. The latest changes are unverified. Are you sure you want to attempt to merge?', + ); + }); + + it('should emit the mergeWithFailedPipeline event', () => { + findMergeBtn().vm.$emit('click'); + + expect(wrapper.emitted('mergeWithFailedPipeline')).toHaveLength(1); + }); + + it('when the cancel button is clicked should emit cancel and call hide', () => { + jest.spyOn(findModal().vm, 'hide'); + + findCancelBtn().vm.$emit('click'); + + expect(wrapper.emitted('cancel')).toHaveLength(1); + expect(findModal().vm.hide).toHaveBeenCalled(); + }); + + it('should emit cancel when the hide event is emitted', () => { + findModal().vm.$emit('hide'); + + expect(wrapper.emitted('cancel')).toHaveLength(1); + }); + + it('when modal is shown it will focus the cancel button', () => { + jest.spyOn(findCancelBtn().element, 'focus'); + + findModal().vm.$emit('shown'); + + expect(findCancelBtn().element.focus).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_archived_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_archived_spec.js new file mode 100644 index 00000000000..9332b7e334a --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_archived_spec.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived.vue'; + +describe('MRWidgetArchived', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(archivedComponent); + vm = mountComponent(Component); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a ci status failed icon', () => { + expect(vm.$el.querySelector('.ci-status-icon')).not.toBeNull(); + }); + + it('renders information', () => { + expect(vm.$el.querySelector('.bold').textContent.trim()).toEqual( + 'Merge unavailable: merge requests are read-only on archived projects.', + ); + }); +}); 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 new file mode 100644 index 00000000000..28182793683 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -0,0 +1,224 @@ +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { trimText } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import autoMergeEnabledComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue'; +import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; +import eventHub from '~/vue_merge_request_widget/event_hub'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; + +let wrapper; +let mergeRequestWidgetGraphqlEnabled = false; + +function convertPropsToGraphqlState(props) { + return { + autoMergeStrategy: props.autoMergeStrategy, + cancelAutoMergePath: 'http://text.com', + mergeUser: { + id: props.mergeUserId, + ...props.setToAutoMergeBy, + }, + targetBranch: props.targetBranch, + targetBranchCommitsPath: props.targetBranchPath, + shouldRemoveSourceBranch: props.shouldRemoveSourceBranch, + forceRemoveSourceBranch: props.shouldRemoveSourceBranch, + userPermissions: { + removeSourceBranch: props.canRemoveSourceBranch, + }, + }; +} + +function factory(propsData, stateOverride = {}) { + let state = {}; + + if (mergeRequestWidgetGraphqlEnabled) { + state = { ...convertPropsToGraphqlState(propsData), ...stateOverride }; + } + + wrapper = extendedWrapper( + mount(autoMergeEnabledComponent, { + propsData: { + mr: propsData, + service: new MRWidgetService({}), + }, + data() { + return { state }; + }, + provide: { glFeatures: { mergeRequestWidgetGraphql: mergeRequestWidgetGraphqlEnabled } }, + mocks: { + $apollo: { + queries: { + state: { loading: false }, + }, + }, + }, + }), + ); +} + +const targetBranchPath = '/foo/bar'; +const targetBranch = 'foo'; +const sha = '1EA2EZ34'; +const defaultMrProps = () => ({ + shouldRemoveSourceBranch: false, + canRemoveSourceBranch: true, + canCancelAutomaticMerge: true, + mergeUserId: 1, + currentUserId: 1, + setToAutoMergeBy: {}, + sha, + targetBranchPath, + targetBranch, + autoMergeStrategy: MWPS_MERGE_STRATEGY, +}); + +const getStatusText = () => wrapper.findByTestId('statusText').text(); + +describe('MRWidgetAutoMergeEnabled', () => { + let oldWindowGl; + + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + oldWindowGl = window.gl; + window.gl = { + mrWidgetData: { + defaultAvatarUrl: 'no_avatar.png', + }, + }; + }); + + afterEach(() => { + window.gl = oldWindowGl; + wrapper.destroy(); + wrapper = null; + }); + + [true, false].forEach((mergeRequestWidgetGraphql) => { + describe(`when graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'disabled'}`, () => { + beforeEach(() => { + mergeRequestWidgetGraphqlEnabled = mergeRequestWidgetGraphql; + }); + + describe('computed', () => { + describe('cancelButtonText', () => { + it('should return "Cancel" if MWPS is selected', () => { + factory({ + ...defaultMrProps(), + autoMergeStrategy: MWPS_MERGE_STRATEGY, + }); + + expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe( + 'Cancel auto-merge', + ); + }); + }); + }); + + describe('methods', () => { + describe('cancelAutomaticMerge', () => { + it('should set flag and call service then tell main component to update the widget with data', async () => { + factory({ + ...defaultMrProps(), + }); + const mrObj = { + is_new_mr_data: true, + }; + jest.spyOn(wrapper.vm.service, 'cancelAutomaticMerge').mockReturnValue( + new Promise((resolve) => { + resolve({ + data: mrObj, + }); + }), + ); + + wrapper.vm.cancelAutomaticMerge(); + + await waitForPromises(); + + expect(wrapper.vm.isCancellingAutoMerge).toBe(true); + if (mergeRequestWidgetGraphql) { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + } else { + expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); + } + }); + }); + + describe('removeSourceBranch', () => { + it('should set flag and call service then request main component to update the widget', async () => { + factory({ + ...defaultMrProps(), + }); + jest.spyOn(wrapper.vm.service, 'merge').mockReturnValue( + Promise.resolve({ + data: { + status: MWPS_MERGE_STRATEGY, + }, + }), + ); + + wrapper.vm.removeSourceBranch(); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(wrapper.vm.service.merge).toHaveBeenCalledWith({ + sha, + auto_merge_strategy: MWPS_MERGE_STRATEGY, + should_remove_source_branch: true, + }); + }); + }); + }); + + 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(), + }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + isCancellingAutoMerge: true, + }); + + await nextTick(); + + expect(wrapper.find('.js-cancel-auto-merge').props('loading')).toBe(true); + }); + + it('should render the status text as "...to merged automatically" if MWPS is selected', () => { + factory({ + ...defaultMrProps(), + autoMergeStrategy: MWPS_MERGE_STRATEGY, + }); + + expect(getStatusText()).toContain( + 'to be merged automatically when the pipeline succeeds', + ); + }); + + it('should render the cancel button as "Cancel" if MWPS is selected', () => { + factory({ + ...defaultMrProps(), + autoMergeStrategy: MWPS_MERGE_STRATEGY, + }); + + const cancelButtonText = trimText(wrapper.find('.js-cancel-auto-merge').text()); + + expect(cancelButtonText).toBe('Cancel auto-merge'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js new file mode 100644 index 00000000000..9320e733636 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed_spec.js @@ -0,0 +1,68 @@ +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import AutoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +describe('MRWidgetAutoMergeFailed', () => { + let wrapper; + const mergeError = 'This is the merge error'; + const findButton = () => wrapper.find(GlButton); + + const createComponent = (props = {}, mergeRequestWidgetGraphql = false) => { + wrapper = mount(AutoMergeFailedComponent, { + propsData: { ...props }, + data() { + if (mergeRequestWidgetGraphql) { + return { mergeError: props.mr?.mergeError }; + } + + return {}; + }, + provide: { + glFeatures: { mergeRequestWidgetGraphql }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + [true, false].forEach((mergeRequestWidgetGraphql) => { + describe(`when graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'dislabed'}`, () => { + beforeEach(() => { + createComponent( + { + mr: { mergeError }, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('renders failed message', () => { + expect(wrapper.text()).toContain('This merge request failed to be merged automatically'); + }); + + it('renders merge error provided', () => { + expect(wrapper.text()).toContain(mergeError); + }); + + it('render refresh button', () => { + expect(findButton().text()).toBe('Refresh'); + }); + + it('emits event and shows loading icon when button is clicked', async () => { + jest.spyOn(eventHub, '$emit'); + findButton().vm.$emit('click'); + + expect(eventHub.$emit.mock.calls[0][0]).toBe('MRWidgetUpdateRequested'); + + await nextTick(); + + expect(findButton().attributes('disabled')).toBe('disabled'); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_checking_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_checking_spec.js new file mode 100644 index 00000000000..02de426204b --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_checking_spec.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import checkingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking.vue'; + +describe('MRWidgetChecking', () => { + let Component; + let vm; + + beforeEach(() => { + Component = Vue.extend(checkingComponent); + vm = mountComponent(Component); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders loading icon', () => { + expect(vm.$el.querySelector('.mr-widget-icon span').classList).toContain('gl-spinner'); + }); + + it('renders information about merging', () => { + expect(vm.$el.querySelector('.media-body').textContent.trim()).toEqual( + 'Checking if merge request can be merged…', + ); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js new file mode 100644 index 00000000000..f7d046eb8f9 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed.vue'; + +describe('MRWidgetClosed', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(closedComponent); + vm = mountComponent(Component, { + mr: { + metrics: { + mergedBy: {}, + closedBy: { + name: 'Administrator', + username: 'root', + webUrl: 'http://localhost:3000/root', + avatarUrl: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + mergedAt: 'Jan 24, 2018 1:02pm UTC', + closedAt: 'Jan 24, 2018 1:02pm UTC', + readableMergedAt: '', + readableClosedAt: 'less than a minute ago', + }, + targetBranchPath: '/twitter/flight/commits/so_long_jquery', + targetBranch: 'so_long_jquery', + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders warning icon', () => { + expect(vm.$el.querySelector('.js-ci-status-icon-warning')).not.toBeNull(); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js new file mode 100644 index 00000000000..663fabb761c --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commit_message_dropdown_spec.js @@ -0,0 +1,61 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; + +const commits = [ + { + title: 'Commit 1', + short_id: '78d5b7', + message: 'Update test.txt', + }, + { + title: 'Commit 2', + short_id: '34cbe28b', + message: 'Fixed test', + }, + { + title: 'Commit 3', + short_id: 'fa42932a', + message: 'Added changelog', + }, +]; + +describe('Commits message dropdown component', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(CommitMessageDropdown, { + propsData: { + commits, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownElements = () => wrapper.findAll(GlDropdownItem); + const findFirstDropdownElement = () => findDropdownElements().at(0); + + it('should have 3 elements in dropdown list', () => { + expect(findDropdownElements().length).toBe(3); + }); + + it('should have correct message for the first dropdown list element', () => { + expect(findFirstDropdownElement().text()).toContain('78d5b7'); + expect(findFirstDropdownElement().text()).toContain('Commit 1'); + }); + + it('should emit a commit title on selecting commit', async () => { + findFirstDropdownElement().vm.$emit('click'); + + await nextTick(); + expect(wrapper.emitted().input[0]).toEqual(['Update test.txt']); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commits_header_spec.js new file mode 100644 index 00000000000..774e2bafed3 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_commits_header_spec.js @@ -0,0 +1,135 @@ +import { mount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; + +describe('Commits header component', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = mount(CommitsHeader, { + stubs: { + GlSprintf, + }, + propsData: { + isSquashEnabled: false, + targetBranch: 'main', + commitsCount: 5, + isFastForwardEnabled: false, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findHeaderWrapper = () => wrapper.find('.js-mr-widget-commits-count'); + const findCommitToggle = () => wrapper.find('.commit-edit-toggle'); + const findTargetBranchMessage = () => wrapper.find('.label-branch'); + const findModifyButton = () => wrapper.find('.modify-message-button'); + + describe('when fast-forward is enabled', () => { + beforeEach(() => { + createComponent({ + isFastForwardEnabled: true, + isSquashEnabled: true, + }); + }); + + it('has commits count message showing 1 commit', () => { + expect(wrapper.text()).toContain('1 commit'); + }); + + it('has button with modify commit message', () => { + expect(findModifyButton().text()).toBe('Modify commit message'); + }); + + it('does not have merge commit part of the message', () => { + expect(findHeaderWrapper().text()).not.toContain('1 merge commit'); + }); + }); + + describe('when collapsed', () => { + it('toggle has aria-label equal to Expand', () => { + createComponent(); + + expect(findCommitToggle().attributes('aria-label')).toBe('Expand'); + }); + + it('has a chevron-right icon', async () => { + createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ expanded: false }); + + await nextTick(); + expect(findCommitToggle().props('icon')).toBe('chevron-right'); + }); + + describe('when squash is disabled', () => { + beforeEach(() => { + createComponent(); + }); + + it('has commits count message showing correct amount of commits', () => { + expect(wrapper.text()).toContain('5 commits'); + }); + + it('has button with modify merge commit message', () => { + expect(findModifyButton().text()).toBe('Modify merge commit'); + }); + }); + + describe('when squash is enabled', () => { + beforeEach(() => { + createComponent({ isSquashEnabled: true }); + }); + + it('has commits count message showing one commit when squash is enabled', () => { + expect(wrapper.text()).toContain('1 commit'); + }); + + it('has button with modify commit messages text', () => { + expect(findModifyButton().text()).toBe('Modify commit messages'); + }); + }); + + it('has correct target branch displayed', () => { + createComponent(); + + expect(findTargetBranchMessage().text()).toBe('main'); + }); + + it('does has merge commit part of the message', () => { + createComponent(); + + expect(findHeaderWrapper().text()).toContain('1 merge commit'); + }); + }); + + describe('when expanded', () => { + beforeEach(() => { + createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ expanded: true }); + }); + + it('toggle has aria-label equal to collapse', async () => { + await nextTick(); + expect(findCommitToggle().attributes('aria-label')).toBe('Collapse'); + }); + + it('has a chevron-down icon', async () => { + await nextTick(); + expect(findCommitToggle().props('icon')).toBe('chevron-down'); + }); + + it('has a collapse text', async () => { + await nextTick(); + expect(findHeaderWrapper().text()).toBe('Collapse'); + }); + }); +}); 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 new file mode 100644 index 00000000000..7a9fd5b002d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js @@ -0,0 +1,252 @@ +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { TEST_HOST } from 'helpers/test_constants'; +import { removeBreakLine } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; + +describe('MRWidgetConflicts', () => { + let wrapper; + let mergeRequestWidgetGraphql = null; + const path = '/conflicts'; + + const findResolveButton = () => wrapper.findByTestId('resolve-conflicts-button'); + const findMergeLocalButton = () => wrapper.findByTestId('merge-locally-button'); + + const mergeConflictsText = 'Merge blocked: merge conflicts must be resolved.'; + const fastForwardMergeText = + 'Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.'; + const userCannotMergeText = + 'Users who can write to the source or target branches can resolve the conflicts.'; + const resolveConflictsBtnText = 'Resolve conflicts'; + const mergeLocallyBtnText = 'Resolve locally'; + + async function createComponent(propsData = {}) { + wrapper = extendedWrapper( + mount(ConflictsComponent, { + propsData, + provide: { + glFeatures: { + mergeRequestWidgetGraphql, + }, + }, + mocks: { + $apollo: { + queries: { + userPermissions: { loading: false }, + stateData: { loading: false }, + }, + }, + }, + }), + ); + + if (mergeRequestWidgetGraphql) { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + userPermissions: { + canMerge: propsData.mr.canMerge, + pushToSourceBranch: propsData.mr.canPushToSourceBranch, + }, + stateData: { + shouldBeRebased: propsData.mr.shouldBeRebased, + sourceBranchProtected: propsData.mr.sourceBranchProtected, + }, + }); + } + + await nextTick(); + } + + afterEach(() => { + mergeRequestWidgetGraphql = null; + wrapper.destroy(); + }); + + [false, true].forEach((featureEnabled) => { + describe(`with GraphQL feature flag ${featureEnabled ? 'enabled' : 'disabled'}`, () => { + beforeEach(() => { + mergeRequestWidgetGraphql = featureEnabled; + }); + + // There are two permissions we need to consider: + // + // 1. Is the user allowed to merge to the target branch? + // 2. Is the user allowed to push to the source branch? + // + // This yields 4 possible permutations that we need to test, and + // we test them below. A user who can push to the source + // branch should be allowed to resolve conflicts. This is + // consistent with what the backend does. + describe('when allowed to merge but not allowed to push to source branch', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: true, + canPushToSourceBranch: false, + conflictResolutionPath: path, + conflictsDocsPath: '', + }, + }); + }); + + it('should tell you about conflicts without bothering other people', () => { + expect(wrapper.text()).toContain(mergeConflictsText); + expect(wrapper.text()).not.toContain(userCannotMergeText); + }); + + it('should not allow you to resolve the conflicts', () => { + expect(wrapper.text()).not.toContain(resolveConflictsBtnText); + }); + + it('should have merge buttons', () => { + expect(findMergeLocalButton().text()).toContain(mergeLocallyBtnText); + }); + }); + + describe('when not allowed to merge but allowed to push to source branch', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: false, + canPushToSourceBranch: true, + conflictResolutionPath: path, + conflictsDocsPath: '', + }, + }); + }); + + it('should tell you about conflicts', () => { + expect(wrapper.text()).toContain(mergeConflictsText); + expect(wrapper.text()).toContain(userCannotMergeText); + }); + + it('should allow you to resolve the conflicts', () => { + expect(findResolveButton().text()).toContain(resolveConflictsBtnText); + expect(findResolveButton().attributes('href')).toEqual(path); + }); + + it('should not have merge buttons', () => { + expect(wrapper.text()).not.toContain(mergeLocallyBtnText); + }); + }); + + describe('when allowed to merge and push to source branch', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: true, + canPushToSourceBranch: true, + conflictResolutionPath: path, + conflictsDocsPath: '', + }, + }); + }); + + it('should tell you about conflicts without bothering other people', () => { + expect(wrapper.text()).toContain(mergeConflictsText); + expect(wrapper.text()).not.toContain(userCannotMergeText); + }); + + it('should allow you to resolve the conflicts', () => { + expect(findResolveButton().text()).toContain(resolveConflictsBtnText); + expect(findResolveButton().attributes('href')).toEqual(path); + }); + + it('should have merge buttons', () => { + expect(findMergeLocalButton().text()).toContain(mergeLocallyBtnText); + }); + }); + + describe('when user does not have permission to push to source branch', () => { + it('should show proper message', async () => { + await createComponent({ + mr: { + canMerge: false, + canPushToSourceBranch: false, + conflictsDocsPath: '', + }, + }); + + expect(wrapper.text().trim().replace(/\s\s+/g, ' ')).toContain(userCannotMergeText); + }); + + it('should not have action buttons', async () => { + await createComponent({ + mr: { + canMerge: false, + canPushToSourceBranch: false, + conflictsDocsPath: '', + }, + }); + + expect(findResolveButton().exists()).toBe(false); + expect(findMergeLocalButton().exists()).toBe(false); + }); + + it('should not have resolve button when no conflict resolution path', async () => { + await createComponent({ + mr: { + canMerge: true, + conflictResolutionPath: null, + conflictsDocsPath: '', + }, + }); + + expect(findResolveButton().exists()).toBe(false); + }); + }); + + describe('when fast-forward or semi-linear merge enabled', () => { + it('should tell you to rebase locally', async () => { + await createComponent({ + mr: { + shouldBeRebased: true, + conflictsDocsPath: '', + }, + }); + + expect(removeBreakLine(wrapper.text()).trim()).toContain(fastForwardMergeText); + }); + }); + + describe('when source branch protected', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: true, + canPushToSourceBranch: true, + conflictResolutionPath: TEST_HOST, + sourceBranchProtected: true, + conflictsDocsPath: '', + }, + }); + }); + + it('should allow you to resolve the conflicts', () => { + expect(findResolveButton().exists()).toBe(true); + }); + }); + + describe('when source branch not protected', () => { + beforeEach(async () => { + await createComponent({ + mr: { + canMerge: true, + canPushToSourceBranch: true, + conflictResolutionPath: TEST_HOST, + sourceBranchProtected: false, + conflictsDocsPath: '', + }, + }); + }); + + it('should allow you to resolve the conflicts', () => { + expect(findResolveButton().text()).toContain(resolveConflictsBtnText); + expect(findResolveButton().attributes('href')).toEqual(TEST_HOST); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js new file mode 100644 index 00000000000..989aa76f09b --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_failed_to_merge_spec.js @@ -0,0 +1,180 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import MrWidgetFailedToMerge from '~/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +describe('MRWidgetFailedToMerge', () => { + const dummyIntervalId = 1337; + let wrapper; + + const createComponent = (props = {}, data = {}) => { + wrapper = shallowMount(MrWidgetFailedToMerge, { + propsData: { + mr: { + mergeError: 'Merge error happened', + }, + ...props, + }, + data() { + return data; + }, + }); + }; + + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + jest.spyOn(window, 'setInterval').mockReturnValue(dummyIntervalId); + jest.spyOn(window, 'clearInterval').mockImplementation(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('interval', () => { + it('sets interval to refresh', () => { + createComponent(); + + expect(window.setInterval).toHaveBeenCalledWith(wrapper.vm.updateTimer, 1000); + expect(wrapper.vm.intervalId).toBe(dummyIntervalId); + }); + + it('clears interval when destroying ', () => { + createComponent(); + wrapper.destroy(); + + expect(window.clearInterval).toHaveBeenCalledWith(dummyIntervalId); + }); + }); + + describe('mergeError', () => { + it('removes forced line breaks', async () => { + createComponent({ mr: { mergeError: 'contains<br />line breaks<br />' } }); + + await nextTick(); + + expect(wrapper.find('[data-testid="merge-error"]').text()).toBe('contains line breaks.'); + }); + + it('does not append an extra period', async () => { + createComponent({ mr: { mergeError: 'contains a period.' } }); + + await nextTick(); + + expect(wrapper.find('[data-testid="merge-error"]').text()).toBe('contains a period.'); + }); + + it('does not insert an extra space between the final character and the period', async () => { + createComponent({ mr: { mergeError: 'contains a <a href="http://example.com">link</a>.' } }); + + await nextTick(); + + expect(wrapper.find('[data-testid="merge-error"]').text()).toBe('contains a link.'); + }); + + it('removes extra spaces', async () => { + createComponent({ mr: { mergeError: 'contains a lot of spaces .' } }); + + await nextTick(); + + expect(wrapper.find('[data-testid="merge-error"]').text()).toBe('contains a lot of spaces.'); + }); + }); + + describe('created', () => { + it('should disable polling', () => { + createComponent(); + + expect(eventHub.$emit).toHaveBeenCalledWith('DisablePolling'); + }); + }); + + describe('methods', () => { + describe('refresh', () => { + it('should emit event to request component refresh', () => { + createComponent(); + + expect(wrapper.vm.isRefreshing).toBe(false); + + wrapper.vm.refresh(); + + expect(wrapper.vm.isRefreshing).toBe(true); + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(eventHub.$emit).toHaveBeenCalledWith('EnablePolling'); + }); + }); + + describe('updateTimer', () => { + it('should update timer and emit event when timer end', () => { + createComponent(); + + jest.spyOn(wrapper.vm, 'refresh').mockImplementation(() => {}); + + expect(wrapper.vm.timer).toEqual(10); + + for (let i = 0; i < 10; i += 1) { + expect(wrapper.vm.timer).toEqual(10 - i); + wrapper.vm.updateTimer(); + } + + expect(wrapper.vm.refresh).toHaveBeenCalled(); + }); + }); + }); + + describe('while it is refreshing', () => { + it('renders Refresing now', async () => { + createComponent({}, { isRefreshing: true }); + + await nextTick(); + + expect(wrapper.find('.js-refresh-label').text().trim()).toBe('Refreshing now'); + }); + }); + + describe('while it is not regresing', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders warning icon and disabled merge button', () => { + expect(wrapper.find('.js-ci-status-icon-warning')).not.toBeNull(); + }); + + it('renders given error', () => { + expect(wrapper.find('.has-error-message').text().trim()).toBe('Merge error happened.'); + }); + + it('renders refresh button', () => { + expect( + wrapper.find('[data-testid="merge-request-failed-refresh-button"]').text().trim(), + ).toBe('Refresh now'); + }); + + it('renders remaining time', () => { + expect(wrapper.find('.has-custom-error').text().trim()).toBe( + 'Refreshing in 10 seconds to show the updated status...', + ); + }); + }); + + it('should just generic merge failed message if merge_error is not available', async () => { + createComponent({ mr: { mergeError: null } }); + + await nextTick(); + + expect(wrapper.text().trim()).toContain('Merge failed.'); + expect(wrapper.text().trim()).not.toContain('Merge error happened.'); + }); + + it('should show refresh label when refresh requested', async () => { + createComponent(); + + wrapper.vm.refresh(); + + await nextTick(); + + expect(wrapper.text().trim()).not.toContain('Merge failed. Refreshing'); + expect(wrapper.text().trim()).toContain('Refreshing now'); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js new file mode 100644 index 00000000000..2606933450e --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merged_spec.js @@ -0,0 +1,180 @@ +import { getByRole } from '@testing-library/dom'; +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants'; +import modalEventHub from '~/projects/commit/event_hub'; +import mergedComponent from '~/vue_merge_request_widget/components/states/mr_widget_merged.vue'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +describe('MRWidgetMerged', () => { + let vm; + const targetBranch = 'foo'; + + beforeEach(() => { + jest.spyOn(document, 'dispatchEvent'); + const Component = Vue.extend(mergedComponent); + const mr = { + isRemovingSourceBranch: false, + cherryPickInForkPath: false, + canCherryPickInCurrentMR: true, + revertInForkPath: false, + canRevertInCurrentMR: true, + canRemoveSourceBranch: true, + sourceBranchRemoved: true, + metrics: { + mergedBy: { + name: 'Administrator', + username: 'root', + webUrl: 'http://localhost:3000/root', + avatarUrl: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + mergedAt: 'Jan 24, 2018 1:02pm UTC', + readableMergedAt: '', + closedBy: {}, + closedAt: 'Jan 24, 2018 1:02pm UTC', + readableClosedAt: '', + }, + updatedAt: 'mergedUpdatedAt', + shortMergeCommitSha: '958c0475', + mergeCommitSha: '958c047516e182dfc52317f721f696e8a1ee85ed', + mergeCommitPath: + 'http://localhost:3000/root/nautilus/commit/f7ce827c314c9340b075657fd61c789fb01cf74d', + sourceBranch: 'bar', + targetBranch, + }; + + const service = { + removeSourceBranch() {}, + }; + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + vm = mountComponent(Component, { mr, service }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('shouldShowRemoveSourceBranch', () => { + it('returns true when sourceBranchRemoved is false', () => { + vm.mr.sourceBranchRemoved = false; + + expect(vm.shouldShowRemoveSourceBranch).toEqual(true); + }); + + it('returns false when sourceBranchRemoved is true', () => { + vm.mr.sourceBranchRemoved = true; + + expect(vm.shouldShowRemoveSourceBranch).toEqual(false); + }); + + it('returns false when canRemoveSourceBranch is false', () => { + vm.mr.sourceBranchRemoved = false; + vm.mr.canRemoveSourceBranch = false; + + expect(vm.shouldShowRemoveSourceBranch).toEqual(false); + }); + + it('returns false when is making request', () => { + vm.mr.canRemoveSourceBranch = true; + vm.isMakingRequest = true; + + expect(vm.shouldShowRemoveSourceBranch).toEqual(false); + }); + + it('returns true when all are true', () => { + vm.mr.isRemovingSourceBranch = true; + vm.mr.canRemoveSourceBranch = true; + vm.isMakingRequest = true; + + expect(vm.shouldShowRemoveSourceBranch).toEqual(false); + }); + }); + + describe('shouldShowSourceBranchRemoving', () => { + it('should correct value when fields changed', () => { + vm.mr.sourceBranchRemoved = false; + + expect(vm.shouldShowSourceBranchRemoving).toEqual(false); + + vm.mr.sourceBranchRemoved = true; + + expect(vm.shouldShowRemoveSourceBranch).toEqual(false); + + vm.mr.sourceBranchRemoved = false; + vm.isMakingRequest = true; + + expect(vm.shouldShowSourceBranchRemoving).toEqual(true); + + vm.isMakingRequest = false; + vm.mr.isRemovingSourceBranch = true; + + expect(vm.shouldShowSourceBranchRemoving).toEqual(true); + }); + }); + }); + + describe('methods', () => { + describe('removeSourceBranch', () => { + it('should set flag and call service then request main component to update the widget', async () => { + jest.spyOn(vm.service, 'removeSourceBranch').mockReturnValue( + new Promise((resolve) => { + resolve({ + data: { + message: 'Branch was deleted', + }, + }); + }), + ); + + vm.removeSourceBranch(); + + await waitForPromises(); + + const args = eventHub.$emit.mock.calls[0]; + + expect(vm.isMakingRequest).toEqual(true); + expect(args[0]).toEqual('MRWidgetUpdateRequested'); + expect(args[1]).not.toThrow(); + }); + }); + }); + + it('calls dispatchDocumentEvent to load in the modal component', () => { + expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('merged:UpdateActions')); + }); + + it('emits event to open the revert modal on revert button click', () => { + const eventHubSpy = jest.spyOn(modalEventHub, '$emit'); + + getByRole(vm.$el, 'button', { name: /Revert/i }).click(); + + expect(eventHubSpy).toHaveBeenCalledWith(OPEN_REVERT_MODAL); + }); + + it('emits event to open the cherry-pick modal on cherry-pick button click', () => { + const eventHubSpy = jest.spyOn(modalEventHub, '$emit'); + + getByRole(vm.$el, 'button', { name: /Cherry-pick/i }).click(); + + expect(eventHubSpy).toHaveBeenCalledWith(OPEN_CHERRY_PICK_MODAL); + }); + + it('has merged by information', () => { + expect(vm.$el.textContent).toContain('Merged by'); + expect(vm.$el.textContent).toContain('Administrator'); + }); + + it('shows revert and cherry-pick buttons', () => { + expect(vm.$el.textContent).toContain('Revert'); + expect(vm.$el.textContent).toContain('Cherry-pick'); + }); + + it('should use mergedEvent mergedAt as tooltip title', () => { + expect(vm.$el.querySelector('time').getAttribute('title')).toBe('Jan 24, 2018 1:02pm UTC'); + }); +}); 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 new file mode 100644 index 00000000000..49bd3739fdb --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js @@ -0,0 +1,61 @@ +import { shallowMount } from '@vue/test-utils'; +import simplePoll from '~/lib/utils/simple_poll'; +import MrWidgetMerging from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue'; + +jest.mock('~/lib/utils/simple_poll', () => + jest.fn().mockImplementation(jest.requireActual('~/lib/utils/simple_poll').default), +); + +describe('MRWidgetMerging', () => { + let wrapper; + + const GlEmoji = { template: '<img />' }; + beforeEach(() => { + wrapper = shallowMount(MrWidgetMerging, { + propsData: { + mr: { + targetBranchPath: '/branch-path', + targetBranch: 'branch', + transitionStateMachine() {}, + }, + service: { + poll: jest.fn().mockResolvedValue(), + }, + }, + stubs: { + GlEmoji, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders information about merge request being merged', () => { + expect( + wrapper + .find('.media-body') + .text() + .trim() + .replace(/\s\s+/g, ' ') + .replace(/[\r\n]+/g, ' '), + ).toContain('Merging!'); + }); + + describe('initiateMergePolling', () => { + it('should call simplePoll', () => { + wrapper.vm.initiateMergePolling(); + + expect(simplePoll).toHaveBeenCalledWith(expect.any(Function), { timeout: 0 }); + }); + + it('should call handleMergePolling', () => { + jest.spyOn(wrapper.vm, 'handleMergePolling').mockImplementation(() => {}); + + wrapper.vm.initiateMergePolling(); + + expect(wrapper.vm.handleMergePolling).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_missing_branch_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_missing_branch_spec.js new file mode 100644 index 00000000000..ddce07954ab --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_missing_branch_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import MissingBranchComponent from '~/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue'; + +let wrapper; + +async function factory(sourceBranchRemoved, mergeRequestWidgetGraphql) { + wrapper = shallowMount(MissingBranchComponent, { + propsData: { + mr: { sourceBranchRemoved }, + }, + provide: { + glFeatures: { mergeRequestWidgetGraphql }, + }, + }); + + if (mergeRequestWidgetGraphql) { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ state: { sourceBranchExists: !sourceBranchRemoved } }); + } + + await nextTick(); +} + +describe('MRWidgetMissingBranch', () => { + afterEach(() => { + wrapper.destroy(); + }); + + [true, false].forEach((mergeRequestWidgetGraphql) => { + describe(`widget GraphQL feature flag is ${ + mergeRequestWidgetGraphql ? 'enabled' : 'disabled' + }`, () => { + it.each` + sourceBranchRemoved | branchName + ${true} | ${'source'} + ${false} | ${'target'} + `( + 'should set missing branch name as $branchName when sourceBranchRemoved is $sourceBranchRemoved', + async ({ sourceBranchRemoved, branchName }) => { + await factory(sourceBranchRemoved, mergeRequestWidgetGraphql); + + expect(wrapper.find('[data-testid="widget-content"]').text()).toContain(branchName); + }, + ); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_not_allowed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_not_allowed_spec.js new file mode 100644 index 00000000000..63e93074857 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_not_allowed_spec.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue'; + +describe('MRWidgetNotAllowed', () => { + let vm; + beforeEach(() => { + const Component = Vue.extend(notAllowedComponent); + vm = mountComponent(Component); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders success icon', () => { + expect(vm.$el.querySelector('.ci-status-icon-success')).not.toBe(null); + }); + + it('renders informative text', () => { + expect(vm.$el.innerText).toContain('Ready to be merged automatically.'); + expect(vm.$el.innerText).toContain( + 'Ask someone with write access to this repository to merge this request', + ); + }); +}); 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 new file mode 100644 index 00000000000..6de0c06c33d --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -0,0 +1,28 @@ +import Vue, { nextTick } from 'vue'; +import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing_to_merge.vue'; + +describe('NothingToMerge', () => { + describe('template', () => { + const Component = Vue.extend(NothingToMerge); + const newBlobPath = '/foo'; + const vm = new Component({ + el: document.createElement('div'), + propsData: { + mr: { newBlobPath }, + }, + }); + + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBe(true); + expect(vm.$el.querySelector('[data-testid="createFileButton"]').href).toContain(newBlobPath); + expect(vm.$el.innerText).toContain('Use merge requests to propose changes to your project'); + }); + + it('should not show new blob link if there is no link available', () => { + vm.mr.newBlobPath = null; + nextTick(() => { + expect(vm.$el.querySelector('[data-testid="createFileButton"]')).toEqual(null); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js new file mode 100644 index 00000000000..9b10b078e89 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js @@ -0,0 +1,28 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import PipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue'; + +describe('MRWidgetPipelineBlocked', () => { + let wrapper; + + const createWrapper = (mountFn = shallowMount) => { + wrapper = mountFn(PipelineBlockedComponent); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders warning icon', () => { + createWrapper(mount); + + expect(wrapper.find('.ci-status-icon-warning').exists()).toBe(true); + }); + + it('renders information text', () => { + createWrapper(); + + expect(wrapper.text()).toBe( + "Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.", + ); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js new file mode 100644 index 00000000000..4e44ac539f2 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_failed_spec.js @@ -0,0 +1,23 @@ +import { shallowMount } from '@vue/test-utils'; +import PipelineFailed from '~/vue_merge_request_widget/components/states/pipeline_failed.vue'; + +describe('PipelineFailed', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineFailed); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should render error message with a disabled merge button', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); 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 new file mode 100644 index 00000000000..6e89cd41559 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -0,0 +1,819 @@ +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 readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json'; +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'; +import simplePoll from '~/lib/utils/simple_poll'; +import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue'; +import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; +import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue'; +import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue'; +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'; + +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 = + readyToMergeResponse.data.project.mergeRequest.defaultSquashCommitMessage; +const commitMessageWithDescription = + readyToMergeResponse.data.project.mergeRequest.defaultMergeCommitMessageWithDescription; +const createTestMr = (customConfig) => { + const mr = { + isPipelineActive: false, + pipeline: null, + isPipelineFailed: false, + isPipelinePassing: false, + isMergeAllowed: true, + isApproved: true, + onlyAllowMergeIfPipelineSucceeds: false, + ffOnlyEnabled: false, + hasCI: false, + ciStatus: null, + sha: '12345678', + squash: false, + squashIsEnabledByDefault: false, + squashIsReadonly: false, + squashIsSelected: false, + commitMessage, + squashCommitMessage, + commitMessageWithDescription, + defaultMergeCommitMessage: commitMessage, + defaultSquashCommitMessage: squashCommitMessage, + shouldRemoveSourceBranch: true, + canRemoveSourceBranch: false, + targetBranch: 'main', + preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY, + availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY], + mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs', + transitionStateMachine: (transition) => eventHub.$emit('StateMachineValueChanged', transition), + translateStateToMachine: () => this.transitionStateMachine(), + state: 'open', + canMerge: true, + }; + + Object.assign(mr, customConfig.mr); + + return mr; +}; + +const createTestService = () => ({ + merge: jest.fn(), + poll: jest.fn().mockResolvedValue(), +}); + +Vue.use(VueApollo); + +let wrapper; +let readyToMergeResponseSpy; + +const findMergeButton = () => wrapper.find('[data-testid="merge-button"]'); +const findPipelineFailedConfirmModal = () => + wrapper.findComponent(MergeFailedPipelineConfirmationDialog); + +const createReadyToMergeResponse = (customMr) => { + return produce(readyToMergeResponse, (draft) => { + Object.assign(draft.data.project.mergeRequest, customMr); + }); +}; + +const createComponent = ( + customConfig = {}, + mergeRequestWidgetGraphql = false, + restructuredMrWidget = true, +) => { + wrapper = shallowMount(ReadyToMerge, { + propsData: { + mr: createTestMr(customConfig), + service: createTestService(), + }, + provide: { + glFeatures: { + mergeRequestWidgetGraphql, + restructuredMrWidget, + }, + }, + stubs: { + CommitEdit, + }, + apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]), + }); +}; + +const findCheckboxElement = () => wrapper.find(SquashBeforeMerge); +const findCommitEditElements = () => wrapper.findAll(CommitEdit); +const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown); +const findFirstCommitEditLabel = () => findCommitEditElements().at(0).props('label'); +const findTipLink = () => wrapper.find(GlSprintf); +const findCommitEditWithInputId = (inputId) => + findCommitEditElements().wrappers.find((x) => x.props('inputId') === inputId); +const findMergeCommitMessage = () => findCommitEditWithInputId('merge-message-edit').props('value'); +const findSquashCommitMessage = () => + findCommitEditWithInputId('squash-message-edit').props('value'); + +const triggerApprovalUpdated = () => eventHub.$emit('ApprovalUpdated'); + +describe('ReadyToMerge', () => { + beforeEach(() => { + readyToMergeResponseSpy = jest.fn().mockResolvedValueOnce(readyToMergeResponse); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('isAutoMergeAvailable', () => { + it('should return true when at least one merge strategy is available', () => { + createComponent(); + + expect(wrapper.vm.isAutoMergeAvailable).toBe(true); + }); + + it('should return false when no merge strategies are available', () => { + createComponent({ mr: { availableAutoMergeStrategies: [] } }); + + expect(wrapper.vm.isAutoMergeAvailable).toBe(false); + }); + }); + + describe('status', () => { + it('defaults to success', () => { + createComponent({ mr: { pipeline: true, availableAutoMergeStrategies: [] } }); + + expect(wrapper.vm.status).toEqual('success'); + }); + + it('returns failed when MR has CI but also has an unknown status', () => { + createComponent({ mr: { hasCI: true } }); + + expect(wrapper.vm.status).toEqual('failed'); + }); + + it('returns default when MR has no pipeline', () => { + createComponent({ mr: { availableAutoMergeStrategies: [] } }); + + expect(wrapper.vm.status).toEqual('success'); + }); + + it('returns pending when pipeline is active', () => { + createComponent({ mr: { pipeline: {}, isPipelineActive: true } }); + + expect(wrapper.vm.status).toEqual('pending'); + }); + + it('returns failed when pipeline is failed', () => { + createComponent({ + mr: { pipeline: {}, isPipelineFailed: true, availableAutoMergeStrategies: [] }, + }); + + expect(wrapper.vm.status).toEqual('failed'); + }); + }); + + describe('Merge Button Variant', () => { + it('defaults to confirm class', () => { + createComponent({ + mr: { availableAutoMergeStrategies: [] }, + }); + + expect(findMergeButton().attributes('variant')).toBe('confirm'); + }); + }); + + describe('status icon', () => { + it('defaults to tick icon', () => { + createComponent(); + + expect(wrapper.vm.iconClass).toEqual('success'); + }); + + it('shows tick for success status', () => { + createComponent({ mr: { pipeline: true } }); + + expect(wrapper.vm.iconClass).toEqual('success'); + }); + + it('shows tick for pending status', () => { + createComponent({ mr: { pipeline: {}, isPipelineActive: true } }); + + expect(wrapper.vm.iconClass).toEqual('success'); + }); + }); + + describe('mergeButtonText', () => { + it('should return "Merge" when no auto merge strategies are available', () => { + createComponent({ mr: { availableAutoMergeStrategies: [] } }); + + expect(wrapper.vm.mergeButtonText).toEqual('Merge'); + }); + + it('should return "Merge in progress"', async () => { + createComponent(); + + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ isMergingImmediately: true }); + + await nextTick(); + + expect(wrapper.vm.mergeButtonText).toEqual('Merge in progress'); + }); + + it('should return "Merge when pipeline succeeds" when the MWPS auto merge strategy is available', () => { + createComponent({ + mr: { isMergingImmediately: false, preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY }, + }); + + expect(wrapper.vm.mergeButtonText).toEqual('Merge when pipeline succeeds'); + }); + }); + + describe('autoMergeText', () => { + it('should return Merge when pipeline succeeds', () => { + createComponent({ mr: { preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY } }); + + expect(wrapper.vm.autoMergeText).toEqual('Merge when pipeline succeeds'); + }); + }); + + describe('shouldShowMergeImmediatelyDropdown', () => { + it('should return false if no pipeline is active', () => { + createComponent({ + mr: { isPipelineActive: false, onlyAllowMergeIfPipelineSucceeds: false }, + }); + + expect(wrapper.vm.shouldShowMergeImmediatelyDropdown).toBe(false); + }); + + it('should return false if "Pipelines must succeed" is enabled for the current project', () => { + createComponent({ mr: { isPipelineActive: true, onlyAllowMergeIfPipelineSucceeds: true } }); + + expect(wrapper.vm.shouldShowMergeImmediatelyDropdown).toBe(false); + }); + }); + + describe('isMergeButtonDisabled', () => { + it('should return false with initial data', () => { + createComponent({ mr: { isMergeAllowed: true } }); + + expect(wrapper.vm.isMergeButtonDisabled).toBe(false); + }); + + it('should return true when there is no commit message', () => { + createComponent({ mr: { isMergeAllowed: true, commitMessage: '' } }); + + expect(wrapper.vm.isMergeButtonDisabled).toBe(true); + }); + + it('should return true if merge is not allowed', () => { + createComponent({ + mr: { + isMergeAllowed: false, + availableAutoMergeStrategies: [], + onlyAllowMergeIfPipelineSucceeds: true, + }, + }); + + expect(wrapper.vm.isMergeButtonDisabled).toBe(true); + }); + + it('should return true when the vm instance is making request', async () => { + createComponent({ mr: { isMergeAllowed: true } }); + + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ isMakingRequest: true }); + + await nextTick(); + + expect(wrapper.vm.isMergeButtonDisabled).toBe(true); + }); + }); + }); + + describe('methods', () => { + describe('handleMergeButtonClick', () => { + const response = (status) => ({ + data: { + status, + }, + }); + + beforeEach(() => { + readyToMergeResponseSpy = jest + .fn() + .mockResolvedValueOnce(createReadyToMergeResponse({ squash: true, squashOnMerge: true })) + .mockResolvedValue( + createReadyToMergeResponse({ + squash: true, + squashOnMerge: true, + defaultMergeCommitMessage: '', + defaultSquashCommitMessage: '', + }), + ); + }); + + it('should handle merge when pipeline succeeds', async () => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + jest + .spyOn(wrapper.vm.service, 'merge') + .mockResolvedValue(response('merge_when_pipeline_succeeds')); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ removeSourceBranch: false }); + + wrapper.vm.handleMergeButtonClick(true); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(eventHub.$emit).toHaveBeenCalledWith('StateMachineValueChanged', { + transition: 'start-auto-merge', + }); + + const params = wrapper.vm.service.merge.mock.calls[0][0]; + + expect(params).toEqual( + expect.objectContaining({ + sha: wrapper.vm.mr.sha, + commit_message: wrapper.vm.mr.commitMessage, + should_remove_source_branch: false, + auto_merge_strategy: 'merge_when_pipeline_succeeds', + }), + ); + }); + + it('should handle merge failed', async () => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.service, 'merge').mockResolvedValue(response('failed')); + wrapper.vm.handleMergeButtonClick(false, true); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith('FailedToMerge', undefined); + + const params = wrapper.vm.service.merge.mock.calls[0][0]; + + expect(params.should_remove_source_branch).toBe(true); + expect(params.auto_merge_strategy).toBeUndefined(); + }); + + it('should handle merge action accepted case', async () => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.service, 'merge').mockResolvedValue(response('success')); + jest.spyOn(wrapper.vm.mr, 'transitionStateMachine'); + wrapper.vm.handleMergeButtonClick(); + + expect(eventHub.$emit).toHaveBeenCalledWith('StateMachineValueChanged', { + transition: 'start-merge', + }); + + await waitForPromises(); + + expect(wrapper.vm.mr.transitionStateMachine).toHaveBeenCalledWith({ + transition: 'start-merge', + }); + + const params = wrapper.vm.service.merge.mock.calls[0][0]; + + expect(params.should_remove_source_branch).toBe(true); + expect(params.auto_merge_strategy).toBeUndefined(); + }); + + it('hides edit commit message', async () => { + createComponent({}, true, true); + + await waitForPromises(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.service, 'merge').mockResolvedValue(response('success')); + + await wrapper + .findComponent('[data-testid="widget_edit_commit_message"]') + .vm.$emit('input', true); + + expect(wrapper.findComponent('[data-testid="edit_commit_message"]').exists()).toBe(true); + + wrapper.vm.handleMergeButtonClick(); + + await waitForPromises(); + + expect(wrapper.findComponent('[data-testid="edit_commit_message"]').exists()).toBe(false); + }); + }); + + describe('initiateRemoveSourceBranchPolling', () => { + it('should emit event and call simplePoll', () => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + wrapper.vm.initiateRemoveSourceBranchPolling(); + + expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [true]); + expect(simplePoll).toHaveBeenCalled(); + }); + }); + + describe('handleRemoveBranchPolling', () => { + const response = (state) => ({ + data: { + source_branch_exists: state, + }, + }); + + it('should call start and stop polling when MR merged', async () => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + jest.spyOn(wrapper.vm.service, 'poll').mockResolvedValue(response(false)); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + wrapper.vm.handleRemoveBranchPolling( + () => { + cpc = true; + }, + () => { + spc = true; + }, + ); + + await waitForPromises(); + + expect(wrapper.vm.service.poll).toHaveBeenCalled(); + + const args = eventHub.$emit.mock.calls[0]; + + expect(args[0]).toEqual('MRWidgetUpdateRequested'); + expect(args[1]).toBeDefined(); + args[1](); + + expect(eventHub.$emit).toHaveBeenCalledWith('SetBranchRemoveFlag', [false]); + + expect(cpc).toBe(false); + expect(spc).toBe(true); + }); + + it('should continue polling until MR is merged', async () => { + createComponent(); + + jest.spyOn(wrapper.vm.service, 'poll').mockResolvedValue(response(true)); + + let cpc = false; // continuePollingCalled + let spc = false; // stopPollingCalled + + wrapper.vm.handleRemoveBranchPolling( + () => { + cpc = true; + }, + () => { + spc = true; + }, + ); + + await waitForPromises(); + + expect(cpc).toBe(true); + expect(spc).toBe(false); + }); + }); + }); + + describe('Remove source branch checkbox', () => { + describe('when user can merge but cannot delete branch', () => { + it('should be disabled in the rendered output', () => { + createComponent(); + + expect(wrapper.find('#remove-source-branch-input').exists()).toBe(false); + }); + }); + + describe('when user can merge and can delete branch', () => { + beforeEach(() => { + createComponent({ + mr: { canRemoveSourceBranch: true }, + }); + }); + + it('isRemoveSourceBranchButtonDisabled should be false', () => { + expect(wrapper.find('#remove-source-branch-input').props('disabled')).toBe(undefined); + }); + }); + }); + + describe('render children components', () => { + describe('squash checkbox', () => { + it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => { + createComponent({ + mr: { commitsCount: 2, enableSquashBeforeMerge: true }, + }); + + expect(findCheckboxElement().exists()).toBe(true); + }); + + it('should not be rendered when squash before merge is disabled', () => { + createComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: false } }); + + expect(findCheckboxElement().exists()).toBe(false); + }); + + it('should be rendered when there is only 1 commit', () => { + createComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } }); + + expect(findCheckboxElement().exists()).toBe(true); + }); + + describe('squash options', () => { + it.each` + squashState | state | prop | expectation + ${'squashIsReadonly'} | ${'enabled'} | ${'isDisabled'} | ${false} + ${'squashIsSelected'} | ${'selected'} | ${'value'} | ${false} + ${'squashIsSelected'} | ${'unselected'} | ${'value'} | ${false} + `( + 'is $state when squashIsReadonly returns $expectation ', + ({ squashState, prop, expectation }) => { + createComponent({ + mr: { commitsCount: 2, enableSquashBeforeMerge: true, [squashState]: expectation }, + }); + + expect(findCheckboxElement().props(prop)).toBe(expectation); + }, + ); + + it('is not rendered for "Do not allow" option', () => { + createComponent({ + mr: { + commitsCount: 2, + enableSquashBeforeMerge: true, + squashIsReadonly: true, + squashIsSelected: false, + }, + }); + + expect(findCheckboxElement().exists()).toBe(false); + }); + }); + }); + + describe('commits edit components', () => { + describe('when fast-forward merge is enabled', () => { + it('should not be rendered if squash is disabled', async () => { + createComponent({ + mr: { + ffOnlyEnabled: true, + squash: false, + enableSquashBeforeMerge: true, + commitsCount: 2, + }, + }); + + expect(findCommitEditElements().length).toBe(0); + }); + + it('should not be rendered if squash before merge is disabled', () => { + createComponent({ + mr: { + ffOnlyEnabled: true, + squash: true, + enableSquashBeforeMerge: false, + commitsCount: 2, + }, + }); + + expect(findCommitEditElements().length).toBe(0); + }); + + it('should not be rendered if there is only one commit', () => { + createComponent({ + mr: { + ffOnlyEnabled: true, + squash: true, + enableSquashBeforeMerge: true, + commitsCount: 1, + }, + }); + + expect(findCommitEditElements().length).toBe(0); + }); + + it('should have one edit component if squash is enabled and there is more than 1 commit', async () => { + createComponent({ + mr: { + ffOnlyEnabled: true, + squashIsSelected: true, + enableSquashBeforeMerge: true, + commitsCount: 2, + }, + }); + + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + expect(findCommitEditElements().length).toBe(1); + expect(findFirstCommitEditLabel()).toBe('Squash commit message'); + }); + }); + + it('should have two edit components when squash is enabled and there is more than 1 commit', async () => { + createComponent({ + mr: { + commitsCount: 2, + squashIsSelected: true, + enableSquashBeforeMerge: true, + }, + }); + + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + expect(findCommitEditElements().length).toBe(2); + }); + + it('should have two edit components when squash is enabled and there is more than 1 commit and mergeRequestWidgetGraphql is enabled', async () => { + createComponent( + { + mr: { + commitsCount: 2, + squashIsSelected: true, + enableSquashBeforeMerge: true, + }, + }, + true, + ); + + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ + loading: false, + state: { + ...createTestMr({}), + userPermissions: {}, + squash: true, + mergeable: true, + commitCount: 2, + commitsWithoutMergeCommits: {}, + }, + }); + await nextTick(); + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + expect(findCommitEditElements().length).toBe(2); + }); + + it('should have one edit components when squash is enabled and there is 1 commit only', async () => { + createComponent({ + mr: { + commitsCount: 1, + squash: true, + enableSquashBeforeMerge: true, + }, + }); + + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + expect(findCommitEditElements().length).toBe(1); + }); + + it('should have correct edit squash commit label', async () => { + createComponent({ + mr: { + commitsCount: 2, + squashIsSelected: true, + enableSquashBeforeMerge: true, + }, + }); + + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + expect(findFirstCommitEditLabel()).toBe('Squash commit message'); + }); + }); + + describe('commits dropdown', () => { + it('should not be rendered if squash is disabled', () => { + createComponent(); + + expect(findCommitDropdownElement().exists()).toBe(false); + }); + + it('should be rendered if squash is enabled and there is more than 1 commit', async () => { + createComponent({ + mr: { enableSquashBeforeMerge: true, squashIsSelected: true, commitsCount: 2 }, + }); + + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + expect(findCommitDropdownElement().exists()).toBe(true); + }); + }); + + it('renders a tip including a link to docs on templates', async () => { + createComponent(); + + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + expect(findTipLink().exists()).toBe(true); + }); + }); + + describe('Merge button when pipeline has failed', () => { + beforeEach(() => { + createComponent({ + mr: { pipeline: {}, isPipelineFailed: true, availableAutoMergeStrategies: [] }, + }); + }); + + it('should display the correct merge text', () => { + expect(findMergeButton().text()).toBe('Merge...'); + }); + + it('should display confirmation modal when merge button is clicked', async () => { + expect(findPipelineFailedConfirmModal().props()).toEqual({ visible: false }); + + await findMergeButton().vm.$emit('click'); + + expect(findPipelineFailedConfirmModal().props()).toEqual({ visible: true }); + }); + }); + + describe('updating graphql data triggers commit message update when default changed', () => { + const UPDATED_MERGE_COMMIT_MESSAGE = 'New merge message from BE'; + const UPDATED_SQUASH_COMMIT_MESSAGE = 'New squash message from BE'; + const USER_COMMIT_MESSAGE = 'Merge message provided manually by user'; + + const createDefaultGqlComponent = () => + createComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: true } }, true); + + beforeEach(() => { + readyToMergeResponseSpy = jest + .fn() + .mockResolvedValueOnce(createReadyToMergeResponse({ squash: true, squashOnMerge: true })) + .mockResolvedValue( + createReadyToMergeResponse({ + squash: true, + squashOnMerge: true, + defaultMergeCommitMessage: UPDATED_MERGE_COMMIT_MESSAGE, + defaultSquashCommitMessage: UPDATED_SQUASH_COMMIT_MESSAGE, + }), + ); + }); + + describe.each` + desc | finderFn | initialValue | updatedValue | inputId + ${'merge commit message'} | ${findMergeCommitMessage} | ${commitMessage} | ${UPDATED_MERGE_COMMIT_MESSAGE} | ${'#merge-message-edit'} + ${'squash commit message'} | ${findSquashCommitMessage} | ${squashCommitMessage} | ${UPDATED_SQUASH_COMMIT_MESSAGE} | ${'#squash-message-edit'} + `('with $desc', ({ finderFn, initialValue, updatedValue, inputId }) => { + it('should have initial value', async () => { + createDefaultGqlComponent(); + + await waitForPromises(); + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + expect(finderFn()).toBe(initialValue); + }); + + it('should have updated value after graphql refetch', async () => { + createDefaultGqlComponent(); + await waitForPromises(); + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + triggerApprovalUpdated(); + await waitForPromises(); + + expect(finderFn()).toBe(updatedValue); + }); + + it('should not update if user has touched', async () => { + createDefaultGqlComponent(); + await waitForPromises(); + await wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true); + + const input = wrapper.find(inputId); + input.element.value = USER_COMMIT_MESSAGE; + input.trigger('input'); + + triggerApprovalUpdated(); + await waitForPromises(); + + expect(finderFn()).toBe(USER_COMMIT_MESSAGE); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_sha_mismatch_spec.js new file mode 100644 index 00000000000..2a343997cf5 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_sha_mismatch_spec.js @@ -0,0 +1,42 @@ +import { mount } from '@vue/test-utils'; +import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue'; +import { I18N_SHA_MISMATCH } from '~/vue_merge_request_widget/i18n'; + +function createComponent({ path = '' } = {}) { + return mount(ShaMismatch, { + propsData: { + mr: { + mergeRequestDiffsPath: path, + }, + }, + }); +} + +describe('ShaMismatch', () => { + let wrapper; + const findActionButton = () => wrapper.find('[data-testid="action-button"]'); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render warning message', () => { + expect(wrapper.element.innerText).toContain(I18N_SHA_MISMATCH.warningMessage); + }); + + it('action button should have correct label', () => { + expect(findActionButton().text()).toBe(I18N_SHA_MISMATCH.actionButtonLabel); + }); + + it('action button should link to the diff path', () => { + const DIFF_PATH = '/gitlab-org/gitlab-test/-/merge_requests/6/diffs'; + + wrapper = createComponent({ path: DIFF_PATH }); + + expect(findActionButton().attributes('href')).toBe(DIFF_PATH); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js new file mode 100644 index 00000000000..6ea2e8675d3 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -0,0 +1,104 @@ +import { GlFormCheckbox, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue'; +import { SQUASH_BEFORE_MERGE } from '~/vue_merge_request_widget/i18n'; + +describe('Squash before merge component', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMount(SquashBeforeMerge, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findCheckbox = () => wrapper.find(GlFormCheckbox); + + describe('checkbox', () => { + it('is unchecked if passed value prop is false', () => { + createComponent({ + value: false, + }); + + expect(findCheckbox().vm.$attrs.checked).toBe(false); + }); + + it('is checked if passed value prop is true', () => { + createComponent({ + value: true, + }); + + expect(findCheckbox().vm.$attrs.checked).toBe(true); + }); + + it('is disabled if isDisabled prop is true', () => { + createComponent({ + value: false, + isDisabled: true, + }); + + expect(findCheckbox().vm.$attrs.disabled).toBe(true); + }); + }); + + describe('tooltip', () => { + const tooltipTitle = () => findCheckbox().attributes('title'); + + it('does not render when isDisabled is false', () => { + createComponent({ + value: true, + isDisabled: false, + }); + expect(tooltipTitle()).toBeUndefined(); + }); + + it('display message when when isDisabled is true', () => { + createComponent({ + value: true, + isDisabled: true, + }); + + expect(tooltipTitle()).toBe(SQUASH_BEFORE_MERGE.tooltipTitle); + }); + }); + + describe('about link', () => { + it('is not rendered if no help path is passed', () => { + createComponent({ + value: false, + }); + + const aboutLink = wrapper.findComponent(GlLink); + + expect(aboutLink.exists()).toBe(false); + }); + + it('is rendered if help path is passed', () => { + createComponent({ + value: false, + helpPath: 'test-path', + }); + + const aboutLink = wrapper.findComponent(GlLink); + + expect(aboutLink.exists()).toBe(true); + }); + + it('should have a correct help path if passed', () => { + createComponent({ + value: false, + helpPath: 'test-path', + }); + + const aboutLink = wrapper.findComponent(GlLink); + + expect(aboutLink.attributes('href')).toEqual('test-path'); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js new file mode 100644 index 00000000000..e2d79c61b9b --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -0,0 +1,64 @@ +import { mount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import notesEventHub from '~/notes/event_hub'; +import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue'; + +function createComponent({ path = '' } = {}) { + return mount(UnresolvedDiscussions, { + propsData: { + mr: { + createIssueToResolveDiscussionsPath: path, + }, + }, + }); +} + +describe('UnresolvedDiscussions', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('triggers the correct notes event when the jump to first unresolved discussion button is clicked', () => { + jest.spyOn(notesEventHub, '$emit'); + + wrapper.find('[data-testid="jump-to-first"]').trigger('click'); + + expect(notesEventHub.$emit).toHaveBeenCalledWith('jumpToFirstUnresolvedDiscussion'); + }); + + describe('with threads path', () => { + beforeEach(() => { + wrapper = createComponent({ path: TEST_HOST }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should have correct elements', () => { + expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`); + + expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); + expect(wrapper.element.innerText).toContain('Create issue to resolve all threads'); + expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual( + TEST_HOST, + ); + }); + }); + + describe('without threads path', () => { + it('should not show create issue link if user cannot create issue', () => { + expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`); + + expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); + expect(wrapper.element.innerText).not.toContain('Create issue to resolve all threads'); + expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_wip_spec.js new file mode 100644 index 00000000000..af52901f508 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_wip_spec.js @@ -0,0 +1,101 @@ +import Vue, { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkInProgress from '~/vue_merge_request_widget/components/states/work_in_progress.vue'; +import toast from '~/vue_shared/plugins/global_toast'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +jest.mock('~/vue_shared/plugins/global_toast'); + +const createComponent = () => { + const Component = Vue.extend(WorkInProgress); + const mr = { + title: 'The best MR ever', + removeWIPPath: '/path/to/remove/wip', + }; + const service = { + removeWIP() {}, + }; + return new Component({ + el: document.createElement('div'), + propsData: { mr, service }, + }); +}; + +describe('Wip', () => { + describe('props', () => { + it('should have props', () => { + const { mr, service } = WorkInProgress.props; + + expect(mr.type instanceof Object).toBe(true); + expect(mr.required).toBe(true); + + expect(service.type instanceof Object).toBe(true); + expect(service.required).toBe(true); + }); + }); + + describe('data', () => { + it('should have default data', () => { + const vm = createComponent(); + + expect(vm.isMakingRequest).toBeFalsy(); + }); + }); + + describe('methods', () => { + const mrObj = { + is_new_mr_data: true, + }; + + describe('handleRemoveDraft', () => { + it('should make a request to service and handle response', async () => { + const vm = createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + jest.spyOn(vm.service, 'removeWIP').mockReturnValue( + new Promise((resolve) => { + resolve({ + data: mrObj, + }); + }), + ); + + vm.handleRemoveDraft(); + + await waitForPromises(); + + expect(vm.isMakingRequest).toBe(true); + expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); + expect(toast).toHaveBeenCalledWith('Marked as ready. Merging is now allowed.'); + }); + }); + }); + + describe('template', () => { + let vm; + let el; + + beforeEach(() => { + vm = createComponent(); + el = vm.$el; + }); + + it('should have correct elements', () => { + expect(el.classList.contains('mr-widget-body')).toBe(true); + expect(el.innerText).toContain( + "Merge blocked: merge request must be marked as ready. It's still marked as draft.", + ); + expect(el.querySelector('.js-remove-draft').innerText.replace(/\s\s+/g, ' ')).toContain( + 'Mark as ready', + ); + }); + + it('should not show removeWIP button is user cannot update MR', async () => { + vm.mr.removeWIPPath = ''; + + await nextTick(); + + expect(el.querySelector('.js-remove-draft')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/states/new_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/new_ready_to_merge_spec.js new file mode 100644 index 00000000000..5ec9654a4af --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/new_ready_to_merge_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import ReadyToMerge from '~/vue_merge_request_widget/components/states/new_ready_to_merge.vue'; + +let wrapper; + +function factory({ canMerge }) { + wrapper = shallowMount(ReadyToMerge, { + propsData: { + mr: {}, + }, + data() { + return { canMerge }; + }, + }); +} + +describe('New ready to merge state component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + canMerge + ${true} + ${false} + `('renders permission text if canMerge ($canMerge) is false', ({ canMerge }) => { + factory({ canMerge }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/terraform/mock_data.js b/spec/frontend/vue_merge_request_widget/components/terraform/mock_data.js new file mode 100644 index 00000000000..8e46af5dfd6 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/terraform/mock_data.js @@ -0,0 +1,31 @@ +export const invalidPlanWithName = { + job_name: 'Invalid Plan', + job_path: '/path/to/ci/logs/3', + tf_report_error: 'api_error', +}; + +export const invalidPlanWithoutName = { + tf_report_error: 'invalid_json_format', +}; + +export const validPlanWithName = { + create: 10, + update: 20, + delete: 30, + job_name: 'Valid Plan', + job_path: '/path/to/ci/logs/1', +}; + +export const validPlanWithoutName = { + create: 10, + update: 20, + delete: 30, + job_path: '/path/to/ci/logs/2', +}; + +export const plans = { + invalid_plan_one: invalidPlanWithName, + invalid_plan_two: invalidPlanWithoutName, + valid_plan_one: validPlanWithName, + valid_plan_two: validPlanWithoutName, +}; diff --git a/spec/frontend/vue_merge_request_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_merge_request_widget/components/terraform/mr_widget_terraform_container_spec.js new file mode 100644 index 00000000000..8f20d6a8fc9 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/terraform/mr_widget_terraform_container_spec.js @@ -0,0 +1,174 @@ +import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; +import MrWidgetExpanableSection from '~/vue_merge_request_widget/components/mr_widget_expandable_section.vue'; +import MrWidgetTerraformContainer from '~/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue'; +import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue'; +import { invalidPlanWithName, plans, validPlanWithName } from './mock_data'; + +describe('MrWidgetTerraformConainer', () => { + let mock; + let wrapper; + + const propsData = { endpoint: '/path/to/terraform/report.json' }; + + const findHeader = () => wrapper.find('[data-testid="terraform-header-text"]'); + const findPlans = () => wrapper.findAll(TerraformPlan).wrappers.map((x) => x.props('plan')); + + const mockPollingApi = (response, body, header) => { + mock.onGet(propsData.endpoint).reply(response, body, header); + }; + + const mountWrapper = () => { + wrapper = shallowMount(MrWidgetTerraformContainer, { + propsData, + stubs: { MrWidgetExpanableSection, GlSprintf }, + }); + return axios.waitForAll(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('when data is loading', () => { + beforeEach(async () => { + mockPollingApi(200, plans, {}); + + await mountWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + wrapper.setData({ loading: true }); + await nextTick(); + }); + + it('diplays loading skeleton', () => { + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); + expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(false); + }); + }); + + describe('when data has finished loading', () => { + beforeEach(() => { + mockPollingApi(200, plans, {}); + return mountWrapper(); + }); + + it('displays terraform content', () => { + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); + expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(true); + expect(findPlans()).toEqual(Object.values(plans)); + }); + + describe('when data includes one invalid plan', () => { + beforeEach(() => { + const invalidPlanGroup = { bad_plan: invalidPlanWithName }; + mockPollingApi(200, invalidPlanGroup, {}); + return mountWrapper(); + }); + + it('displays header text for one invalid plan', () => { + expect(findHeader().text()).toBe('1 Terraform report failed to generate'); + }); + }); + + describe('when data includes multiple invalid plans', () => { + beforeEach(() => { + const invalidPlanGroup = { + bad_plan_one: invalidPlanWithName, + bad_plan_two: invalidPlanWithName, + }; + + mockPollingApi(200, invalidPlanGroup, {}); + return mountWrapper(); + }); + + it('displays header text for multiple invalid plans', () => { + expect(findHeader().text()).toBe('2 Terraform reports failed to generate'); + }); + }); + + describe('when data includes one valid plan', () => { + beforeEach(() => { + const validPlanGroup = { valid_plan: validPlanWithName }; + mockPollingApi(200, validPlanGroup, {}); + return mountWrapper(); + }); + + it('displays header text for one valid plans', () => { + expect(findHeader().text()).toBe('1 Terraform report was generated in your pipelines'); + }); + }); + + describe('when data includes multiple valid plans', () => { + beforeEach(() => { + const validPlanGroup = { + valid_plan_one: validPlanWithName, + valid_plan_two: validPlanWithName, + }; + mockPollingApi(200, validPlanGroup, {}); + return mountWrapper(); + }); + + it('displays header text for multiple valid plans', () => { + expect(findHeader().text()).toBe('2 Terraform reports were generated in your pipelines'); + }); + }); + }); + + describe('polling', () => { + let pollRequest; + let pollStop; + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + }); + + afterEach(() => { + pollRequest.mockRestore(); + pollStop.mockRestore(); + }); + + describe('successful poll', () => { + beforeEach(() => { + mockPollingApi(200, plans, {}); + + return mountWrapper(); + }); + + it('does not make additional requests after poll is successful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('polling fails', () => { + beforeEach(() => { + mockPollingApi(500, null, {}); + return mountWrapper(); + }); + + it('stops loading', () => { + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); + }); + + it('generates one broken plan', () => { + expect(findPlans()).toEqual([{ tf_report_error: 'api_error' }]); + }); + + it('does not make additional requests after poll is unsuccessful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/terraform/terraform_plan_spec.js b/spec/frontend/vue_merge_request_widget/components/terraform/terraform_plan_spec.js new file mode 100644 index 00000000000..3c9f6c2e165 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/terraform/terraform_plan_spec.js @@ -0,0 +1,93 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue'; +import { + invalidPlanWithName, + invalidPlanWithoutName, + validPlanWithName, + validPlanWithoutName, +} from './mock_data'; + +describe('TerraformPlan', () => { + let wrapper; + + const findIcon = () => wrapper.find('[data-testid="change-type-icon"]'); + const findLogButton = () => wrapper.find('[data-testid="terraform-report-link"]'); + + const mountWrapper = (propsData) => { + wrapper = shallowMount(TerraformPlan, { stubs: { GlLink, GlSprintf }, propsData }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('valid plan with job_name', () => { + beforeEach(() => { + mountWrapper({ plan: validPlanWithName }); + }); + + it('displays a document icon', () => { + expect(findIcon().attributes('name')).toBe('doc-changes'); + }); + + it('diplays the header text with a name', () => { + expect(wrapper.text()).toContain(`The job ${validPlanWithName.job_name} generated a report.`); + }); + + it('diplays the reported changes', () => { + expect(wrapper.text()).toContain( + `Reported Resource Changes: ${validPlanWithName.create} to add, ${validPlanWithName.update} to change, ${validPlanWithName.delete} to delete`, + ); + }); + + it('renders button when url is found', () => { + expect(findLogButton().exists()).toBe(true); + expect(findLogButton().text()).toEqual('View full log'); + }); + }); + + describe('valid plan without job_name', () => { + beforeEach(() => { + mountWrapper({ plan: validPlanWithoutName }); + }); + + it('diplays the header text without a name', () => { + expect(wrapper.text()).toContain('A report was generated in your pipelines.'); + }); + }); + + describe('invalid plan with job_name', () => { + beforeEach(() => { + mountWrapper({ plan: invalidPlanWithName }); + }); + + it('displays a warning icon', () => { + expect(findIcon().attributes('name')).toBe('warning'); + }); + + it('diplays the header text with a name', () => { + expect(wrapper.text()).toContain( + `The job ${invalidPlanWithName.job_name} failed to generate a report.`, + ); + }); + + it('diplays generic error since report values are missing', () => { + expect(wrapper.text()).toContain('Generating the report caused an error.'); + }); + }); + + describe('invalid plan with out job_name', () => { + beforeEach(() => { + mountWrapper({ plan: invalidPlanWithoutName }); + }); + + it('diplays the header text without a name', () => { + expect(wrapper.text()).toContain('A report failed to generate.'); + }); + + it('does not render button because url is missing', () => { + expect(findLogButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js new file mode 100644 index 00000000000..6bb718082a4 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js @@ -0,0 +1,19 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import App from '~/vue_merge_request_widget/components/widget/app.vue'; + +describe('MR Widget App', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(App, { + propsData: { + mr: {}, + }, + }); + }; + + it('mounts the component', () => { + createComponent(); + expect(wrapper.findByTestId('mr-widget-app').exists()).toBe(true); + }); +}); 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 new file mode 100644 index 00000000000..3c08ffdef18 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js @@ -0,0 +1,167 @@ +import { nextTick } from 'vue'; +import * as Sentry from '@sentry/browser'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; +import Widget from '~/vue_merge_request_widget/components/widget/widget.vue'; + +describe('MR Widget', () => { + let wrapper; + + const findStatusIcon = () => wrapper.findComponent(StatusIcon); + + const createComponent = ({ propsData, slots } = {}) => { + wrapper = shallowMountExtended(Widget, { + propsData: { + loadingText: 'Loading widget', + widgetName: 'MyWidget', + value: { + collapsed: null, + expanded: null, + }, + ...propsData, + }, + slots, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('on mount', () => { + it('fetches collapsed', async () => { + const fetchCollapsedData = jest + .fn() + .mockReturnValue(Promise.resolve({ headers: {}, status: 200, data: {} })); + + createComponent({ propsData: { fetchCollapsedData } }); + await waitForPromises(); + expect(fetchCollapsedData).toHaveBeenCalled(); + expect(wrapper.vm.error).toBe(null); + }); + + it('sets the error text when fetch method fails', async () => { + const fetchCollapsedData = jest.fn().mockReturnValue(() => Promise.reject()); + createComponent({ propsData: { fetchCollapsedData } }); + await waitForPromises(); + expect(wrapper.vm.error).toBe('Failed to load'); + }); + + it('displays loading icon until request is made and then displays status icon when the request is complete', async () => { + const fetchCollapsedData = jest + .fn() + .mockReturnValue(Promise.resolve({ headers: {}, status: 200, data: {} })); + + createComponent({ propsData: { fetchCollapsedData, statusIconName: 'warning' } }); + + // Let on mount be called + await nextTick(); + + expect(findStatusIcon().props('isLoading')).toBe(true); + + // Wait until `fetchCollapsedData` is resolved + await waitForPromises(); + + expect(findStatusIcon().props('isLoading')).toBe(false); + expect(findStatusIcon().props('iconName')).toBe('warning'); + }); + + it('displays the loading text', async () => { + const fetchCollapsedData = jest.fn().mockReturnValue(() => Promise.reject()); + createComponent({ propsData: { fetchCollapsedData, statusIconName: 'warning' } }); + expect(wrapper.text()).not.toContain('Loading'); + await nextTick(); + expect(wrapper.text()).toContain('Loading'); + }); + }); + + describe('fetch', () => { + it('sets the data.collapsed property after a successfull call - multiPolling: false', async () => { + const mockData = { headers: {}, status: 200, data: { vulnerabilities: [] } }; + createComponent({ propsData: { fetchCollapsedData: async () => mockData } }); + await waitForPromises(); + expect(wrapper.emitted('input')[0][0]).toEqual({ collapsed: mockData.data, expanded: null }); + }); + + it('sets the data.collapsed property after a successfull call - multiPolling: true', async () => { + const mockData1 = { headers: {}, status: 200, data: { vulnerabilities: [{ vuln: 1 }] } }; + const mockData2 = { headers: {}, status: 200, data: { vulnerabilities: [{ vuln: 2 }] } }; + + createComponent({ + propsData: { + multiPolling: true, + fetchCollapsedData: () => [ + () => Promise.resolve(mockData1), + () => Promise.resolve(mockData2), + ], + }, + }); + + await waitForPromises(); + + expect(wrapper.emitted('input')[0][0]).toEqual({ + collapsed: [mockData1.data, mockData2.data], + expanded: null, + }); + }); + + it('calls sentry when failed', async () => { + const error = new Error('Something went wrong'); + jest.spyOn(Sentry, 'captureException').mockImplementation(); + createComponent({ + propsData: { + fetchCollapsedData: async () => Promise.reject(error), + }, + }); + await waitForPromises(); + expect(wrapper.emitted('input')).toBeUndefined(); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + }); + + describe('content', () => { + it('displays summary property when summary slot is not provided', () => { + createComponent({ + propsData: { + summary: 'Hello world', + fetchCollapsedData: async () => Promise.resolve(), + }, + }); + + expect(wrapper.findByTestId('widget-extension-top-level-summary').text()).toBe('Hello world'); + }); + + it.todo('displays content property when content slot is not provided'); + + it('displays the summary slot when provided', () => { + createComponent({ + propsData: { + fetchCollapsedData: async () => Promise.resolve(), + }, + slots: { + summary: '<b>More complex summary</b>', + }, + }); + + expect(wrapper.findByTestId('widget-extension-top-level-summary').text()).toBe( + 'More complex summary', + ); + }); + + it('displays the content slot when provided', () => { + createComponent({ + propsData: { + fetchCollapsedData: async () => Promise.resolve(), + }, + slots: { + content: '<b>More complex content</b>', + }, + }); + + expect(wrapper.findByTestId('widget-extension-collapsed-section').text()).toBe( + 'More complex content', + ); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js new file mode 100644 index 00000000000..7e7438bcc0f --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js @@ -0,0 +1,125 @@ +import { GlIcon, GlLoadingIcon, GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { + CREATED, + RUNNING, + DEPLOYING, + REDEPLOYING, +} from '~/vue_merge_request_widget/components/deployment/constants'; +import DeploymentActionButton from '~/vue_merge_request_widget/components/deployment/deployment_action_button.vue'; +import { actionButtonMocks } from './deployment_mock_data'; + +const baseProps = { + actionsConfiguration: actionButtonMocks[DEPLOYING], + actionInProgress: null, + computedDeploymentStatus: CREATED, + icon: 'play', +}; + +describe('Deployment action button', () => { + let wrapper; + + const factory = (options = {}) => { + wrapper = mount(DeploymentActionButton, { + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when passed only icon via props', () => { + beforeEach(() => { + factory({ + propsData: baseProps, + slots: {}, + stubs: { + 'gl-icon': GlIcon, + }, + }); + }); + + it('renders prop icon correctly', () => { + expect(wrapper.find(GlIcon).exists()).toBe(true); + }); + }); + + describe('when passed multiple items', () => { + beforeEach(() => { + factory({ + propsData: baseProps, + slots: { + default: [`<span>${actionButtonMocks[DEPLOYING]}</span>`], + }, + stubs: { + 'gl-icon': GlIcon, + }, + }); + }); + + it('renders slot and icon prop correctly', () => { + expect(wrapper.find(GlIcon).exists()).toBe(true); + expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING].toString()); + }); + }); + + describe('when its action is in progress', () => { + beforeEach(() => { + factory({ + propsData: { + ...baseProps, + actionInProgress: actionButtonMocks[DEPLOYING].actionName, + }, + }); + }); + + it('is disabled and shows the loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(GlButton).props('disabled')).toBe(true); + }); + }); + + describe('when another action is in progress', () => { + beforeEach(() => { + factory({ + propsData: { + ...baseProps, + actionInProgress: actionButtonMocks[REDEPLOYING].actionName, + }, + }); + }); + it('is disabled and does not show the loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(GlButton).props('disabled')).toBe(true); + }); + }); + + describe('when action status is running', () => { + beforeEach(() => { + factory({ + propsData: { + ...baseProps, + actionInProgress: actionButtonMocks[REDEPLOYING].actionName, + computedDeploymentStatus: RUNNING, + }, + }); + }); + it('is disabled and does not show the loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(GlButton).props('disabled')).toBe(true); + }); + }); + + describe('when no action is in progress', () => { + beforeEach(() => { + factory({ + propsData: baseProps, + }); + }); + it('is not disabled nor does it show the loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(GlButton).props('disabled')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js new file mode 100644 index 00000000000..a285d26f404 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js @@ -0,0 +1,234 @@ +import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { + CREATED, + MANUAL_DEPLOY, + FAILED, + DEPLOYING, + REDEPLOYING, + STOPPING, +} from '~/vue_merge_request_widget/components/deployment/constants'; +import DeploymentActions from '~/vue_merge_request_widget/components/deployment/deployment_actions.vue'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import { + actionButtonMocks, + deploymentMockData, + playDetails, + retryDetails, +} from './deployment_mock_data'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => { + return { + confirmAction: jest.fn(), + }; +}); + +describe('DeploymentAction component', () => { + let wrapper; + let executeActionSpy; + + const factory = (options = {}) => { + // This destroys any wrappers created before a nested call to factory reassigns it + if (wrapper && wrapper.destroy) { + wrapper.destroy(); + } + + wrapper = mount(DeploymentActions, options); + }; + + const findStopButton = () => wrapper.find('.js-stop-env'); + const findDeployButton = () => wrapper.find('.js-manual-deploy-action'); + const findRedeployButton = () => wrapper.find('.js-manual-redeploy-action'); + + beforeEach(() => { + executeActionSpy = jest.spyOn(MRWidgetService, 'executeInlineAction'); + + factory({ + propsData: { + computedDeploymentStatus: CREATED, + deployment: deploymentMockData, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + confirmAction.mockReset(); + }); + + describe('actions do not appear when conditions are unmet', () => { + describe('when there is no stop_url', () => { + beforeEach(() => { + factory({ + propsData: { + computedDeploymentStatus: CREATED, + deployment: { + ...deploymentMockData, + stop_url: null, + }, + }, + }); + }); + + it('the stop button does not appear', () => { + expect(findStopButton().exists()).toBe(false); + }); + }); + + describe('when there is no play_path in details', () => { + it('the manual deploy button does not appear', () => { + expect(findDeployButton().exists()).toBe(false); + }); + }); + + describe('when there is no retry_path in details', () => { + it('the manual redeploy button does not appear', () => { + expect(findRedeployButton().exists()).toBe(false); + }); + }); + }); + + describe('when conditions are met', () => { + describe.each` + configConst | computedDeploymentStatus | displayConditionChanges | finderFn | endpoint + ${STOPPING} | ${CREATED} | ${{}} | ${findStopButton} | ${deploymentMockData.stop_url} + ${DEPLOYING} | ${MANUAL_DEPLOY} | ${playDetails} | ${findDeployButton} | ${playDetails.playable_build.play_path} + ${REDEPLOYING} | ${FAILED} | ${retryDetails} | ${findRedeployButton} | ${retryDetails.playable_build.retry_path} + `( + '$configConst action', + ({ configConst, computedDeploymentStatus, displayConditionChanges, finderFn, endpoint }) => { + describe(`${configConst} action`, () => { + beforeEach(() => { + factory({ + propsData: { + computedDeploymentStatus, + deployment: { + ...deploymentMockData, + details: displayConditionChanges, + }, + }, + }); + }); + + it('the button is rendered', () => { + expect(finderFn().exists()).toBe(true); + }); + + describe('when clicked', () => { + describe('should show a confirm dialog but not call executeInlineAction when declined', () => { + beforeEach(() => { + executeActionSpy.mockResolvedValueOnce(); + confirmAction.mockResolvedValueOnce(false); + finderFn().trigger('click'); + }); + + it('should show the confirm dialog', () => { + expect(confirmAction).toHaveBeenCalled(); + expect(confirmAction).toHaveBeenCalledWith( + actionButtonMocks[configConst].confirmMessage, + { + primaryBtnVariant: actionButtonMocks[configConst].buttonVariant, + primaryBtnText: actionButtonMocks[configConst].buttonText, + }, + ); + }); + + it('should not execute the action', () => { + expect(MRWidgetService.executeInlineAction).not.toHaveBeenCalled(); + }); + }); + + describe('should show a confirm dialog and call executeInlineAction when accepted', () => { + beforeEach(() => { + executeActionSpy.mockResolvedValueOnce(); + confirmAction.mockResolvedValueOnce(true); + finderFn().trigger('click'); + }); + + it('should show the confirm dialog', () => { + expect(confirmAction).toHaveBeenCalled(); + expect(confirmAction).toHaveBeenCalledWith( + actionButtonMocks[configConst].confirmMessage, + { + primaryBtnVariant: actionButtonMocks[configConst].buttonVariant, + primaryBtnText: actionButtonMocks[configConst].buttonText, + }, + ); + }); + + it('should execute the action with expected URL', () => { + expect(MRWidgetService.executeInlineAction).toHaveBeenCalled(); + expect(MRWidgetService.executeInlineAction).toHaveBeenCalledWith(endpoint); + }); + + it('should not throw an error', () => { + expect(createFlash).not.toHaveBeenCalled(); + }); + + describe('response includes redirect_url', () => { + const url = '/root/example'; + beforeEach(async () => { + executeActionSpy.mockResolvedValueOnce({ + data: { redirect_url: url }, + }); + + await waitForPromises(); + + confirmAction.mockResolvedValueOnce(true); + finderFn().trigger('click'); + }); + + it('calls visit url with the redirect_url', () => { + expect(visitUrl).toHaveBeenCalled(); + expect(visitUrl).toHaveBeenCalledWith(url); + }); + }); + + describe('it should call the executeAction method ', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm, 'executeAction').mockImplementation(); + + await waitForPromises(); + + confirmAction.mockResolvedValueOnce(true); + finderFn().trigger('click'); + }); + + it('calls with the expected arguments', () => { + expect(wrapper.vm.executeAction).toHaveBeenCalled(); + expect(wrapper.vm.executeAction).toHaveBeenCalledWith( + endpoint, + actionButtonMocks[configConst], + ); + }); + }); + + describe('when executeInlineAction errors', () => { + beforeEach(async () => { + executeActionSpy.mockRejectedValueOnce(); + + await waitForPromises(); + + confirmAction.mockResolvedValueOnce(true); + finderFn().trigger('click'); + }); + + it('should call createFlash with error message', () => { + expect(createFlash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ + message: actionButtonMocks[configConst].errorMessage, + }); + }); + }); + }); + }); + }); + }, + ); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_list_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_list_spec.js new file mode 100644 index 00000000000..948d7ebab5e --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_list_spec.js @@ -0,0 +1,95 @@ +import { mount } from '@vue/test-utils'; +import { zip } from 'lodash'; +import { trimText } from 'helpers/text_helper'; +import Deployment from '~/vue_merge_request_widget/components/deployment/deployment.vue'; +import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue'; +import MrCollapsibleExtension from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue'; +import { mockStore } from '../mock_data'; + +const DEFAULT_PROPS = { + hasDeploymentMetrics: false, + deploymentClass: 'js-pre-deployment', +}; + +describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue', () => { + let wrapper; + let propsData; + + const factory = (props = {}) => { + propsData = { + ...DEFAULT_PROPS, + deployments: mockStore.deployments, + ...props, + }; + wrapper = mount(DeploymentList, { + propsData, + }); + }; + + afterEach(() => { + wrapper?.destroy?.(); + wrapper = null; + }); + + describe('with few deployments', () => { + beforeEach(() => { + factory(); + }); + + it('shows all deployments', () => { + const deploymentWrappers = wrapper.findAllComponents(Deployment); + expect(wrapper.findComponent(MrCollapsibleExtension).exists()).toBe(false); + expect(deploymentWrappers).toHaveLength(propsData.deployments.length); + + zip(deploymentWrappers.wrappers, propsData.deployments).forEach( + ([deploymentWrapper, deployment]) => { + expect(deploymentWrapper.props('deployment')).toEqual(deployment); + expect(deploymentWrapper.props()).toMatchObject({ + showMetrics: DEFAULT_PROPS.hasDeploymentMetrics, + }); + expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true); + expect(deploymentWrapper.text()).toEqual(expect.any(String)); + expect(deploymentWrapper.text()).not.toBe(''); + }, + ); + }); + }); + describe('with many deployments', () => { + let deployments; + let collapsibleExtension; + + beforeEach(() => { + deployments = [ + ...mockStore.deployments, + ...mockStore.deployments.map((deployment) => ({ + ...deployment, + id: deployment.id + mockStore.deployments.length, + })), + ]; + factory({ deployments }); + + collapsibleExtension = wrapper.findComponent(MrCollapsibleExtension); + }); + + it('shows collapsed deployments', () => { + expect(collapsibleExtension.exists()).toBe(true); + expect(trimText(collapsibleExtension.text())).toBe( + `${deployments.length} environments impacted. View all environments.`, + ); + }); + it('shows all deployments on click', async () => { + await collapsibleExtension.find('button').trigger('click'); + const deploymentWrappers = wrapper.findAllComponents(Deployment); + expect(deploymentWrappers).toHaveLength(deployments.length); + + zip(deploymentWrappers.wrappers, propsData.deployments).forEach( + ([deploymentWrapper, deployment]) => { + expect(deploymentWrapper.props('deployment')).toEqual(deployment); + expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true); + expect(deploymentWrapper.text()).toEqual(expect.any(String)); + expect(deploymentWrapper.text()).not.toBe(''); + }, + ); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js new file mode 100644 index 00000000000..e98b1160ae4 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js @@ -0,0 +1,76 @@ +import { + DEPLOYING, + REDEPLOYING, + SUCCESS, + STOPPING, +} from '~/vue_merge_request_widget/components/deployment/constants'; + +const actionButtonMocks = { + [STOPPING]: { + actionName: STOPPING, + buttonText: 'Stop environment', + buttonVariant: 'danger', + busyText: 'This environment is being deployed', + confirmMessage: 'Are you sure you want to stop this environment?', + errorMessage: 'Something went wrong while stopping this environment. Please try again.', + }, + [DEPLOYING]: { + actionName: DEPLOYING, + buttonText: 'Deploy', + buttonVariant: 'confirm', + busyText: 'This environment is being deployed', + confirmMessage: 'Are you sure you want to deploy this environment?', + errorMessage: 'Something went wrong while deploying this environment. Please try again.', + }, + [REDEPLOYING]: { + actionName: REDEPLOYING, + buttonText: 'Re-deploy', + buttonVariant: 'confirm', + busyText: 'This environment is being re-deployed', + confirmMessage: 'Are you sure you want to re-deploy this environment?', + errorMessage: 'Something went wrong while deploying this environment. Please try again.', + }, +}; + +const deploymentMockData = { + id: 15, + name: 'review/diplo', + url: '/root/review-apps/environments/15', + stop_url: '/root/review-apps/environments/15/stop', + metrics_url: '/root/review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/review-apps/environments/15/metrics', + external_url: 'http://gitlab.com.', + external_url_formatted: 'gitlab', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + details: {}, + status: SUCCESS, + changes: [ + { + path: 'index.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html', + }, + { + path: 'imgs/gallery.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', + }, + { + path: 'about/', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/', + }, + ], +}; + +const playDetails = { + playable_build: { + play_path: '/root/test-deployments/-/jobs/1131/play', + }, +}; + +const retryDetails = { + playable_build: { + retry_path: '/root/test-deployments/-/jobs/1131/retry', + }, +}; + +export { actionButtonMocks, deploymentMockData, playDetails, retryDetails }; diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js new file mode 100644 index 00000000000..c27cbd8b781 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_spec.js @@ -0,0 +1,187 @@ +import { mount } from '@vue/test-utils'; +import { + CREATED, + RUNNING, + SUCCESS, + FAILED, + CANCELED, + SKIPPED, +} from '~/vue_merge_request_widget/components/deployment/constants'; +import DeploymentComponent from '~/vue_merge_request_widget/components/deployment/deployment.vue'; +import DeploymentInfo from '~/vue_merge_request_widget/components/deployment/deployment_info.vue'; +import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; +import { deploymentMockData, playDetails, retryDetails } from './deployment_mock_data'; + +describe('Deployment component', () => { + let wrapper; + + const factory = (options = {}) => { + // This destroys any wrappers created before a nested call to factory reassigns it + if (wrapper && wrapper.destroy) { + wrapper.destroy(); + } + wrapper = mount(DeploymentComponent, options); + }; + + beforeEach(() => { + factory({ + propsData: { + deployment: deploymentMockData, + showMetrics: false, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('always renders DeploymentInfo', () => { + expect(wrapper.find(DeploymentInfo).exists()).toBe(true); + }); + + describe('status message and buttons', () => { + const noActions = []; + const noDetails = { isManual: false }; + const deployDetail = { + ...playDetails, + isManual: true, + }; + + const retryDetail = { + ...retryDetails, + isManual: true, + }; + const defaultGroup = ['.js-deploy-url', '.js-stop-env']; + const manualDeployGroup = ['.js-manual-deploy-action', ...defaultGroup]; + const manualRedeployGroup = ['.js-manual-redeploy-action', ...defaultGroup]; + + describe.each` + status | previous | deploymentDetails | text | actionButtons + ${CREATED} | ${true} | ${deployDetail} | ${'Can be manually deployed to'} | ${manualDeployGroup} + ${CREATED} | ${true} | ${noDetails} | ${'Will deploy to'} | ${defaultGroup} + ${CREATED} | ${false} | ${deployDetail} | ${'Can be manually deployed to'} | ${noActions} + ${CREATED} | ${false} | ${noDetails} | ${'Will deploy to'} | ${noActions} + ${RUNNING} | ${true} | ${deployDetail} | ${'Deploying to'} | ${defaultGroup} + ${RUNNING} | ${true} | ${noDetails} | ${'Deploying to'} | ${defaultGroup} + ${RUNNING} | ${false} | ${deployDetail} | ${'Deploying to'} | ${noActions} + ${RUNNING} | ${false} | ${noDetails} | ${'Deploying to'} | ${noActions} + ${SUCCESS} | ${true} | ${deployDetail} | ${'Deployed to'} | ${defaultGroup} + ${SUCCESS} | ${true} | ${noDetails} | ${'Deployed to'} | ${defaultGroup} + ${SUCCESS} | ${false} | ${deployDetail} | ${'Deployed to'} | ${defaultGroup} + ${SUCCESS} | ${false} | ${noDetails} | ${'Deployed to'} | ${defaultGroup} + ${FAILED} | ${true} | ${retryDetail} | ${'Failed to deploy to'} | ${manualRedeployGroup} + ${FAILED} | ${true} | ${noDetails} | ${'Failed to deploy to'} | ${defaultGroup} + ${FAILED} | ${false} | ${retryDetail} | ${'Failed to deploy to'} | ${noActions} + ${FAILED} | ${false} | ${noDetails} | ${'Failed to deploy to'} | ${noActions} + ${CANCELED} | ${true} | ${deployDetail} | ${'Canceled deployment to'} | ${defaultGroup} + ${CANCELED} | ${true} | ${noDetails} | ${'Canceled deployment to'} | ${defaultGroup} + ${CANCELED} | ${false} | ${deployDetail} | ${'Canceled deployment to'} | ${noActions} + ${CANCELED} | ${false} | ${noDetails} | ${'Canceled deployment to'} | ${noActions} + ${SKIPPED} | ${true} | ${deployDetail} | ${'Skipped deployment to'} | ${defaultGroup} + ${SKIPPED} | ${true} | ${noDetails} | ${'Skipped deployment to'} | ${defaultGroup} + ${SKIPPED} | ${false} | ${deployDetail} | ${'Skipped deployment to'} | ${noActions} + ${SKIPPED} | ${false} | ${noDetails} | ${'Skipped deployment to'} | ${noActions} + `( + '$status + previous: $previous + manual: $deploymentDetails.isManual', + ({ status, previous, deploymentDetails, text, actionButtons }) => { + beforeEach(() => { + const previousOrSuccess = Boolean(previous || status === SUCCESS); + const updatedDeploymentData = { + status, + deployed_at: previous ? deploymentMockData.deployed_at : null, + deployed_at_formatted: previous ? deploymentMockData.deployed_at_formatted : null, + external_url: previousOrSuccess ? deploymentMockData.external_url : null, + external_url_formatted: previousOrSuccess + ? deploymentMockData.external_url_formatted + : null, + stop_url: previousOrSuccess ? deploymentMockData.stop_url : null, + details: deploymentDetails, + }; + + factory({ + propsData: { + showMetrics: false, + deployment: { + ...deploymentMockData, + ...updatedDeploymentData, + }, + }, + }); + }); + + it(`renders the text: ${text}`, () => { + expect(wrapper.find(DeploymentInfo).text()).toContain(text); + }); + + if (actionButtons.length > 0) { + describe('renders the expected button group', () => { + actionButtons.forEach((button) => { + it(`renders ${button}`, () => { + expect(wrapper.find(button).exists()).toBe(true); + }); + }); + }); + } + + if (actionButtons.length === 0) { + describe('does not render the button group', () => { + defaultGroup.forEach((button) => { + it(`does not render ${button}`, () => { + expect(wrapper.find(button).exists()).toBe(false); + }); + }); + }); + } + + if (actionButtons.includes(DeploymentViewButton)) { + it('renders the View button with expected text', () => { + if (status === SUCCESS) { + expect(wrapper.find(DeploymentViewButton).text()).toContain('View app'); + } else { + expect(wrapper.find(DeploymentViewButton).text()).toContain('View latest app'); + } + }); + } + }, + ); + }); + + describe('hasExternalUrls', () => { + describe('when deployment has both external_url_formatted and external_url', () => { + it('should render the View Button', () => { + expect(wrapper.find(DeploymentViewButton).exists()).toBe(true); + }); + }); + + describe('when deployment has no external_url_formatted', () => { + beforeEach(() => { + factory({ + propsData: { + deployment: { ...deploymentMockData, external_url_formatted: null }, + showMetrics: false, + }, + }); + }); + + it('should not render the View Button', () => { + expect(wrapper.find(DeploymentViewButton).exists()).toBe(false); + }); + }); + + describe('when deployment has no external_url', () => { + beforeEach(() => { + factory({ + propsData: { + deployment: { ...deploymentMockData, external_url: null }, + showMetrics: false, + }, + }); + }); + + it('should not render the View Button', () => { + expect(wrapper.find(DeploymentViewButton).exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js new file mode 100644 index 00000000000..eb6e3711e2e --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_view_button_spec.js @@ -0,0 +1,109 @@ +import { GlDropdown, GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; +import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue'; +import { deploymentMockData } from './deployment_mock_data'; + +const appButtonText = { + text: 'View app', + tooltip: 'View the latest successful deployment to this environment', +}; + +describe('Deployment View App button', () => { + let wrapper; + + const createComponent = (options = {}) => { + wrapper = mountExtended(DeploymentViewButton, { + ...options, + }); + }; + + beforeEach(() => { + createComponent({ + propsData: { + deployment: deploymentMockData, + appButtonText, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findReviewAppLink = () => wrapper.findComponent(ReviewAppLink); + const findMrWigdetDeploymentDropdown = () => wrapper.findComponent(GlDropdown); + const findMrWigdetDeploymentDropdownIcon = () => + wrapper.findByTestId('mr-wigdet-deployment-dropdown-icon'); + const findDeployUrlMenuItems = () => wrapper.findAllComponents(GlLink); + + describe('text', () => { + it('renders text as passed', () => { + expect(findReviewAppLink().props().display.text).toBe(appButtonText.text); + }); + }); + + describe('without changes', () => { + beforeEach(() => { + createComponent({ + propsData: { + deployment: { ...deploymentMockData, changes: null }, + appButtonText, + }, + }); + }); + + it('renders the link to the review app without dropdown', () => { + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + }); + }); + + describe('with a single change', () => { + beforeEach(() => { + createComponent({ + propsData: { + deployment: { ...deploymentMockData, changes: [deploymentMockData.changes[0]] }, + appButtonText, + }, + }); + }); + + it('renders the link to the review app without dropdown', () => { + expect(findMrWigdetDeploymentDropdown().exists()).toBe(false); + expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(false); + }); + + it('renders the link to the review app linked to to the first change', () => { + const expectedUrl = deploymentMockData.changes[0].external_url; + + expect(findReviewAppLink().attributes('href')).toBe(expectedUrl); + }); + }); + + describe('with multiple changes', () => { + beforeEach(() => { + createComponent({ + propsData: { + deployment: deploymentMockData, + appButtonText, + }, + }); + }); + + it('renders the link to the review app with dropdown', () => { + expect(findMrWigdetDeploymentDropdown().exists()).toBe(true); + expect(findMrWigdetDeploymentDropdownIcon().exists()).toBe(true); + }); + + it('renders all the links to the review apps', () => { + const allUrls = findDeployUrlMenuItems().wrappers; + const expectedUrls = deploymentMockData.changes.map((change) => change.external_url); + + expectedUrls.forEach((expectedUrl, idx) => { + const deployUrl = allUrls[idx]; + + expect(deployUrl.attributes('href')).toBe(expectedUrl); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..5c1d3c8e8e8 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js @@ -0,0 +1,269 @@ +import { nextTick } from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import testReportExtension from '~/vue_merge_request_widget/extensions/test_report'; +import { i18n } from '~/vue_merge_request_widget/extensions/test_report/constants'; +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 extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; +import httpStatusCodes from '~/lib/utils/http_status'; +import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; + +import { failedReport } from 'jest/reports/mock_data/mock_data'; +import mixedResultsTestReports from 'jest/reports/mock_data/new_and_fixed_failures_report.json'; +import newErrorsTestReports from 'jest/reports/mock_data/new_errors_report.json'; +import newFailedTestReports from 'jest/reports/mock_data/new_failures_report.json'; +import successTestReports from 'jest/reports/mock_data/no_failures_report.json'; +import resolvedFailures from 'jest/reports/mock_data/resolved_failures.json'; +import recentFailures from 'jest/reports/mock_data/recent_failures_report.json'; + +const reportWithParsingErrors = failedReport; +reportWithParsingErrors.suites[0].suite_errors = { + head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + base: 'JUnit data parsing failed: string not matched', +}; + +describe('Test report extension', () => { + let wrapper; + let mock; + + registerExtension(testReportExtension); + + const endpoint = '/root/repo/-/merge_requests/4/test_reports.json'; + + const mockApi = (statusCode, data = mixedResultsTestReports) => { + mock.onGet(endpoint).reply(statusCode, data); + }; + + const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button'); + const findFullReportLink = () => wrapper.findByTestId('full-report-link'); + const findCopyFailedSpecsBtn = () => wrapper.findByTestId('copy-failed-specs-btn'); + const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); + const findModal = () => wrapper.find(TestCaseDetails); + + const createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + testResultsPath: endpoint, + headBlobPath: 'head/blob/path', + pipeline: { path: 'pipeline/path' }, + }, + }, + }); + }; + + const createExpandedWidgetWithData = async (data = mixedResultsTestReports) => { + mockApi(httpStatusCodes.OK, data); + createComponent(); + await waitForPromises(); + findToggleCollapsedButton().trigger('click'); + await waitForPromises(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + it('displays loading state initially', () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + expect(wrapper.text()).toContain(i18n.loading); + }); + + it('with a 204 response, continues to display loading state', async () => { + mockApi(httpStatusCodes.NO_CONTENT, ''); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.loading); + }); + + it('with an error response, displays failed to load text', async () => { + mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.error); + }); + + it.each` + description | mockData | expectedResult + ${'mixed test results'} | ${mixedResultsTestReports} | ${'Test summary: 2 failed and 2 fixed test results, 11 total tests'} + ${'unchanged test results'} | ${successTestReports} | ${'Test summary: no changed test results, 11 total tests'} + ${'tests with errors'} | ${newErrorsTestReports} | ${'Test summary: 2 errors, 11 total tests'} + ${'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); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(expectedResult); + }); + + it('displays report level recently failed count', async () => { + mockApi(httpStatusCodes.OK, recentFailures); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain( + '2 out of 3 failed tests have failed more than once in the last 14 days', + ); + }); + + it('displays a link to the full report', async () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + await waitForPromises(); + + expect(findFullReportLink().text()).toBe('Full report'); + expect(findFullReportLink().attributes('href')).toBe('pipeline/path/test_report'); + }); + + it('hides copy failed tests button when there are no failing tests', async () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + await waitForPromises(); + + expect(findCopyFailedSpecsBtn().exists()).toBe(false); + }); + + it('displays copy failed tests button when there are failing tests', async () => { + mockApi(httpStatusCodes.OK, newFailedTestReports); + createComponent(); + + await waitForPromises(); + + expect(findCopyFailedSpecsBtn().exists()).toBe(true); + expect(findCopyFailedSpecsBtn().text()).toBe(i18n.copyFailedSpecs); + expect(findCopyFailedSpecsBtn().attributes('data-clipboard-text')).toBe( + 'spec/file_1.rb spec/file_2.rb', + ); + }); + + it('copy failed tests button updates tooltip text when clicked', async () => { + mockApi(httpStatusCodes.OK, newFailedTestReports); + createComponent(); + + await waitForPromises(); + + // original tooltip shows up + expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({ + title: i18n.copyFailedSpecsTooltip, + }); + + await findCopyFailedSpecsBtn().trigger('click'); + + // tooltip text is replaced for 1 second + expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({ + title: 'Copied', + }); + + jest.runAllTimers(); + await nextTick(); + + // tooltip reverts back to original string + expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({ + title: i18n.copyFailedSpecsTooltip, + }); + }); + + it('shows an error when a suite has a parsing error', async () => { + mockApi(httpStatusCodes.OK, reportWithParsingErrors); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.error); + }); + }); + + describe('expanded data', () => { + it('displays summary for each suite', async () => { + await createExpandedWidgetWithData(); + + expect(trimText(findAllExtensionListItems().at(0).text())).toContain( + 'rspec:pg: 1 failed and 2 fixed test results, 8 total tests', + ); + expect(trimText(findAllExtensionListItems().at(1).text())).toContain( + 'java ant: 1 failed, 3 total tests', + ); + }); + + it('displays suite parsing errors', async () => { + await createExpandedWidgetWithData(reportWithParsingErrors); + + const suiteText = trimText(findAllExtensionListItems().at(0).text()); + + expect(suiteText).toContain( + 'Head report parsing error: JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + ); + expect(suiteText).toContain( + 'Base report parsing error: JUnit data parsing failed: string not matched', + ); + }); + + it('displays suite level recently failed count', async () => { + await createExpandedWidgetWithData(recentFailures); + + expect(trimText(findAllExtensionListItems().at(0).text())).toContain( + '1 out of 2 failed tests has failed more than once in the last 14 days', + ); + expect(trimText(findAllExtensionListItems().at(1).text())).toContain( + '1 out of 1 failed test has failed more than once in the last 14 days', + ); + }); + + it('displays the list of failed and fixed tests', async () => { + await createExpandedWidgetWithData(); + + const firstSuite = trimText(findAllExtensionListItems().at(0).text()); + const secondSuite = trimText(findAllExtensionListItems().at(1).text()); + + expect(firstSuite).toContain('Test#subtract when a is 2 and b is 1 returns correct result'); + expect(firstSuite).toContain('Test#sum when a is 1 and b is 2 returns summary'); + expect(firstSuite).toContain('Test#sum when a is 100 and b is 200 returns summary'); + + expect(secondSuite).toContain('sumTest'); + }); + + it('displays the test level recently failed count', async () => { + await createExpandedWidgetWithData(recentFailures); + + expect(trimText(findAllExtensionListItems().at(0).text())).toContain( + 'Failed 8 times in main in the last 14 days', + ); + }); + }); + + describe('modal link', () => { + beforeEach(async () => { + await createExpandedWidgetWithData(); + + wrapper.findByTestId('modal-link').trigger('click'); + }); + + it('opens a modal to display test case details', () => { + expect(findModal().exists()).toBe(true); + expect(findModal().props('testCase')).toMatchObject( + mixedResultsTestReports.suites[0].new_failures[0], + ); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/extensions/test_report/utils_spec.js b/spec/frontend/vue_merge_request_widget/extensions/test_report/utils_spec.js new file mode 100644 index 00000000000..69ea70549fe --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extensions/test_report/utils_spec.js @@ -0,0 +1,242 @@ +import * as utils from '~/vue_merge_request_widget/extensions/test_report/utils'; + +describe('test report widget extension utils', () => { + describe('summaryTextbuilder', () => { + it('should render text for no changed results in multiple tests', () => { + const name = 'Test summary'; + const data = { total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}no%{strong_end} changed test results, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for no changed results in one test', () => { + const name = 'Test summary'; + const data = { total: 1 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}no%{strong_end} changed test results, %{strong_start}1%{strong_end} total test', + ); + }); + + it('should render text for multiple failed results', () => { + const name = 'Test summary'; + const data = { failed: 3, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}3%{strong_end} failed, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for multiple errored results', () => { + const name = 'Test summary'; + const data = { errored: 7, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}7%{strong_end} errors, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for multiple fixed results', () => { + const name = 'Test summary'; + const data = { resolved: 4, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for multiple fixed, and multiple failed results', () => { + const name = 'Test summary'; + const data = { failed: 3, resolved: 4, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}3%{strong_end} failed and %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for a singular fixed, and a singular failed result', () => { + const name = 'Test summary'; + const data = { failed: 1, resolved: 1, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}1%{strong_end} failed and %{strong_start}1%{strong_end} fixed test result, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for singular failed, errored, and fixed results', () => { + const name = 'Test summary'; + const data = { failed: 1, errored: 1, resolved: 1, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}1%{strong_end} failed, %{strong_start}1%{strong_end} error and %{strong_start}1%{strong_end} fixed test result, %{strong_start}10%{strong_end} total tests', + ); + }); + + it('should render text for multiple failed, errored, and fixed results', () => { + const name = 'Test summary'; + const data = { failed: 2, errored: 3, resolved: 4, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary: %{strong_start}2%{strong_end} failed, %{strong_start}3%{strong_end} errors and %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests', + ); + }); + }); + + describe('reportTextBuilder', () => { + const name = 'Rspec'; + + it('should render text for no changed results in multiple tests', () => { + const data = { name, summary: { total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: no changed test results, 10 total tests'); + }); + + it('should render text for no changed results in one test', () => { + const data = { name, summary: { total: 1 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: no changed test results, 1 total test'); + }); + + it('should render text for multiple failed results', () => { + const data = { name, summary: { failed: 3, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 3 failed, 10 total tests'); + }); + + it('should render text for multiple errored results', () => { + const data = { name, summary: { errored: 7, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 7 errors, 10 total tests'); + }); + + it('should render text for multiple fixed results', () => { + const data = { name, summary: { resolved: 4, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 4 fixed test results, 10 total tests'); + }); + + it('should render text for multiple fixed, and multiple failed results', () => { + const data = { name, summary: { failed: 3, resolved: 4, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 3 failed and 4 fixed test results, 10 total tests'); + }); + + it('should render text for a singular fixed, and a singular failed result', () => { + const data = { name, summary: { failed: 1, resolved: 1, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 1 failed and 1 fixed test result, 10 total tests'); + }); + + it('should render text for singular failed, errored, and fixed results', () => { + const data = { name, summary: { failed: 1, errored: 1, resolved: 1, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 1 failed, 1 error and 1 fixed test result, 10 total tests'); + }); + + it('should render text for multiple failed, errored, and fixed results', () => { + const data = { name, summary: { failed: 2, errored: 3, resolved: 4, total: 10 } }; + const result = utils.reportTextBuilder(data); + + expect(result).toBe('Rspec: 2 failed, 3 errors and 4 fixed test results, 10 total tests'); + }); + }); + + describe('recentFailuresTextBuilder', () => { + it.each` + recentlyFailed | failed | expected + ${0} | ${1} | ${''} + ${1} | ${1} | ${'1 out of 1 failed test has failed more than once in the last 14 days'} + ${1} | ${2} | ${'1 out of 2 failed tests has failed more than once in the last 14 days'} + ${2} | ${3} | ${'2 out of 3 failed tests have failed more than once in the last 14 days'} + `( + 'should render summary for $recentlyFailed out of $failed failures', + ({ recentlyFailed, failed, expected }) => { + const result = utils.recentFailuresTextBuilder({ recentlyFailed, failed }); + + expect(result).toBe(expected); + }, + ); + }); + + describe('countRecentlyFailedTests', () => { + it('counts tests with more than one recent failure in a report', () => { + const report = { + new_failures: [{ recent_failures: { count: 2 } }], + existing_failures: [{ recent_failures: { count: 1 } }], + resolved_failures: [{ recent_failures: { count: 20 } }, { recent_failures: { count: 5 } }], + }; + const result = utils.countRecentlyFailedTests(report); + + expect(result).toBe(3); + }); + + it('counts tests with more than one recent failure in an array of reports', () => { + const reports = [ + { + new_failures: [{ recent_failures: { count: 2 } }], + existing_failures: [ + { recent_failures: { count: 20 } }, + { recent_failures: { count: 5 } }, + ], + resolved_failures: [{ recent_failures: { count: 2 } }], + }, + { + new_failures: [{ recent_failures: { count: 8 } }, { recent_failures: { count: 14 } }], + existing_failures: [{ recent_failures: { count: 1 } }], + resolved_failures: [{ recent_failures: { count: 7 } }, { recent_failures: { count: 5 } }], + }, + ]; + const result = utils.countRecentlyFailedTests(reports); + + expect(result).toBe(8); + }); + + it.each([ + [], + {}, + null, + undefined, + { new_failures: undefined }, + [{ existing_failures: null }], + { resolved_failures: [{}] }, + [{ new_failures: [{ recent_failures: {} }] }], + ])('returns 0 when subject is %s', (subject) => { + const result = utils.countRecentlyFailedTests(subject); + + expect(result).toBe(0); + }); + }); + + describe('formatFilePath', () => { + it.each` + file | expected + ${'./test.js'} | ${'test.js'} + ${'/test.js'} | ${'test.js'} + ${'.//////////////test.js'} | ${'test.js'} + ${'test.js'} | ${'test.js'} + ${'mock/path./test.js'} | ${'mock/path./test.js'} + ${'./mock/path./test.js'} | ${'mock/path./test.js'} + `('should format $file to be $expected', ({ file, expected }) => { + expect(utils.formatFilePath(file)).toBe(expected); + }); + }); +}); 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 new file mode 100644 index 00000000000..a06ad930abe --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extentions/accessibility/index_spec.js @@ -0,0 +1,127 @@ +import MockAdapter from 'axios-mock-adapter'; +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 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 { accessibilityReportResponseErrors, accessibilityReportResponseSuccess } from './mock_data'; + +describe('Accessibility extension', () => { + let wrapper; + let mock; + + registerExtension(accessibilityExtension); + + const endpoint = '/root/repo/-/merge_requests/4/accessibility_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 createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + accessibilityReportPath: endpoint, + }, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + it('displays loading text', () => { + mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors); + + createComponent(); + + expect(wrapper.text()).toBe('Accessibility scanning results are being parsed'); + }); + + it('displays failed loading text', async () => { + mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe('Accessibility scanning failed loading results'); + }); + + it('displays detected errors and is expandable', async () => { + mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe( + 'Accessibility scanning detected 8 issues for the source branch only', + ); + expect(findToggleCollapsedButton().exists()).toBe(true); + }); + + it('displays no detected errors and is not expandable', async () => { + mockApi(httpStatusCodes.OK, accessibilityReportResponseSuccess); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe( + 'Accessibility scanning detected no issues for the source branch only', + ); + expect(findToggleCollapsedButton().exists()).toBe(false); + }); + }); + + describe('expanded data', () => { + beforeEach(async () => { + mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors); + + createComponent(); + + await waitForPromises(); + + findToggleCollapsedButton().trigger('click'); + + await waitForPromises(); + }); + + it('displays all report list items in viewport', async () => { + expect(findAllExtensionListItems()).toHaveLength(7); + }); + + it('displays report list item formatted', () => { + const text = { + newError: trimText(findAllExtensionListItems().at(0).text()), + resolvedError: trimText(findAllExtensionListItems().at(3).text()), + existingError: trimText(findAllExtensionListItems().at(6).text()), + }; + + expect(text.newError).toBe( + 'New The accessibility scanning found an error of the following type: WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1 Learn more Message: Iframe element requires a non-empty title attribute that identifies the frame.', + ); + expect(text.resolvedError).toBe( + 'The accessibility scanning found an error of the following type: WCAG2AA.Principle1.Guideline1_1.1_1_1.H30.2 Learn more Message: Img element is the only content of the link, but is missing alt text. The alt text should describe the purpose of the link.', + ); + expect(text.existingError).toBe( + 'The accessibility scanning found an error of the following type: WCAG2AA.Principle1.Guideline1_1.1_1_1.H37 Learn more Message: Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.', + ); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/extentions/accessibility/mock_data.js b/spec/frontend/vue_merge_request_widget/extentions/accessibility/mock_data.js new file mode 100644 index 00000000000..06dc93d101f --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extentions/accessibility/mock_data.js @@ -0,0 +1,137 @@ +export const accessibilityReportResponseErrors = { + status: 'failed', + new_errors: [ + { + code: 'WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1', + type: 'error', + type_code: 1, + message: 'Iframe element requires a non-empty title attribute that identifies the frame.', + context: + '<iframe height="0" width="0" style="display: none; visibility: hidden;" src="//10421980.fls.doubleclick.net/activityi;src=10421980;type=count0;cat=globa0;ord=6271888671448;gtm=2wg1c0;auiddc=40010797.1642181125;u1=undefined;u2=undefined;u3=undefined;u...', + selector: 'html > body > iframe:nth-child(42)', + runner: 'htmlcs', + runner_extras: {}, + }, + { + code: 'WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2', + type: 'error', + type_code: 1, + message: + 'This form does not contain a submit button, which creates issues for those who cannot submit the form using the keyboard. Submit buttons are INPUT elements with type attribute "submit" or "image", or BUTTON elements with type "submit" or omitted/invalid.', + context: + '<form class="challenge-form" id="challenge-form" action="/users/sign_in?__cf_chl_jschl_tk__=xoagAHj9DXTTDveypAmMkakkNQgeWc6LmZA53YyDeSg-1642181129-0-gaNycGzNB1E" method="POST" enctype="application/x-www-form-urlencoded">\n <input type="hidden" name...', + selector: '#challenge-form', + runner: 'htmlcs', + runner_extras: {}, + }, + { + code: 'WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1', + type: 'error', + type_code: 1, + message: 'Iframe element requires a non-empty title attribute that identifies the frame.', + context: '<iframe style="display: none;"></iframe>', + selector: 'html > body > iframe', + runner: 'htmlcs', + runner_extras: {}, + }, + ], + resolved_errors: [ + { + code: 'WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1', + type: 'error', + type_code: 1, + message: 'Iframe element requires a non-empty title attribute that identifies the frame.', + context: + '<iframe height="0" width="0" style="display: none; visibility: hidden;" src="//10421980.fls.doubleclick.net/activityi;src=10421980;type=count0;cat=globa0;ord=6722452746146;gtm=2wg1a0;auiddc=716711306.1642082367;u1=undefined;u2=undefined;u3=undefined;...', + selector: 'html > body > iframe:nth-child(42)', + runner: 'htmlcs', + runner_extras: {}, + }, + { + code: 'WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2', + type: 'error', + type_code: 1, + message: + 'This form does not contain a submit button, which creates issues for those who cannot submit the form using the keyboard. Submit buttons are INPUT elements with type attribute "submit" or "image", or BUTTON elements with type "submit" or omitted/invalid.', + context: + '<form class="challenge-form" id="challenge-form" action="/users/sign_in?__cf_chl_jschl_tk__=vDKZT2hjxWCstlWz2wtxsLdqLF79rM4IsoxzMgY6Lfw-1642082370-0-gaNycGzNB2U" method="POST" enctype="application/x-www-form-urlencoded">\n <input type="hidden" name...', + selector: '#challenge-form', + runner: 'htmlcs', + runner_extras: {}, + }, + ], + existing_errors: [ + { + code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H30.2', + type: 'error', + type_code: 1, + message: + 'Img element is the only content of the link, but is missing alt text. The alt text should describe the purpose of the link.', + context: '<a href="/" data-nav="logo">\n<img src="/images/icons/logos/...</a>', + selector: '#navigation-mobile > header > a', + runner: 'htmlcs', + runner_extras: {}, + }, + { + code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H37', + type: 'error', + type_code: 1, + message: + 'Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.', + context: '<img src="/images/icons/slp-hamburger.svg" class="slp-inline-block slp-mr-8">', + selector: '#slpMobileNavActive > img', + runner: 'htmlcs', + runner_extras: {}, + }, + { + code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H37', + type: 'error', + type_code: 1, + message: + 'Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.', + context: '<img src="/images/icons/slp-caret-down.svg">', + selector: '#navigation-mobile > div:nth-child(2) > div:nth-child(2) > button > div > img', + runner: 'htmlcs', + runner_extras: {}, + }, + { + code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H37', + type: 'error', + type_code: 1, + message: + 'Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.', + context: '<img src="/images/icons/slp-caret-down.svg">', + selector: '#navigation-mobile > div:nth-child(2) > div:nth-child(3) > button > div > img', + runner: 'htmlcs', + runner_extras: {}, + }, + { + code: 'WCAG2AA.Principle1.Guideline1_1.1_1_1.H37', + type: 'error', + type_code: 1, + message: + 'Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.', + context: '<img src="/images/icons/slp-caret-down.svg">', + selector: '#navigation-mobile > div:nth-child(2) > div:nth-child(4) > button > div > img', + runner: 'htmlcs', + runner_extras: {}, + }, + ], + summary: { + total: 8, + resolved: 2, + errored: 8, + }, +}; + +export const accessibilityReportResponseSuccess = { + status: 'success', + new_errors: [], + resolved_errors: [], + existing_errors: [], + summary: { + total: 0, + resolved: 0, + errored: 0, + }, +}; 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 new file mode 100644 index 00000000000..9a72e4a086b --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js @@ -0,0 +1,145 @@ +import MockAdapter from 'axios-mock-adapter'; +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 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 from '~/lib/utils/http_status'; +import { + codeQualityResponseNewErrors, + codeQualityResponseResolvedErrors, + codeQualityResponseResolvedAndNewErrors, + codeQualityResponseNoErrors, +} from './mock_data'; + +describe('Code Quality extension', () => { + let wrapper; + let mock; + + registerExtension(codeQualityExtension); + + const endpoint = '/root/repo/-/merge_requests/4/accessibility_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 createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + codeQuality: endpoint, + blobPath: { + head_path: 'example/path', + base_path: 'example/path', + }, + }, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + it('displays loading text', () => { + mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors); + + createComponent(); + + expect(wrapper.text()).toBe('Code Quality test metrics results are being parsed'); + }); + + it('displays failed loading text', async () => { + mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + + createComponent(); + + await waitForPromises(); + expect(wrapper.text()).toBe('Code Quality failed loading results'); + }); + + it('displays quality degradation', async () => { + mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe('Code Quality degraded on 2 points.'); + }); + + it('displays quality improvement', async () => { + mockApi(httpStatusCodes.OK, codeQualityResponseResolvedErrors); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe('Code Quality improved on 2 points.'); + }); + + it('displays quality improvement and degradation', async () => { + mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe('Code Quality improved on 1 point and degraded on 1 point.'); + }); + + it('displays no detected errors', async () => { + mockApi(httpStatusCodes.OK, codeQualityResponseNoErrors); + + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toBe('No changes to Code Quality.'); + }); + }); + + describe('expanded data', () => { + beforeEach(async () => { + mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors); + + createComponent(); + + await waitForPromises(); + + findToggleCollapsedButton().trigger('click'); + + await waitForPromises(); + }); + + it('displays all report list items in viewport', async () => { + expect(findAllExtensionListItems()).toHaveLength(2); + }); + + it('displays report list item formatted', () => { + const text = { + newError: trimText(findAllExtensionListItems().at(0).text().replace(/\s+/g, ' ').trim()), + resolvedError: findAllExtensionListItems().at(1).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", + ); + }); + }); +}); 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 new file mode 100644 index 00000000000..f5ad0ce7377 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js @@ -0,0 +1,87 @@ +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: 2, + resolved: 0, + errored: 2, + }, +}; + +export const codeQualityResponseResolvedErrors = { + status: 'failed', + 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: 2, + resolved: 2, + errored: 0, + }, +}; + +export const codeQualityResponseResolvedAndNewErrors = { + status: 'failed', + new_errors: [ + { + description: "Parsing error: 'return' outside of function", + severity: 'minor', + file_path: 'index.js', + line: 12, + }, + ], + resolved_errors: [ + { + description: "Parsing error: 'return' outside of function", + severity: 'minor', + file_path: 'index.js', + line: 12, + }, + ], + existing_errors: [], + summary: { + total: 2, + resolved: 1, + errored: 1, + }, +}; + +export const codeQualityResponseNoErrors = { + status: 'failed', + new_errors: [], + resolved_errors: [], + existing_errors: [], + summary: { + total: 0, + resolved: 0, + errored: 0, + }, +}; diff --git a/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js new file mode 100644 index 00000000000..d9faa7b2d25 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/extentions/terraform/index_spec.js @@ -0,0 +1,192 @@ +import MockAdapter from 'axios-mock-adapter'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; +import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; +import terraformExtension from '~/vue_merge_request_widget/extensions/terraform'; +import { + plans, + validPlanWithName, + validPlanWithoutName, + invalidPlanWithName, + invalidPlanWithoutName, +} from '../../components/terraform/mock_data'; + +jest.mock('~/api.js'); + +describe('Terraform extension', () => { + let wrapper; + let mock; + + const endpoint = '/path/to/terraform/report.json'; + const successStatusCode = 200; + const errorStatusCode = 500; + + const findListItem = (at) => wrapper.findAllByTestId('extension-list-item').at(at); + + registerExtension(terraformExtension); + + const mockPollingApi = (response, body, header) => { + mock.onGet(endpoint).reply(response, body, header); + }; + + const createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + terraformReportsPath: endpoint, + }, + }, + }); + return axios.waitForAll(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + describe('while loading', () => { + const loadingText = 'Loading Terraform reports...'; + it('should render loading text', async () => { + mockPollingApi(successStatusCode, plans, {}); + createComponent(); + + expect(wrapper.text()).toContain(loadingText); + await waitForPromises(); + expect(wrapper.text()).not.toContain(loadingText); + }); + }); + + describe('when the fetching fails', () => { + beforeEach(() => { + mockPollingApi(errorStatusCode, null, {}); + return createComponent(); + }); + + it('should generate one invalid plan and render correct summary text', () => { + expect(wrapper.text()).toContain('1 Terraform report failed to generate'); + }); + }); + + describe('when the fetching succeeds', () => { + describe.each` + responseType | response | summaryTitle | summarySubtitle + ${'1 invalid report'} | ${{ 0: invalidPlanWithName }} | ${'1 Terraform report failed to generate'} | ${''} + ${'2 valid reports'} | ${{ 0: validPlanWithName, 1: validPlanWithName }} | ${'2 Terraform reports were generated in your pipelines'} | ${''} + ${'1 valid and 2 invalid reports'} | ${{ 0: validPlanWithName, 1: invalidPlanWithName, 2: invalidPlanWithName }} | ${'Terraform report was generated in your pipelines'} | ${'2 Terraform reports failed to generate'} + `('and received $responseType', ({ response, summaryTitle, summarySubtitle }) => { + beforeEach(async () => { + mockPollingApi(successStatusCode, response, {}); + return createComponent(); + }); + + it(`should render correct summary text`, () => { + expect(wrapper.text()).toContain(summaryTitle); + + if (summarySubtitle) { + expect(wrapper.text()).toContain(summarySubtitle); + } + }); + }); + }); + }); + + describe('expanded data', () => { + beforeEach(async () => { + mockPollingApi(successStatusCode, plans, {}); + await createComponent(); + + wrapper.findByTestId('toggle-button').trigger('click'); + }); + + describe.each` + reportType | title | subtitle | logLink | lineNumber + ${'a valid report with name'} | ${`The job ${validPlanWithName.job_name} generated a report.`} | ${`Reported Resource Changes: ${validPlanWithName.create} to add, ${validPlanWithName.update} to change, ${validPlanWithName.delete} to delete`} | ${validPlanWithName.job_path} | ${0} + ${'a valid report without name'} | ${'A Terraform report was generated in your pipelines.'} | ${`Reported Resource Changes: ${validPlanWithoutName.create} to add, ${validPlanWithoutName.update} to change, ${validPlanWithoutName.delete} to delete`} | ${validPlanWithoutName.job_path} | ${1} + ${'an invalid report with name'} | ${`The job ${invalidPlanWithName.job_name} failed to generate a report.`} | ${'Generating the report caused an error.'} | ${invalidPlanWithName.job_path} | ${2} + ${'an invalid report without name'} | ${'A Terraform report failed to generate.'} | ${'Generating the report caused an error.'} | ${invalidPlanWithoutName.job_path} | ${3} + `('renders correct text for $reportType', ({ title, subtitle, logLink, lineNumber }) => { + it('renders correct text', () => { + expect(findListItem(lineNumber).text()).toContain(title); + expect(findListItem(lineNumber).text()).toContain(subtitle); + }); + + it(`${logLink ? 'renders' : "doesn't render"} the log link`, () => { + const logText = 'Full log'; + if (logLink) { + expect( + findListItem(lineNumber) + .find('[data-testid="extension-actions-button"]') + .attributes('href'), + ).toBe(logLink); + } else { + expect(findListItem(lineNumber).text()).not.toContain(logText); + } + }); + }); + + it('responds with the correct telemetry when the deeply nested "Full log" link is clicked', () => { + api.trackRedisHllUserEvent.mockClear(); + api.trackRedisCounterEvent.mockClear(); + + findListItem(0).find('[data-testid="extension-actions-button"]').trigger('click'); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_terraform_click_full_report', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_terraform_count_click_full_report', + ); + }); + }); + + describe('polling', () => { + let pollRequest; + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + }); + + afterEach(() => { + pollRequest.mockRestore(); + }); + + describe('successful poll', () => { + beforeEach(() => { + mockPollingApi(successStatusCode, plans, {}); + + return createComponent(); + }); + + it('does not make additional requests after poll is successful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + }); + }); + + describe('polling fails', () => { + beforeEach(() => { + mockPollingApi(errorStatusCode, null, {}); + return createComponent(); + }); + + it('generates one broken plan', () => { + expect(wrapper.text()).toContain('1 Terraform report failed to generate'); + }); + + it('does not make additional requests after poll is unsuccessful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/mock_data.js b/spec/frontend/vue_merge_request_widget/mock_data.js new file mode 100644 index 00000000000..20d00a116bb --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/mock_data.js @@ -0,0 +1,358 @@ +import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; + +export const artifacts = [ + { + text: 'result.txt', + url: 'bar', + job_name: 'generate-artifact', + job_path: 'bar', + }, + { + text: 'foo.txt', + url: 'foo', + job_name: 'foo-artifact', + job_path: 'foo', + }, +]; + +export default { + id: 132, + iid: 22, + assignee_id: null, + author_id: 1, + description: '', + lock_version: null, + milestone_id: null, + position: 0, + state: 'merged', + title: 'Update README.md', + updated_by_id: null, + created_at: '2017-04-07T12:27:26.718Z', + updated_at: '2017-04-07T15:39:25.852Z', + time_estimate: 0, + total_time_spent: 0, + human_access: 'Maintainer', + human_time_estimate: null, + human_total_time_spent: null, + in_progress_merge_commit_sha: null, + merge_commit_sha: '53027d060246c8f47e4a9310fb332aa52f221775', + short_merge_commit_sha: '53027d06', + merge_error: null, + merge_params: { + force_remove_source_branch: null, + }, + merge_status: 'can_be_merged', + merge_user_id: null, + pipelines_empty_svg_path: '/path/to/svg', + source_branch: 'daaaa', + source_branch_link: 'daaaa', + source_project_id: 19, + source_project_full_path: '/group1/project1', + target_branch: 'main', + target_project_id: 19, + target_project_full_path: '/group2/project2', + merge_request_add_ci_config_path: '/root/group2/project2/-/ci/editor', + is_dismissed_suggest_pipeline: false, + user_callouts_path: 'some/callout/path', + suggest_pipeline_feature_id: 'suggest_pipeline', + new_project_pipeline_path: '/group2/project2/pipelines/new', + source_project_default_url: '/gitlab-org/html5-boilerplate.git', + metrics: { + merged_by: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', + }, + merged_at: '2017-04-07T15:39:25.696Z', + closed_by: null, + closed_at: null, + }, + author: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', + }, + merge_user: null, + diff_head_sha: '104096c51715e12e7ae41f9333e9fa35b73f385d', + diff_head_commit_short_id: '104096c5', + default_merge_commit_message: + "Merge branch 'daaaa' into 'main'\n\nUpdate README.md\n\nSee merge request !22", + pipeline: { + id: 172, + user: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', + }, + active: false, + coverage: '92.16', + path: '/root/acets-app/pipelines/172', + details: { + artifacts, + status: { + icon: 'status_success', + favicon: 'favicon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/acets-app/pipelines/172', + }, + duration: null, + finished_at: '2017-04-07T14:00:14.256Z', + stages: [ + { + name: 'build', + title: 'build: failed', + status: { + icon: 'status_failed', + favicon: 'favicon_status_failed', + text: 'failed', + label: 'failed', + group: 'failed', + has_details: true, + details_path: '/root/acets-app/pipelines/172#build', + }, + path: '/root/acets-app/pipelines/172#build', + dropdown_path: '/root/acets-app/pipelines/172/stage.json?stage=build', + }, + { + name: 'review', + title: 'review: skipped', + status: { + icon: 'status_skipped', + favicon: 'favicon_status_skipped', + text: 'skipped', + label: 'skipped', + group: 'skipped', + has_details: true, + details_path: '/root/acets-app/pipelines/172#review', + }, + path: '/root/acets-app/pipelines/172#review', + dropdown_path: '/root/acets-app/pipelines/172/stage.json?stage=review', + }, + ], + manual_actions: [ + { + name: 'stop_review', + path: '/root/acets-app/builds/1427/play', + playable: false, + }, + ], + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: false, + merge_request_pipeline: false, + detached_merge_request_pipeline: true, + }, + ref: { + name: 'daaaa', + path: '/root/acets-app/tree/daaaa', + tag: false, + branch: true, + }, + merge_request: { + iid: 1, + path: '/root/detached-merge-request-pipelines/-/merge_requests/1', + title: 'Update README.md', + source_branch: 'feature-1', + source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1', + target_branch: 'main', + target_branch_path: '/root/detached-merge-request-pipelines/branches/main', + }, + commit: { + id: '104096c51715e12e7ae41f9333e9fa35b73f385d', + short_id: '104096c5', + title: 'Update README.md', + created_at: '2017-04-07T15:27:18.000+03:00', + parent_ids: ['2396536178668d8930c29d904e53bd4d06228b32'], + message: 'Update README.md', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2017-04-07T15:27:18.000+03:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2017-04-07T15:27:18.000+03:00', + author: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', + }, + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commit_url: + 'http://localhost:3000/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d', + commit_path: '/root/acets-app/commit/104096c51715e12e7ae41f9333e9fa35b73f385d', + }, + retry_path: '/root/acets-app/pipelines/172/retry', + created_at: '2017-04-07T12:27:19.520Z', + updated_at: '2017-04-07T15:28:44.800Z', + }, + pipelineCoverageDelta: '15.25', + buildsWithCoverage: [ + { name: 'karma', coverage: '40.2' }, + { name: 'rspec', coverage: '80.4' }, + ], + work_in_progress: false, + source_branch_exists: false, + mergeable_discussions_state: true, + conflicts_can_be_resolved_in_ui: false, + branch_missing: true, + commits_count: 1, + has_conflicts: false, + can_be_merged: true, + has_ci: true, + ci_status: 'success', + pipeline_status_path: '/root/acets-app/-/merge_requests/22/pipeline_status', + issues_links: { + closing: '', + mentioned_but_not_closing: '', + }, + current_user: { + can_resolve_conflicts: true, + can_remove_source_branch: false, + can_revert_on_current_merge_request: true, + can_cherry_pick_on_current_merge_request: true, + }, + blob_path: { + base_path: 'blob_path', + head_path: 'blob_path', + }, + codequality_reports_path: 'codequality_reports.json', + codequality_help_path: 'code_quality.html', + target_branch_path: '/root/acets-app/branches/main', + source_branch_path: '/root/acets-app/branches/daaaa', + conflict_resolution_ui_path: '/root/acets-app/-/merge_requests/22/conflicts', + remove_wip_path: '/root/acets-app/-/merge_requests/22/remove_wip', + cancel_auto_merge_path: '/root/acets-app/-/merge_requests/22/cancel_auto_merge', + create_issue_to_resolve_discussions_path: + '/root/acets-app/-/issues/new?merge_request_to_resolve_discussions_of=22', + merge_path: '/root/acets-app/-/merge_requests/22/merge', + cherry_pick_in_fork_path: + '/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+revert+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1', + revert_in_fork_path: + '/root/acets-app/forks?continue%5Bnotice%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+has+been+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.+Try+to+cherry-pick+this+commit+again.&continue%5Bnotice_now%5D=You%27re+not+allowed+to+make+changes+to+this+project+directly.+A+fork+of+this+project+is+being+created+that+you+can+make+changes+in%2C+so+you+can+submit+a+merge+request.&continue%5Bto%5D=%2Froot%2Facets-app%2Fmerge_requests%2F22&namespace_key=1', + email_patches_path: '/root/acets-app/-/merge_requests/22.patch', + plain_diff_path: '/root/acets-app/-/merge_requests/22.diff', + merge_request_basic_path: '/root/acets-app/-/merge_requests/22.json?serializer=basic', + merge_request_widget_path: '/root/acets-app/-/merge_requests/22/widget.json', + merge_request_cached_widget_path: '/cached.json', + merge_check_path: '/root/acets-app/-/merge_requests/22/merge_check', + ci_environments_status_url: '/root/acets-app/-/merge_requests/22/ci_environments_status', + project_archived: false, + default_merge_commit_message_with_description: + "Merge branch 'daaaa' into 'main'\n\nUpdate README.md\n\nSee merge request !22", + default_squash_commit_message: 'Test squash commit message', + diverged_commits_count: 0, + only_allow_merge_if_pipeline_succeeds: false, + commit_change_content_path: '/root/acets-app/-/merge_requests/22/commit_change_content', + merge_commit_path: + 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', + mr_troubleshooting_docs_path: 'help', + ci_troubleshooting_docs_path: 'help2', + merge_request_pipelines_docs_path: '/help/ci/pipelines/merge_request_pipelines.md', + squash: true, + visual_review_app_available: true, + merge_trains_enabled: true, + merge_trains_count: 3, + merge_train_index: 1, + security_reports_docs_path: 'security-reports-docs-path', + sast_comparison_path: '/sast_comparison_path', + secret_detection_comparison_path: '/secret_detection_comparison_path', + gitpod_enabled: true, + show_gitpod_button: true, + gitpod_url: 'http://gitpod.localhost', + user_preferences_gitpod_path: '/-/profile/preferences#user_gitpod_enabled', + user_profile_enable_gitpod_path: '/-/profile?user%5Bgitpod_enabled%5D=true', +}; + +export const mockStore = { + pipeline: { + id: 0, + details: { + artifacts, + status: { + details_path: '/root/review-app-tester/pipelines/66', + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2. png', + group: 'success-with-warnings', + has_details: true, + icon: 'status_warning', + illustration: null, + label: 'passed with warnings', + text: 'passed', + tooltip: 'passed', + }, + }, + flags: {}, + ref: {}, + }, + mergePipeline: { + id: 1, + details: { + artifacts, + status: { + details_path: '/root/review-app-tester/pipelines/66', + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2. png', + group: 'success-with-warnings', + has_details: true, + icon: 'status_warning', + illustration: null, + label: 'passed with warnings', + text: 'passed', + tooltip: 'passed', + }, + }, + flags: {}, + ref: {}, + }, + targetBranch: 'target-branch', + sourceBranch: 'source-branch', + sourceBranchLink: 'source-branch-link', + deployments: [ + { + id: 0, + name: 'bogus', + external_url: 'https://fake.com', + external_url_formatted: 'https://fake.com', + status: SUCCESS, + }, + { + id: 1, + name: 'bogus-docs', + external_url: 'https://fake.com', + external_url_formatted: 'https://fake.com', + status: SUCCESS, + }, + ], + postMergeDeployments: [ + { id: 0, name: 'prod', status: SUCCESS }, + { id: 1, name: 'prod-docs', status: SUCCESS }, + ], + mrTroubleshootingDocsPath: 'mr-troubleshooting-docs-path', + ciTroubleshootingDocsPath: 'ci-troubleshooting-docs-path', + ciStatus: 'ci-status', + hasCI: true, + exposedArtifactsPath: 'exposed_artifacts.json', +}; 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 new file mode 100644 index 00000000000..295b9df30b9 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/mr_widget_how_to_merge_modal_spec.js @@ -0,0 +1,70 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue'; + +describe('MRWidgetHowToMerge', () => { + let wrapper; + + function mountComponent({ data = {}, props = {} } = {}) { + wrapper = shallowMount(MrWidgetHowToMergeModal, { + data() { + return data; + }, + propsData: props, + stubs: {}, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + mountComponent(); + }); + + const findModal = () => wrapper.find(GlModal); + const findInstructionsFields = () => + wrapper.findAll('[ data-testid="how-to-merge-instructions"]'); + const findTipLink = () => wrapper.find("[data-testid='docs-tip']"); + + it('renders a modal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders a selection of markdown fields', () => { + expect(findInstructionsFields().length).toBe(3); + }); + + it('renders a tip including a link to docs when a valid link is present', () => { + mountComponent({ props: { reviewingDocsPath: '/gitlab-org/help' } }); + expect(findTipLink().exists()).toBe(true); + }); + + it('should not render a tip including a link to docs when a valid link is not present', () => { + expect(findTipLink().exists()).toBe(false); + }); + + 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\''); + }); + + 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_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js new file mode 100644 index 00000000000..819841317f9 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -0,0 +1,1198 @@ +import { GlBadge, GlLink, GlIcon, GlButton, GlDropdown } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '@sentry/browser'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; +import api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; +import { setFaviconOverlay } from '~/lib/utils/favicon'; +import notify from '~/lib/utils/notify'; +import SmartInterval from '~/smart_interval'; +import { + registerExtension, + registeredExtensions, +} from '~/vue_merge_request_widget/components/extensions'; +import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; +import eventHub from '~/vue_merge_request_widget/event_hub'; +import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; +import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; +import mockData from './mock_data'; +import { + workingExtension, + collapsedDataErrorExtension, + fullDataErrorExtension, + fullReportExtension, + noTelemetryExtension, + pollingExtension, + pollingFullDataExtension, + pollingErrorExtension, + multiPollingExtension, +} from './test_extensions'; + +jest.mock('~/api.js'); + +jest.mock('~/smart_interval'); + +jest.mock('~/lib/utils/favicon'); + +jest.mock('@sentry/browser', () => ({ + setExtra: jest.fn(), + setExtras: jest.fn(), + captureMessage: jest.fn(), + captureException: jest.fn(), +})); + +Vue.use(VueApollo); + +describe('MrWidgetOptions', () => { + let wrapper; + let mock; + + const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; + const findExtensionToggleButton = () => + wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); + const findExtensionLink = (linkHref) => + wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`); + + beforeEach(() => { + gl.mrWidgetData = { ...mockData }; + gon.features = { asyncMrWidget: true }; + + mock = new MockAdapter(axios); + mock.onGet(mockData.merge_request_widget_path).reply(() => [200, { ...mockData }]); + mock.onGet(mockData.merge_request_cached_widget_path).reply(() => [200, { ...mockData }]); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + + gl.mrWidgetData = {}; + gon.features = {}; + }); + + const createComponent = (mrData = mockData, options = {}) => { + wrapper = mount(MrWidgetOptions, { + propsData: { + mrData: { ...mrData }, + }, + ...options, + }); + + return axios.waitForAll(); + }; + + const findSuggestPipeline = () => wrapper.find('[data-testid="mr-suggest-pipeline"]'); + const findSuggestPipelineButton = () => findSuggestPipeline().find('button'); + const findSecurityMrWidget = () => wrapper.find('[data-testid="security-mr-widget"]'); + + describe('default', () => { + beforeEach(() => { + jest.spyOn(document, 'dispatchEvent'); + return createComponent(); + }); + + describe('data', () => { + it('should instantiate Store and Service', () => { + expect(wrapper.vm.mr).toBeDefined(); + expect(wrapper.vm.service).toBeDefined(); + }); + }); + + describe('computed', () => { + describe('componentName', () => { + it.each` + state | componentName + ${'merged'} | ${'mr-widget-merged'} + ${'conflicts'} | ${'mr-widget-conflicts'} + ${'shaMismatch'} | ${'sha-mismatch'} + `('should translate $state into $componentName', ({ state, componentName }) => { + wrapper.vm.mr.state = state; + + expect(wrapper.vm.componentName).toEqual(componentName); + }); + }); + + describe('shouldRenderPipelines', () => { + it('should return true when hasCI is true', () => { + wrapper.vm.mr.hasCI = true; + + expect(wrapper.vm.shouldRenderPipelines).toBe(true); + }); + + it('should return false when hasCI is false', () => { + wrapper.vm.mr.hasCI = false; + + expect(wrapper.vm.shouldRenderPipelines).toBe(false); + }); + }); + + describe('shouldRenderSourceBranchRemovalStatus', () => { + beforeEach(() => { + wrapper.vm.mr.state = 'readyToMerge'; + }); + + it('should return true when cannot remove source branch and branch will be removed', () => { + wrapper.vm.mr.canRemoveSourceBranch = false; + wrapper.vm.mr.shouldRemoveSourceBranch = true; + + expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(true); + }); + + it('should return false when can remove source branch and branch will be removed', () => { + wrapper.vm.mr.canRemoveSourceBranch = true; + wrapper.vm.mr.shouldRemoveSourceBranch = true; + + expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); + }); + + it('should return false when cannot remove source branch and branch will not be removed', () => { + wrapper.vm.mr.canRemoveSourceBranch = false; + wrapper.vm.mr.shouldRemoveSourceBranch = false; + + expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); + }); + + it('should return false when in merged state', () => { + wrapper.vm.mr.canRemoveSourceBranch = false; + wrapper.vm.mr.shouldRemoveSourceBranch = true; + wrapper.vm.mr.state = 'merged'; + + expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); + }); + + it('should return false when in nothing to merge state', () => { + wrapper.vm.mr.canRemoveSourceBranch = false; + wrapper.vm.mr.shouldRemoveSourceBranch = true; + wrapper.vm.mr.state = 'nothingToMerge'; + + expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); + }); + }); + + describe('shouldRenderCollaborationStatus', () => { + describe('when collaboration is allowed', () => { + beforeEach(() => { + wrapper.vm.mr.allowCollaboration = true; + }); + + describe('when merge request is opened', () => { + beforeEach(() => { + wrapper.vm.mr.isOpen = true; + return nextTick(); + }); + + it('should render collaboration status', () => { + expect(wrapper.text()).toContain(COLLABORATION_MESSAGE); + }); + }); + + describe('when merge request is not opened', () => { + beforeEach(() => { + wrapper.vm.mr.isOpen = false; + return nextTick(); + }); + + it('should not render collaboration status', () => { + expect(wrapper.text()).not.toContain(COLLABORATION_MESSAGE); + }); + }); + }); + + describe('when collaboration is not allowed', () => { + beforeEach(() => { + wrapper.vm.mr.allowCollaboration = false; + }); + + describe('when merge request is opened', () => { + beforeEach(() => { + wrapper.vm.mr.isOpen = true; + return nextTick(); + }); + + it('should not render collaboration status', () => { + expect(wrapper.text()).not.toContain(COLLABORATION_MESSAGE); + }); + }); + }); + }); + + describe('showMergePipelineForkWarning', () => { + describe('when the source project and target project are the same', () => { + beforeEach(() => { + Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true); + Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); + Vue.set(wrapper.vm.mr, 'targetProjectId', 1); + return nextTick(); + }); + + it('should be false', () => { + expect(wrapper.vm.showMergePipelineForkWarning).toEqual(false); + }); + }); + + describe('when merge pipelines are not enabled', () => { + beforeEach(() => { + Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', false); + Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); + Vue.set(wrapper.vm.mr, 'targetProjectId', 2); + return nextTick(); + }); + + it('should be false', () => { + expect(wrapper.vm.showMergePipelineForkWarning).toEqual(false); + }); + }); + + describe('when merge pipelines are enabled _and_ the source project and target project are different', () => { + beforeEach(() => { + Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true); + Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); + Vue.set(wrapper.vm.mr, 'targetProjectId', 2); + return nextTick(); + }); + + it('should be true', () => { + expect(wrapper.vm.showMergePipelineForkWarning).toEqual(true); + }); + }); + }); + + describe('formattedHumanAccess', () => { + it('when user is a tool admin but not a member of project', () => { + wrapper.vm.mr.humanAccess = null; + + expect(wrapper.vm.formattedHumanAccess).toEqual(''); + }); + + it('when user a member of the project', () => { + wrapper.vm.mr.humanAccess = 'Owner'; + + expect(wrapper.vm.formattedHumanAccess).toEqual('owner'); + }); + }); + }); + + describe('methods', () => { + describe('checkStatus', () => { + let cb; + let isCbExecuted; + + beforeEach(() => { + jest.spyOn(wrapper.vm.service, 'checkStatus').mockResolvedValue({ data: mockData }); + jest.spyOn(wrapper.vm.mr, 'setData').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'handleNotification').mockImplementation(() => {}); + + isCbExecuted = false; + cb = () => { + isCbExecuted = true; + }; + }); + + it('should tell service to check status if document is visible', () => { + wrapper.vm.checkStatus(cb); + + return nextTick().then(() => { + expect(wrapper.vm.service.checkStatus).toHaveBeenCalled(); + expect(wrapper.vm.mr.setData).toHaveBeenCalled(); + expect(wrapper.vm.handleNotification).toHaveBeenCalledWith(mockData); + expect(isCbExecuted).toBe(true); + }); + }); + }); + + describe('initPolling', () => { + it('should call SmartInterval', () => { + wrapper.vm.initPolling(); + + expect(SmartInterval).toHaveBeenCalledWith( + expect.objectContaining({ + callback: wrapper.vm.checkStatus, + }), + ); + }); + }); + + describe('initDeploymentsPolling', () => { + it('should call SmartInterval', () => { + wrapper.vm.initDeploymentsPolling(); + + expect(SmartInterval).toHaveBeenCalledWith( + expect.objectContaining({ + callback: wrapper.vm.fetchPreMergeDeployments, + }), + ); + }); + }); + + describe('fetchDeployments', () => { + it('should fetch deployments', () => { + jest + .spyOn(wrapper.vm.service, 'fetchDeployments') + .mockResolvedValue({ data: [{ id: 1, status: SUCCESS }] }); + + wrapper.vm.fetchPreMergeDeployments(); + + return nextTick().then(() => { + expect(wrapper.vm.service.fetchDeployments).toHaveBeenCalled(); + expect(wrapper.vm.mr.deployments.length).toEqual(1); + expect(wrapper.vm.mr.deployments[0].id).toBe(1); + }); + }); + }); + + describe('fetchActionsContent', () => { + it('should fetch content of Cherry Pick and Revert modals', () => { + jest + .spyOn(wrapper.vm.service, 'fetchMergeActionsContent') + .mockResolvedValue({ data: 'hello world' }); + + wrapper.vm.fetchActionsContent(); + + return nextTick().then(() => { + expect(wrapper.vm.service.fetchMergeActionsContent).toHaveBeenCalled(); + expect(document.body.textContent).toContain('hello world'); + expect(document.dispatchEvent).toHaveBeenCalledWith( + new CustomEvent('merged:UpdateActions'), + ); + }); + }); + }); + + describe('bindEventHubListeners', () => { + it.each` + event | method | methodArgs + ${'MRWidgetUpdateRequested'} | ${'checkStatus'} | ${(x) => [x]} + ${'MRWidgetRebaseSuccess'} | ${'checkStatus'} | ${(x) => [x, true]} + ${'FetchActionsContent'} | ${'fetchActionsContent'} | ${() => []} + ${'EnablePolling'} | ${'resumePolling'} | ${() => []} + ${'DisablePolling'} | ${'stopPolling'} | ${() => []} + `('should bind to $event', ({ event, method, methodArgs }) => { + jest.spyOn(wrapper.vm, method).mockImplementation(); + + const eventArg = {}; + eventHub.$emit(event, eventArg); + + expect(wrapper.vm[method]).toHaveBeenCalledWith(...methodArgs(eventArg)); + }); + + it('should bind to SetBranchRemoveFlag', () => { + expect(wrapper.vm.mr.isRemovingSourceBranch).toBe(false); + + eventHub.$emit('SetBranchRemoveFlag', [true]); + + expect(wrapper.vm.mr.isRemovingSourceBranch).toBe(true); + }); + + it('should bind to FailedToMerge', () => { + wrapper.vm.mr.state = ''; + wrapper.vm.mr.mergeError = ''; + + const mergeError = 'Something bad happened!'; + eventHub.$emit('FailedToMerge', mergeError); + + expect(wrapper.vm.mr.state).toBe('failedToMerge'); + expect(wrapper.vm.mr.mergeError).toBe(mergeError); + }); + + it('should bind to UpdateWidgetData', () => { + jest.spyOn(wrapper.vm.mr, 'setData').mockImplementation(); + + const data = { ...mockData }; + eventHub.$emit('UpdateWidgetData', data); + + expect(wrapper.vm.mr.setData).toHaveBeenCalledWith(data); + }); + }); + + describe('setFavicon', () => { + let faviconElement; + + beforeEach(() => { + const favicon = document.createElement('link'); + favicon.setAttribute('id', 'favicon'); + favicon.dataset.originalHref = faviconDataUrl; + document.body.appendChild(favicon); + + faviconElement = document.getElementById('favicon'); + }); + + afterEach(() => { + document.body.removeChild(document.getElementById('favicon')); + }); + + it('should call setFavicon method', async () => { + wrapper.vm.mr.ciStatusFaviconPath = overlayDataUrl; + + await wrapper.vm.setFaviconHelper(); + + expect(setFaviconOverlay).toHaveBeenCalledWith(overlayDataUrl); + }); + + it('should not call setFavicon when there is no ciStatusFaviconPath', async () => { + wrapper.vm.mr.ciStatusFaviconPath = null; + await wrapper.vm.setFaviconHelper(); + expect(faviconElement.getAttribute('href')).toEqual(null); + }); + }); + + describe('handleNotification', () => { + const data = { + ci_status: 'running', + title: 'title', + pipeline: { details: { status: { label: 'running-label' } } }, + }; + + beforeEach(() => { + jest.spyOn(notify, 'notifyMe').mockImplementation(() => {}); + + wrapper.vm.mr.ciStatus = 'failed'; + wrapper.vm.mr.gitlabLogo = 'logo.png'; + }); + + it('should call notifyMe', () => { + wrapper.vm.handleNotification(data); + + expect(notify.notifyMe).toHaveBeenCalledWith( + 'Pipeline running-label', + 'Pipeline running-label for "title"', + 'logo.png', + ); + }); + + it('should not call notifyMe if the status has not changed', () => { + wrapper.vm.mr.ciStatus = data.ci_status; + + wrapper.vm.handleNotification(data); + + expect(notify.notifyMe).not.toHaveBeenCalled(); + }); + + it('should not notify if no pipeline provided', () => { + wrapper.vm.handleNotification({ + ...data, + pipeline: undefined, + }); + + expect(notify.notifyMe).not.toHaveBeenCalled(); + }); + }); + + describe('resumePolling', () => { + it('should call stopTimer on pollingInterval', () => { + jest.spyOn(wrapper.vm.pollingInterval, 'resume').mockImplementation(() => {}); + + wrapper.vm.resumePolling(); + + expect(wrapper.vm.pollingInterval.resume).toHaveBeenCalled(); + }); + }); + + describe('stopPolling', () => { + it('should call stopTimer on pollingInterval', () => { + jest.spyOn(wrapper.vm.pollingInterval, 'stopTimer').mockImplementation(() => {}); + + wrapper.vm.stopPolling(); + + expect(wrapper.vm.pollingInterval.stopTimer).toHaveBeenCalled(); + }); + }); + }); + + describe('rendering deployments', () => { + const changes = [ + { + path: 'index.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html', + }, + { + path: 'imgs/gallery.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', + }, + { + path: 'about/', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/', + }, + ]; + const deploymentMockData = { + id: 15, + name: 'review/diplo', + url: '/root/acets-review-apps/environments/15', + stop_url: '/root/acets-review-apps/environments/15/stop', + metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics', + external_url: 'http://diplo.', + external_url_formatted: 'diplo.', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + changes, + status: SUCCESS, + }; + + beforeEach(() => { + wrapper.vm.mr.deployments.push( + { + ...deploymentMockData, + }, + { + ...deploymentMockData, + id: deploymentMockData.id + 1, + }, + ); + + return nextTick(); + }); + + it('renders multiple deployments', () => { + expect(wrapper.findAll('.deploy-heading').length).toBe(2); + }); + + it('renders dropdpown with multiple file changes', () => { + expect( + wrapper.find('.js-mr-wigdet-deployment-dropdown').findAll('.js-filtered-dropdown-result') + .length, + ).toEqual(changes.length); + }); + }); + + describe('code quality widget', () => { + beforeEach(() => { + jest.spyOn(document, 'dispatchEvent'); + }); + it('renders the component when refactorCodeQualityExtension is false', () => { + createComponent(mockData, {}, { refactorCodeQualityExtension: false }); + expect(wrapper.find('.js-codequality-widget').exists()).toBe(true); + }); + + it('does not render the component when refactorCodeQualityExtension is true', () => { + createComponent(mockData, {}, { refactorCodeQualityExtension: true }); + expect(wrapper.find('.js-codequality-widget').exists()).toBe(true); + }); + }); + + describe('pipeline for target branch after merge', () => { + describe('with information for target branch pipeline', () => { + beforeEach(() => { + wrapper.vm.mr.state = 'merged'; + wrapper.vm.mr.mergePipeline = { + id: 127, + user: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + status_tooltip_html: null, + path: '/root', + }, + active: true, + coverage: null, + source: 'push', + created_at: '2018-10-22T11:41:35.186Z', + updated_at: '2018-10-22T11:41:35.433Z', + path: '/root/ci-web-terminal/pipelines/127', + flags: { + latest: true, + stuck: true, + auto_devops: false, + yaml_errors: false, + retryable: false, + cancelable: true, + failure_reason: false, + }, + details: { + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/root/ci-web-terminal/pipelines/127', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png', + }, + duration: null, + finished_at: null, + stages: [ + { + name: 'test', + title: 'test: pending', + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/root/ci-web-terminal/pipelines/127#test', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png', + }, + path: '/root/ci-web-terminal/pipelines/127#test', + dropdown_path: '/root/ci-web-terminal/pipelines/127/stage.json?stage=test', + }, + ], + artifacts: [], + manual_actions: [], + scheduled_actions: [], + }, + ref: { + name: 'main', + path: '/root/ci-web-terminal/commits/main', + tag: false, + branch: true, + }, + commit: { + id: 'aa1939133d373c94879becb79d91828a892ee319', + short_id: 'aa193913', + title: "Merge branch 'main-test' into 'main'", + created_at: '2018-10-22T11:41:33.000Z', + parent_ids: [ + '4622f4dd792468993003caf2e3be978798cbe096', + '76598df914cdfe87132d0c3c40f80db9fa9396a4', + ], + message: + "Merge branch 'main-test' into 'main'\n\nUpdate .gitlab-ci.yml\n\nSee merge request root/ci-web-terminal!1", + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2018-10-22T11:41:33.000Z', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2018-10-22T11:41:33.000Z', + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + status_tooltip_html: null, + path: '/root', + }, + author_gravatar_url: null, + commit_url: + 'http://localhost:3000/root/ci-web-terminal/commit/aa1939133d373c94879becb79d91828a892ee319', + commit_path: '/root/ci-web-terminal/commit/aa1939133d373c94879becb79d91828a892ee319', + }, + cancel_path: '/root/ci-web-terminal/pipelines/127/cancel', + }; + return nextTick(); + }); + + it('renders pipeline block', () => { + expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(true); + }); + + describe('with post merge deployments', () => { + beforeEach(() => { + wrapper.vm.mr.postMergeDeployments = [ + { + id: 15, + name: 'review/diplo', + url: '/root/acets-review-apps/environments/15', + stop_url: '/root/acets-review-apps/environments/15/stop', + metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics', + external_url: 'http://diplo.', + external_url_formatted: 'diplo.', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + changes: [ + { + path: 'index.html', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/index.html', + }, + { + path: 'imgs/gallery.html', + external_url: + 'http://root-main-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', + }, + { + path: 'about/', + external_url: 'http://root-main-patch-91341.volatile-watch.surge.sh/about/', + }, + ], + status: 'success', + }, + ]; + + return nextTick(); + }); + + it('renders post deployment information', () => { + expect(wrapper.find('.js-post-deployment').exists()).toBe(true); + }); + }); + }); + + describe('without information for target branch pipeline', () => { + beforeEach(() => { + wrapper.vm.mr.state = 'merged'; + + return nextTick(); + }); + + it('does not render pipeline block', () => { + expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(false); + }); + }); + + describe('when state is not merged', () => { + beforeEach(() => { + wrapper.vm.mr.state = 'archived'; + + return nextTick(); + }); + + it('does not render pipeline block', () => { + expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(false); + }); + + it('does not render post deployment information', () => { + expect(wrapper.find('.js-post-deployment').exists()).toBe(false); + }); + }); + }); + + it('should not suggest pipelines when feature flag is not present', () => { + expect(findSuggestPipeline().exists()).toBe(false); + }); + }); + + describe('security widget', () => { + describe.each` + context | hasPipeline | shouldRender + ${'there is a pipeline'} | ${true} | ${true} + ${'no pipeline'} | ${false} | ${false} + `('given $context', ({ hasPipeline, shouldRender }) => { + beforeEach(() => { + const mrData = { + ...mockData, + ...(hasPipeline ? {} : { pipeline: null }), + }; + + // Override top-level mocked requests, which always use a fresh copy of + // mockData, which always includes the full pipeline object. + mock.onGet(mockData.merge_request_widget_path).reply(() => [200, mrData]); + mock.onGet(mockData.merge_request_cached_widget_path).reply(() => [200, mrData]); + + return createComponent(mrData, { + apolloProvider: createMockApollo([ + [ + securityReportMergeRequestDownloadPathsQuery, + async () => ({ data: securityReportMergeRequestDownloadPathsQueryResponse }), + ], + ]), + }); + }); + + it(shouldRender ? 'renders' : 'does not render', () => { + expect(findSecurityMrWidget().exists()).toBe(shouldRender); + }); + }); + }); + + describe('suggestPipeline', () => { + beforeEach(() => { + mock.onAny().reply(200); + }); + + describe('given feature flag is enabled', () => { + beforeEach(async () => { + await createComponent(); + + wrapper.vm.mr.hasCI = false; + }); + + it('should suggest pipelines when none exist', () => { + expect(findSuggestPipeline().exists()).toBe(true); + }); + + it.each([ + { isDismissedSuggestPipeline: true }, + { mergeRequestAddCiConfigPath: null }, + { hasCI: true }, + ])('with %s, should not suggest pipeline', async (obj) => { + Object.assign(wrapper.vm.mr, obj); + + await nextTick(); + + expect(findSuggestPipeline().exists()).toBe(false); + }); + + it('should allow dismiss of the suggest pipeline message', async () => { + await findSuggestPipelineButton().trigger('click'); + + expect(findSuggestPipeline().exists()).toBe(false); + }); + }); + }); + + describe('merge error', () => { + it.each` + state | show | showText + ${'closed'} | ${false} | ${'hides'} + ${'merged'} | ${true} | ${'shows'} + ${'open'} | ${true} | ${'shows'} + `('it $showText merge error when state is $state', ({ state, show }) => { + createComponent({ ...mockData, state, merge_error: 'Error!' }); + + expect(wrapper.find('[data-testid="merge_error"]').exists()).toBe(show); + }); + }); + + describe('mock extension', () => { + let pollRequest; + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + + registerExtension(workingExtension()); + + createComponent(); + }); + + afterEach(() => { + registeredExtensions.extensions = []; + }); + + it('renders collapsed data', async () => { + await waitForPromises(); + + expect(wrapper.text()).toContain('Test extension summary count: 1'); + }); + + it('renders full data', async () => { + await waitForPromises(); + + findExtensionToggleButton().trigger('click'); + + await nextTick(); + + expect( + wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(), + ).toBe(false); + + await nextTick(); + + const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]'); + expect(collapsedSection.exists()).toBe(true); + expect(collapsedSection.text()).toContain('Hello world'); + + // Renders icon in the row + expect(collapsedSection.find(GlIcon).exists()).toBe(true); + expect(collapsedSection.find(GlIcon).props('name')).toBe('status-failed'); + + // Renders badge in the row + expect(collapsedSection.find(GlBadge).exists()).toBe(true); + expect(collapsedSection.find(GlBadge).text()).toBe('Closed'); + + // Renders a link in the row + expect(collapsedSection.find(GlLink).exists()).toBe(true); + expect(collapsedSection.find(GlLink).text()).toBe('GitLab.com'); + + expect(collapsedSection.find(GlButton).exists()).toBe(true); + expect(collapsedSection.find(GlButton).text()).toBe('Full report'); + }); + + it('extension polling is not called if enablePolling flag is not passed', () => { + // called one time due to parent component polling (mount) + expect(pollRequest).toHaveBeenCalledTimes(1); + }); + }); + + describe('expansion', () => { + it('hides collapse button', async () => { + registerExtension(workingExtension(false)); + await createComponent(); + + expect(findExtensionToggleButton().exists()).toBe(false); + }); + + it('shows collapse button', async () => { + registerExtension(workingExtension(true)); + await createComponent(); + + expect(findExtensionToggleButton().exists()).toBe(true); + }); + }); + + describe('mock polling extension', () => { + let pollRequest; + + const findWidgetTestExtension = () => wrapper.find('[data-testid="widget-extension"]'); + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + + registeredExtensions.extensions = []; + }); + + afterEach(() => { + registeredExtensions.extensions = []; + }); + + describe('success - multi polling', () => { + it('sets data when polling is complete', async () => { + registerExtension( + multiPollingExtension([ + () => + Promise.resolve({ + headers: { 'poll-interval': 0 }, + status: 200, + data: { reports: 'parsed' }, + }), + () => + Promise.resolve({ + status: 200, + data: { reports: 'parsed' }, + }), + ]), + ); + + await createComponent(); + expect(findWidgetTestExtension().html()).toContain( + 'Multi polling test extension reports: parsed, count: 2', + ); + }); + + it('shows loading state until polling is complete', async () => { + registerExtension( + multiPollingExtension([ + () => + Promise.resolve({ + headers: { 'poll-interval': 1 }, + status: 204, + }), + () => + Promise.resolve({ + status: 200, + data: { reports: 'parsed' }, + }), + ]), + ); + + await createComponent(); + expect(findWidgetTestExtension().html()).toContain('Test extension loading...'); + }); + }); + + describe('success', () => { + it('does not make additional requests after poll is successful', async () => { + registerExtension(pollingExtension); + + await createComponent(); + + expect(pollRequest).toHaveBeenCalledTimes(4); + }); + }); + + describe('success - full data polling', () => { + it('sets data when polling is complete', async () => { + registerExtension(pollingFullDataExtension); + + await createComponent(); + + api.trackRedisHllUserEvent.mockClear(); + api.trackRedisCounterEvent.mockClear(); + + findExtensionToggleButton().trigger('click'); + + // The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_expand', + ); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_expand_warning', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_count_expand', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_count_expand_warning', + ); + }); + }); + + describe('error', () => { + it('does not make additional requests after poll has failed', async () => { + registerExtension(pollingErrorExtension); + await createComponent(); + + expect(pollRequest).toHaveBeenCalledTimes(4); + }); + + it('captures sentry error and displays error when poll has failed', async () => { + registerExtension(pollingErrorExtension); + await createComponent(); + + expect(Sentry.captureException).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); + }); + }); + }); + + describe('mock extension errors', () => { + afterEach(() => { + registeredExtensions.extensions = []; + }); + + it('handles collapsed data fetch errors', async () => { + registerExtension(collapsedDataErrorExtension); + await createComponent(); + + expect( + wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(), + ).toBe(false); + expect(Sentry.captureException).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); + }); + + it('handles full data fetch errors', async () => { + registerExtension(fullDataErrorExtension); + await createComponent(); + + expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error'); + wrapper + .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') + .trigger('click'); + + await nextTick(); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); + }); + }); + + describe('telemetry', () => { + afterEach(() => { + registeredExtensions.extensions = []; + }); + + it('triggers view events when mounted', () => { + registerExtension(workingExtension()); + createComponent(); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_view', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_count_view', + ); + }); + + describe('expand button', () => { + it('triggers expand events when clicked', async () => { + registerExtension(workingExtension()); + createComponent(); + + await waitForPromises(); + + api.trackRedisHllUserEvent.mockClear(); + api.trackRedisCounterEvent.mockClear(); + + findExtensionToggleButton().trigger('click'); + + // The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_expand', + ); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_expand_warning', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_count_expand', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_count_expand_warning', + ); + }); + + it.each` + widgetName | nonStandardEvent + ${'WidgetCodeQuality'} | ${'i_testing_code_quality_widget_total'} + ${'WidgetTerraform'} | ${'i_testing_terraform_widget_total'} + ${'WidgetIssues'} | ${'i_testing_load_performance_widget_total'} + ${'WidgetTestReport'} | ${'i_testing_summary_widget_total'} + `( + "sends non-standard events for the '$widgetName' widget", + async ({ widgetName, nonStandardEvent }) => { + const definition = { + ...workingExtension(), + name: widgetName, + }; + + registerExtension(definition); + createComponent(); + + await waitForPromises(); + + api.trackRedisHllUserEvent.mockClear(); + + findExtensionToggleButton().trigger('click'); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(nonStandardEvent); + }, + ); + }); + + it('triggers the "full report clicked" events when the appropriate button is clicked', () => { + registerExtension(fullReportExtension); + createComponent(); + + api.trackRedisHllUserEvent.mockClear(); + api.trackRedisCounterEvent.mockClear(); + + findExtensionLink('testref').trigger('click'); + + expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_click_full_report', + ); + expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1); + expect(api.trackRedisCounterEvent).toHaveBeenCalledWith( + 'i_code_review_merge_request_widget_test_extension_count_click_full_report', + ); + }); + + describe('when disabled', () => { + afterEach(() => { + registeredExtensions.extensions = []; + }); + + it("doesn't emit any telemetry events", async () => { + registerExtension(noTelemetryExtension); + createComponent(); + + await waitForPromises(); + + findExtensionToggleButton().trigger('click'); + findExtensionLink('testref').trigger('click'); // The "full report" link + + expect(api.trackRedisHllUserEvent).not.toHaveBeenCalled(); + expect(api.trackRedisCounterEvent).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/stores/artifacts_list/actions_spec.js b/spec/frontend/vue_merge_request_widget/stores/artifacts_list/actions_spec.js new file mode 100644 index 00000000000..22562bb4ddb --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/stores/artifacts_list/actions_spec.js @@ -0,0 +1,158 @@ +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; +import { + setEndpoint, + requestArtifacts, + clearEtagPoll, + stopPolling, + fetchArtifacts, + receiveArtifactsSuccess, + receiveArtifactsError, +} from '~/vue_merge_request_widget/stores/artifacts_list/actions'; +import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types'; +import state from '~/vue_merge_request_widget/stores/artifacts_list/state'; + +describe('Artifacts App Store Actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('setEndpoint', () => { + it('should commit SET_ENDPOINT mutation', () => { + return testAction( + setEndpoint, + 'endpoint.json', + mockedState, + [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }], + [], + ); + }); + }); + + describe('requestArtifacts', () => { + it('should commit REQUEST_ARTIFACTS mutation', () => { + return testAction( + requestArtifacts, + null, + mockedState, + [{ type: types.REQUEST_ARTIFACTS }], + [], + ); + }); + }); + + describe('fetchArtifacts', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + stopPolling(); + clearEtagPoll(); + }); + + describe('success', () => { + it('dispatches requestArtifacts and receiveArtifactsSuccess ', () => { + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [ + { + text: 'result.txt', + url: 'asda', + job_name: 'generate-artifact', + job_path: 'asda', + }, + ]); + + return testAction( + fetchArtifacts, + null, + mockedState, + [], + [ + { + type: 'requestArtifacts', + }, + { + payload: { + data: [ + { + text: 'result.txt', + url: 'asda', + job_name: 'generate-artifact', + job_path: 'asda', + }, + ], + status: 200, + }, + type: 'receiveArtifactsSuccess', + }, + ], + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); + }); + + it('dispatches requestArtifacts and receiveArtifactsError ', () => { + return testAction( + fetchArtifacts, + null, + mockedState, + [], + [ + { + type: 'requestArtifacts', + }, + { + type: 'receiveArtifactsError', + }, + ], + ); + }); + }); + }); + + describe('receiveArtifactsSuccess', () => { + it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', () => { + return testAction( + receiveArtifactsSuccess, + { data: { summary: {} }, status: 200 }, + mockedState, + [{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }], + [], + ); + }); + + it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', () => { + return testAction( + receiveArtifactsSuccess, + { data: { summary: {} }, status: 204 }, + mockedState, + [], + [], + ); + }); + }); + + describe('receiveArtifactsError', () => { + it('should commit RECEIVE_ARTIFACTS_ERROR mutation', () => { + return testAction( + receiveArtifactsError, + null, + mockedState, + [{ type: types.RECEIVE_ARTIFACTS_ERROR }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/stores/artifacts_list/getters_spec.js b/spec/frontend/vue_merge_request_widget/stores/artifacts_list/getters_spec.js new file mode 100644 index 00000000000..dc90fef63c6 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/stores/artifacts_list/getters_spec.js @@ -0,0 +1,32 @@ +import { title } from '~/vue_merge_request_widget/stores/artifacts_list/getters'; +import state from '~/vue_merge_request_widget/stores/artifacts_list/state'; +import { artifacts } from '../../mock_data'; + +describe('Artifacts Store Getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('title', () => { + describe('when is loading', () => { + it('returns loading message', () => { + localState.isLoading = true; + expect(title(localState)).toBe('Loading artifacts'); + }); + }); + describe('when has error', () => { + it('returns error message', () => { + localState.hasError = true; + expect(title(localState)).toBe('An error occurred while fetching the artifacts'); + }); + }); + describe('when it has artifacts', () => { + it('returns artifacts message', () => { + localState.artifacts = artifacts; + expect(title(localState)).toBe('View 2 exposed artifacts'); + }); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/stores/artifacts_list/mutations_spec.js b/spec/frontend/vue_merge_request_widget/stores/artifacts_list/mutations_spec.js new file mode 100644 index 00000000000..a4e6788c7f6 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/stores/artifacts_list/mutations_spec.js @@ -0,0 +1,78 @@ +import * as types from '~/vue_merge_request_widget/stores/artifacts_list/mutation_types'; +import mutations from '~/vue_merge_request_widget/stores/artifacts_list/mutations'; +import state from '~/vue_merge_request_widget/stores/artifacts_list/state'; + +describe('Artifacts Store Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_ENDPOINT', () => { + it('should set endpoint', () => { + mutations[types.SET_ENDPOINT](stateCopy, 'endpoint.json'); + + expect(stateCopy.endpoint).toEqual('endpoint.json'); + }); + }); + + describe('REQUEST_ARTIFACTS', () => { + it('should set isLoading to true', () => { + mutations[types.REQUEST_ARTIFACTS](stateCopy); + + expect(stateCopy.isLoading).toEqual(true); + }); + }); + + describe('REECEIVE_ARTIFACTS_SUCCESS', () => { + const artifacts = [ + { + text: 'result.txt', + url: 'asda', + job_name: 'generate-artifact', + job_path: 'asda', + }, + { + text: 'file.txt', + url: 'asda', + job_name: 'generate-artifact', + job_path: 'asda', + }, + ]; + + beforeEach(() => { + mutations[types.RECEIVE_ARTIFACTS_SUCCESS](stateCopy, artifacts); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to false', () => { + expect(stateCopy.hasError).toEqual(false); + }); + + it('should set list of artifacts', () => { + expect(stateCopy.artifacts).toEqual(artifacts); + }); + }); + + describe('RECEIVE_ARTIFACTS_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_ARTIFACTS_ERROR](stateCopy); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.hasError).toEqual(true); + }); + + it('should set list of artifacts as empty array', () => { + expect(stateCopy.artifacts).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js new file mode 100644 index 00000000000..0246a8d4b0f --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js @@ -0,0 +1,104 @@ +import getStateKey from '~/vue_merge_request_widget/stores/get_state_key'; + +describe('getStateKey', () => { + it('should return proper state name', () => { + const context = { + mergeStatus: 'checked', + autoMergeEnabled: false, + canMerge: true, + onlyAllowMergeIfPipelineSucceeds: false, + isPipelineFailed: false, + hasMergeableDiscussionsState: false, + isPipelineBlocked: false, + canBeMerged: false, + projectArchived: false, + branchMissing: false, + commitsCount: 2, + hasConflicts: false, + draft: false, + }; + const bound = getStateKey.bind(context); + + expect(bound()).toEqual(null); + + context.canBeMerged = true; + + expect(bound()).toEqual('readyToMerge'); + + context.autoMergeEnabled = true; + context.hasMergeableDiscussionsState = true; + + expect(bound()).toEqual('autoMergeEnabled'); + + context.canMerge = true; + context.isSHAMismatch = true; + + expect(bound()).toEqual('shaMismatch'); + + context.canMerge = false; + context.isPipelineBlocked = true; + + expect(bound()).toEqual('pipelineBlocked'); + + context.hasMergeableDiscussionsState = true; + context.autoMergeEnabled = false; + + expect(bound()).toEqual('unresolvedDiscussions'); + + context.draft = true; + + expect(bound()).toEqual('draft'); + + context.onlyAllowMergeIfPipelineSucceeds = true; + context.isPipelineFailed = true; + + expect(bound()).toEqual('pipelineFailed'); + + context.shouldBeRebased = true; + + expect(bound()).toEqual('rebase'); + + context.hasConflicts = true; + + expect(bound()).toEqual('conflicts'); + + context.mergeStatus = 'unchecked'; + + expect(bound()).toEqual('checking'); + + context.commitsCount = 0; + + expect(bound()).toEqual('nothingToMerge'); + + context.commitsCount = 1; + context.branchMissing = true; + + expect(bound()).toEqual('missingBranch'); + + context.projectArchived = true; + + expect(bound()).toEqual('archived'); + }); + + it('returns rebased state key', () => { + const context = { + mergeStatus: 'checked', + autoMergeEnabled: false, + canMerge: true, + onlyAllowMergeIfPipelineSucceeds: true, + isPipelineFailed: true, + hasMergeableDiscussionsState: false, + isPipelineBlocked: false, + canBeMerged: false, + shouldBeRebased: true, + projectArchived: false, + branchMissing: false, + commitsCount: 2, + hasConflicts: false, + draft: false, + }; + const bound = getStateKey.bind(context); + + expect(bound()).toEqual('rebase'); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_merge_request_widget/stores/mr_widget_store_spec.js new file mode 100644 index 00000000000..3cdb4265ef0 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/stores/mr_widget_store_spec.js @@ -0,0 +1,180 @@ +import { convertToCamelCase } from '~/lib/utils/text_utility'; +import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store'; +import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; +import mockData from '../mock_data'; + +describe('MergeRequestStore', () => { + let store; + + beforeEach(() => { + store = new MergeRequestStore(mockData); + }); + + it('should initialize gitpod attributes', () => { + expect(store).toMatchObject({ + gitpodEnabled: mockData.gitpod_enabled, + showGitpodButton: mockData.show_gitpod_button, + gitpodUrl: mockData.gitpod_url, + userPreferencesGitpodPath: mockData.user_preferences_gitpod_path, + userProfileEnableGitpodPath: mockData.user_profile_enable_gitpod_path, + }); + }); + + describe('setData', () => { + it('should set isSHAMismatch when the diff SHA changes', () => { + store.setData({ ...mockData, diff_head_sha: 'a-different-string' }); + + expect(store.isSHAMismatch).toBe(true); + }); + + it('should not set isSHAMismatch when other data changes', () => { + store.setData({ ...mockData, work_in_progress: !mockData.work_in_progress }); + + expect(store.isSHAMismatch).toBe(false); + }); + + it('should update cached sha after rebasing', () => { + store.setData({ ...mockData, diff_head_sha: 'abc123' }, true); + + expect(store.isSHAMismatch).toBe(false); + expect(store.sha).toBe('abc123'); + }); + + describe('isPipelinePassing', () => { + it('is true when the CI status is `success`', () => { + store.setData({ ...mockData, ci_status: 'success' }); + + expect(store.isPipelinePassing).toBe(true); + }); + + it('is true when the CI status is `success-with-warnings`', () => { + store.setData({ ...mockData, ci_status: 'success-with-warnings' }); + + expect(store.isPipelinePassing).toBe(true); + }); + + it('is false when the CI status is `failed`', () => { + store.setData({ ...mockData, ci_status: 'failed' }); + + expect(store.isPipelinePassing).toBe(false); + }); + + it('is false when the CI status is anything except `success`', () => { + store.setData({ ...mockData, ci_status: 'foobarbaz' }); + + expect(store.isPipelinePassing).toBe(false); + }); + }); + + describe('isPipelineSkipped', () => { + it('should set isPipelineSkipped=true when the CI status is `skipped`', () => { + store.setData({ ...mockData, ci_status: 'skipped' }); + + expect(store.isPipelineSkipped).toBe(true); + }); + + it('should set isPipelineSkipped=false when the CI status is anything except `skipped`', () => { + store.setData({ ...mockData, ci_status: 'foobarbaz' }); + + expect(store.isPipelineSkipped).toBe(false); + }); + }); + + describe('isPipelineBlocked', () => { + const pipelineWaitingForManualAction = { + details: { + status: { + group: 'manual', + }, + }, + }; + + it('should be `false` when the pipeline status is missing', () => { + store.setData({ ...mockData, pipeline: undefined }); + + expect(store.isPipelineBlocked).toBe(false); + }); + + it('should be `false` when the pipeline is waiting for manual action', () => { + store.setData({ ...mockData, pipeline: pipelineWaitingForManualAction }); + + expect(store.isPipelineBlocked).toBe(false); + }); + + it('should be `true` when the pipeline is waiting for manual action and the pipeline must succeed', () => { + store.setData({ + ...mockData, + pipeline: pipelineWaitingForManualAction, + only_allow_merge_if_pipeline_succeeds: true, + }); + + expect(store.isPipelineBlocked).toBe(true); + }); + }); + + describe('isNothingToMergeState', () => { + it('returns true when nothingToMerge', () => { + store.state = stateKey.nothingToMerge; + + expect(store.isNothingToMergeState).toBe(true); + }); + + it('returns false when not nothingToMerge', () => { + store.state = 'state'; + + expect(store.isNothingToMergeState).toBe(false); + }); + }); + }); + + describe('setPaths', () => { + it('should set the add ci config path', () => { + store.setPaths({ ...mockData }); + + expect(store.mergeRequestAddCiConfigPath).toBe('/root/group2/project2/-/ci/editor'); + }); + + it('should set humanAccess=Maintainer when user has that role', () => { + store.setPaths({ ...mockData }); + + expect(store.humanAccess).toBe('Maintainer'); + }); + + it('should set pipelinesEmptySvgPath', () => { + store.setPaths({ ...mockData }); + + expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg'); + }); + + it('should set newPipelinePath', () => { + store.setPaths({ ...mockData }); + + expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new'); + }); + + it('should set sourceProjectDefaultUrl', () => { + store.setPaths({ ...mockData }); + + expect(store.sourceProjectDefaultUrl).toBe('/gitlab-org/html5-boilerplate.git'); + }); + + it('should set securityReportsDocsPath', () => { + store.setPaths({ ...mockData }); + + expect(store.securityReportsDocsPath).toBe('security-reports-docs-path'); + }); + + it.each(['sast_comparison_path', 'secret_detection_comparison_path'])( + 'should set %s path', + (property) => { + // Ensure something is set in the mock data + expect(property in mockData).toBe(true); + const expectedValue = mockData[property]; + + store.setPaths({ ...mockData }); + + expect(store[convertToCamelCase(property)]).toBe(expectedValue); + }, + ); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/test_extensions.js b/spec/frontend/vue_merge_request_widget/test_extensions.js new file mode 100644 index 00000000000..1977f550577 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/test_extensions.js @@ -0,0 +1,192 @@ +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; + +export const workingExtension = (shouldCollapse = true) => ({ + name: 'WidgetTestExtension', + props: ['targetProjectFullPath'], + expandEvent: 'test_expand_event', + i18n: { + loading: 'Test extension loading...', + }, + computed: { + summary({ count, targetProjectFullPath } = {}) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count } = {}) { + return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; + }, + shouldCollapse() { + return shouldCollapse; + }, + }, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.resolve([ + { + id: 1, + text: 'Hello world', + icon: { + name: EXTENSION_ICONS.failed, + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], + }, + ]); + }, + }, +}); + +export const collapsedDataErrorExtension = { + name: 'WidgetTestCollapsedErrorExtension', + props: ['targetProjectFullPath'], + expandEvent: 'test_expand_event', + computed: { + summary({ count, targetProjectFullPath }) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count }) { + return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; + }, + }, + methods: { + fetchCollapsedData() { + return Promise.reject(new Error('Fetch error')); + }, + fetchFullData() { + return Promise.resolve([ + { + id: 1, + text: 'Hello world', + icon: { + name: EXTENSION_ICONS.failed, + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], + }, + ]); + }, + }, +}; + +export const fullDataErrorExtension = { + name: 'WidgetTestCollapsedErrorExtension', + props: ['targetProjectFullPath'], + expandEvent: 'test_expand_event', + computed: { + summary({ count, targetProjectFullPath }) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count }) { + return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; + }, + }, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.reject(new Error('Fetch error')); + }, + }, +}; + +export const pollingExtension = { + ...workingExtension(), + enablePolling: true, +}; + +export const pollingFullDataExtension = { + ...workingExtension(), + enableExpandedPolling: true, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.resolve([ + { + headers: { 'poll-interval': 0 }, + status: 200, + data: { + id: 1, + text: 'Hello world', + icon: { + name: EXTENSION_ICONS.failed, + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }], + }, + }, + ]); + }, + }, +}; + +export const fullReportExtension = { + ...workingExtension(), + computed: { + ...workingExtension().computed, + tertiaryButtons() { + return [ + { + text: 'test', + href: `testref`, + target: '_blank', + fullReport: true, + }, + ]; + }, + }, +}; + +export const noTelemetryExtension = { + ...fullReportExtension, + telemetry: false, +}; + +export const multiPollingExtension = (endpointsToBePolled) => ({ + name: 'WidgetTestMultiPollingExtension', + props: [], + i18n: { + loading: 'Test extension loading...', + }, + computed: { + summary(data) { + return `Multi polling test extension reports: ${data?.[0]?.reports}, count: ${data.length}`; + }, + statusIcon(data) { + return data?.[0]?.reports === 'parsed' ? EXTENSION_ICONS.success : EXTENSION_ICONS.warning; + }, + }, + enablePolling: true, + methods: { + fetchMultiData() { + return endpointsToBePolled; + }, + }, +}); + +export const pollingErrorExtension = { + ...collapsedDataErrorExtension, + enablePolling: true, +}; |