From a09983ae35713f5a2bbb100981116d31ce99826e Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Mon, 20 Jul 2020 12:26:25 +0000 Subject: Add latest changes from gitlab-org/gitlab@13-2-stable-ee --- .../components/approvals/approvals_spec.js | 391 +++++++++++++++++++++ .../approvals/approvals_summary_optional_spec.js | 57 +++ .../components/approvals/approvals_summary_spec.js | 93 +++++ .../components/mr_widget_author_spec.js | 54 ++- .../mr_widget_expandable_section_spec.js | 65 ++++ .../components/mr_widget_header_spec.js | 35 ++ .../components/mr_widget_pipeline_spec.js | 300 ++++++++-------- .../components/mr_widget_suggest_pipeline_spec.js | 86 +++-- .../components/mr_widget_terraform_plan_spec.js | 107 ------ .../components/pipeline_tour_mock_data.js | 7 + .../states/mr_widget_auto_merge_enabled_spec.js | 9 + .../components/states/mr_widget_checking_spec.js | 2 +- .../states/mr_widget_pipeline_tour_spec.js | 143 -------- .../states/mr_widget_ready_to_merge_spec.js | 44 ++- .../states/mr_widget_squash_before_merge_spec.js | 42 +++ .../components/states/pipeline_tour_mock_data.js | 10 - .../components/terraform/mock_data.js | 31 ++ .../mr_widget_terraform_container_spec.js | 172 +++++++++ .../components/terraform/terraform_plan_spec.js | 95 +++++ spec/frontend/vue_mr_widget/mock_data.js | 15 +- .../vue_mr_widget/mr_widget_options_spec.js | 6 + .../vue_mr_widget/stores/get_state_key_spec.js | 8 +- 22 files changed, 1304 insertions(+), 468 deletions(-) create mode 100644 spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js delete mode 100644 spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js delete mode 100644 spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_tour_spec.js delete mode 100644 spec/frontend/vue_mr_widget/components/states/pipeline_tour_mock_data.js create mode 100644 spec/frontend/vue_mr_widget/components/terraform/mock_data.js create mode 100644 spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js create mode 100644 spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js (limited to 'spec/frontend/vue_mr_widget') diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js new file mode 100644 index 00000000000..e39f66d3f30 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -0,0 +1,391 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +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 createFlash from '~/flash'; +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 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, +}); +const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] }); + +// For some reason, the `Promise.resolve()` needs to be deferred +// or the timing doesn't work. +const tick = () => Promise.resolve(); +const waitForTick = done => + tick() + .then(done) + .catch(done.fail); + +describe('MRWidget approvals', () => { + let wrapper; + let service; + let mr; + + const createComponent = (props = {}) => { + wrapper = shallowMount(Approvals, { + propsData: { + mr, + service, + ...props, + }, + }); + }; + + 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); + + 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', () => { + wrapper.setData({ fetchingApprovals: true }); + + return tick().then(() => { + expect(wrapper.text()).toContain(FETCH_LOADING); + }); + }); + + it('fetches approvals', () => { + expect(service.fetchApprovals).toHaveBeenCalled(); + }); + }); + + describe('when fetch approvals error', () => { + beforeEach(done => { + jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject()); + createComponent(); + waitForTick(done); + }); + + it('still shows loading message', () => { + expect(wrapper.text()).toContain(FETCH_LOADING); + }); + + it('flashes error', () => { + expect(createFlash).toHaveBeenCalledWith(FETCH_ERROR); + }); + }); + + describe('action button', () => { + describe('when mr is closed', () => { + beforeEach(done => { + mr.isOpen = false; + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = true; + + createComponent(); + waitForTick(done); + }); + + it('action is not rendered', () => { + expect(findActionData()).toBe(null); + }); + }); + + describe('when user cannot approve', () => { + beforeEach(done => { + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + 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(done => { + createComponent(); + waitForTick(done); + }); + + it('approve action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve', + category: 'primary', + }); + }); + }); + + describe('and MR is approved', () => { + beforeEach(() => { + mr.approvals.approved = true; + }); + + describe('with no approvers', () => { + beforeEach(done => { + mr.approvals.approved_by = []; + createComponent(); + waitForTick(done); + }); + + it('approve action (with inverted style) is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve', + category: 'secondary', + }); + }); + }); + + describe('with approvers', () => { + beforeEach(done => { + mr.approvals.approved_by = [{ user: { id: 7 } }]; + createComponent(); + waitForTick(done); + }); + + it('approve additionally action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve additionally', + category: 'secondary', + }); + }); + }); + }); + + describe('when approve action is clicked', () => { + beforeEach(done => { + createComponent(); + waitForTick(done); + }); + + 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 tick().then(() => { + expect(action.props('loading')).toBe(true); + }); + }); + + describe('and after loading', () => { + beforeEach(done => { + findAction().vm.$emit('click'); + waitForTick(done); + }); + + 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(done => { + jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject()); + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('flashes error message', () => { + expect(createFlash).toHaveBeenCalledWith(APPROVE_ERROR); + }); + }); + }); + }); + + describe('when user has approved', () => { + beforeEach(done => { + mr.approvals.user_has_approved = true; + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + it('revoke action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'warning', + text: 'Revoke approval', + category: 'secondary', + }); + }); + + describe('when revoke action is clicked', () => { + describe('and successful', () => { + beforeEach(done => { + findAction().vm.$emit('click'); + waitForTick(done); + }); + + 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(done => { + jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject()); + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('flashes error message', () => { + expect(createFlash).toHaveBeenCalledWith(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(done => { + mr.approvals.user_can_approve = true; + + createComponent(); + waitForTick(done); + }); + + it('is shown', () => { + expect(findSummary().exists()).toBe(false); + expect(findOptionalSummary().props()).toEqual({ + canApprove: true, + helpPath: TEST_HELP_PATH, + }); + }); + }); + + describe('and cannot approve', () => { + beforeEach(done => { + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + it('is shown', () => { + expect(findSummary().exists()).toBe(false); + expect(findOptionalSummary().props()).toEqual({ + canApprove: false, + helpPath: TEST_HELP_PATH, + }); + }); + }); + }); + }); + + describe('approvals summary', () => { + beforeEach(done => { + createComponent(); + waitForTick(done); + }); + + 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(), + }); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js new file mode 100644 index 00000000000..77fad7f51ab --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { + OPTIONAL, + OPTIONAL_CAN_APPROVE, +} from '~/vue_merge_request_widget/components/approvals/messages'; +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 optional can approve message', () => { + expect(wrapper.text()).toEqual(OPTIONAL_CAN_APPROVE); + }); + + 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('shows optional message', () => { + expect(wrapper.text()).toEqual(OPTIONAL); + }); + + it('does not show help link', () => { + expect(findHelpLink().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js new file mode 100644 index 00000000000..822d075f28f --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js @@ -0,0 +1,93 @@ +import { shallowMount } from '@vue/test-utils'; +import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; +import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; +import { toNounSeriesText } from '~/lib/utils/grammar'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; + +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', () => { + 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; + }); + + describe('when approved', () => { + beforeEach(() => { + createComponent({ + approved: true, + }); + }); + + it('shows approved message', () => { + expect(wrapper.text()).toContain(APPROVED_MESSAGE); + }); + + it('renders avatar list for approvers', () => { + const avatars = findAvatars(); + + expect(avatars.exists()).toBe(true); + expect(avatars.props()).toEqual( + expect.objectContaining({ + items: testApprovers(), + }), + ); + }); + }); + + describe('when not approved', () => { + beforeEach(() => { + createComponent(); + }); + + it('render message', () => { + const names = toNounSeriesText(testRulesLeft()); + + expect(wrapper.text()).toContain( + `Requires ${TEST_APPROVALS_LEFT} more approvals from ${names}.`, + ); + }); + }); + + describe('when no rulesLeft', () => { + beforeEach(() => { + createComponent({ + rulesLeft: [], + }); + }); + + it('renders message', () => { + expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} more approvals.`); + }); + }); + + 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_mr_widget/components/mr_widget_author_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_author_spec.js index 05690aa1248..e7c10ab4c2d 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_author_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_author_spec.js @@ -1,39 +1,61 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue'; +window.gl = window.gl || {}; + describe('MrWidgetAuthor', () => { - let vm; + 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(() => { - const Component = Vue.extend(MrWidgetAuthor); - - vm = mountComponent(Component, { - author: { - name: 'Administrator', - username: 'root', - webUrl: 'http://localhost:3000/root', - avatarUrl: - 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + oldWindowGl = window.gl; + window.gl = { + mrWidgetData: { + defaultAvatarUrl: 'no_avatar.png', + }, + }; + wrapper = shallowMount(MrWidgetAuthor, { + propsData: { + author: mockAuthor, }, }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + window.gl = oldWindowGl; }); it('renders link with the author web url', () => { - expect(vm.$el.getAttribute('href')).toEqual('http://localhost:3000/root'); + expect(wrapper.attributes('href')).toBe('http://localhost:3000/root'); }); it('renders image with avatar url', () => { - expect(vm.$el.querySelector('img').getAttribute('src')).toEqual( + 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 wrapper.vm.$nextTick(); + + expect(wrapper.find('img').attributes('src')).toBe('no_avatar.png'); + }); + it('renders author name', () => { - expect(vm.$el.textContent.trim()).toEqual('Administrator'); + expect(wrapper.find('span').text()).toBe('Administrator'); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js new file mode 100644 index 00000000000..69a50899d4d --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js @@ -0,0 +1,65 @@ +import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +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: 'Collapsable Content', + header: 'Header Content', + }, + }); + }); + + it('renders Icon', () => { + expect(wrapper.contains(GlIcon)).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(() => { + findButton().vm.$emit('click'); + return wrapper.vm.$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_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index b492a69fb3d..21058005d29 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -1,7 +1,13 @@ import Vue from 'vue'; +import Mousetrap from 'mousetrap'; import mountComponent from 'helpers/vue_mount_component_helper'; import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue'; +jest.mock('mousetrap', () => ({ + bind: jest.fn(), + unbind: jest.fn(), +})); + describe('MRWidgetHeader', () => { let vm; let Component; @@ -126,6 +132,35 @@ describe('MRWidgetHeader', () => { it('renders target branch', () => { expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master'); }); + + describe('keyboard shortcuts', () => { + it('binds a keyboard shortcut handler to the "b" key', () => { + expect(Mousetrap.bind).toHaveBeenCalledWith('b', expect.any(Function)); + }); + + it('triggers a click on the "copy to clipboard" button when the handler is executed', () => { + const testClickHandler = jest.fn(); + vm.$refs.copyBranchNameButton.$el.addEventListener('click', testClickHandler); + + // Get a reference to the function that was assigned to the "b" shortcut key. + const shortcutHandler = Mousetrap.bind.mock.calls[0][1]; + + expect(testClickHandler).not.toHaveBeenCalled(); + + // Simulate Mousetrap calling the function. + shortcutHandler(); + + expect(testClickHandler).toHaveBeenCalledTimes(1); + }); + + it('unbinds the keyboard shortcut when the component is destroyed', () => { + expect(Mousetrap.unbind).not.toHaveBeenCalled(); + + vm.$destroy(); + + expect(Mousetrap.unbind).toHaveBeenCalledWith('b'); + }); + }); }); describe('with an open merge request', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index 309aec179d9..6486826c3ec 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -1,189 +1,182 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount, mount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; -import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import { SUCCESS } from '~/vue_merge_request_widget/constants'; +import PipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; import mockData from '../mock_data'; describe('MRWidgetPipeline', () => { - let vm; - let Component; - - beforeEach(() => { - Component = Vue.extend(pipelineComponent); - }); + 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.find('[data-testid="ci-error-message"]'); + const findPipelineID = () => wrapper.find('[data-testid="pipeline-id"]'); + const findPipelineInfoContainer = () => wrapper.find('[data-testid="pipeline-info-container"]'); + const findCommitLink = () => wrapper.find('[data-testid="commit-link"]'); + const findPipelineGraph = () => wrapper.find('[data-testid="widget-mini-pipeline-graph"]'); + const findAllPipelineStages = () => wrapper.findAll(PipelineStage); + const findPipelineCoverage = () => wrapper.find('[data-testid="pipeline-coverage"]'); + const findPipelineCoverageDelta = () => wrapper.find('[data-testid="pipeline-coverage-delta"]'); + const findMonitoringPipelineMessage = () => + wrapper.find('[data-testid="monitoring-pipeline-message"]'); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const createWrapper = (props, mountFn = shallowMount) => { + wrapper = mountFn(PipelineComponent, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; afterEach(() => { - vm.$destroy(); + if (wrapper?.destroy) { + wrapper.destroy(); + wrapper = null; + } }); describe('computed', () => { describe('hasPipeline', () => { - it('should return true when there is a pipeline', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - ciStatus: 'success', - hasCi: true, - troubleshootingDocsPath: 'help', - }); + beforeEach(() => { + createWrapper(); + }); - expect(vm.hasPipeline).toEqual(true); + it('should return true when there is a pipeline', () => { + expect(wrapper.vm.hasPipeline).toBe(true); }); - it('should return false when there is no pipeline', () => { - vm = mountComponent(Component, { - pipeline: {}, - troubleshootingDocsPath: 'help', - }); + it('should return false when there is no pipeline', async () => { + wrapper.setProps({ pipeline: {} }); - expect(vm.hasPipeline).toEqual(false); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.hasPipeline).toBe(false); }); }); describe('hasCIError', () => { - it('should return false when there is no CI error', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', - }); + beforeEach(() => { + createWrapper(); + }); - expect(vm.hasCIError).toEqual(false); + it('should return false when there is no CI error', () => { + expect(wrapper.vm.hasCIError).toBe(false); }); - it('should return true when there is a CI error', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - hasCi: true, - ciStatus: null, - troubleshootingDocsPath: 'help', - }); + it('should return true when there is a pipeline, but no ci status', async () => { + wrapper.setProps({ ciStatus: null }); - expect(vm.hasCIError).toEqual(true); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.hasCIError).toBe(true); }); }); describe('coverageDeltaClass', () => { - it('should return no class if there is no coverage change', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - pipelineCoverageDelta: '0', - troubleshootingDocsPath: 'help', - }); + beforeEach(() => { + createWrapper({ pipelineCoverageDelta: '0' }); + }); - expect(vm.coverageDeltaClass).toEqual(''); + it('should return no class if there is no coverage change', async () => { + expect(wrapper.vm.coverageDeltaClass).toBe(''); }); - it('should return text-success if the coverage increased', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - pipelineCoverageDelta: '10', - troubleshootingDocsPath: 'help', - }); + it('should return text-success if the coverage increased', async () => { + wrapper.setProps({ pipelineCoverageDelta: '10' }); - expect(vm.coverageDeltaClass).toEqual('text-success'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.coverageDeltaClass).toBe('text-success'); }); - it('should return text-danger if the coverage decreased', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - pipelineCoverageDelta: '-12', - troubleshootingDocsPath: 'help', - }); + it('should return text-danger if the coverage decreased', async () => { + wrapper.setProps({ pipelineCoverageDelta: '-12' }); - expect(vm.coverageDeltaClass).toEqual('text-danger'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.coverageDeltaClass).toBe('text-danger'); }); }); }); describe('rendered output', () => { - it('should render CI error', () => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - hasCi: true, - troubleshootingDocsPath: 'help', - }); - - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - 'Could not retrieve the pipeline status. For troubleshooting steps, read the documentation.', - ); + beforeEach(() => { + createWrapper({ ciStatus: null }, mount); }); - it('should render CI error when no pipeline is provided', () => { - vm = mountComponent(Component, { - pipeline: {}, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', - }); - - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - 'Could not retrieve the pipeline status. For troubleshooting steps, read the documentation.', - ); + it('should render CI error if there is a pipeline, but no status', async () => { + expect(findCIErrorMessage().text()).toBe(ciErrorMessage); }); - it('should render CI error when no CI is provided and pipeline must succeed is turned on', () => { - vm = mountComponent(Component, { + it('should render a loading state when no pipeline is found', async () => { + wrapper.setProps({ pipeline: {}, hasCi: false, pipelineMustSucceed: true, - troubleshootingDocsPath: 'help', }); - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - 'No pipeline has been run for this commit.', - ); + await wrapper.vm.$nextTick(); + + expect(findMonitoringPipelineMessage().text()).toBe(monitoringMessage); + expect(findLoadingIcon().exists()).toBe(true); }); describe('with a pipeline', () => { beforeEach(() => { - vm = mountComponent(Component, { - pipeline: mockData.pipeline, - hasCi: true, - ciStatus: 'success', - pipelineCoverageDelta: mockData.pipelineCoverageDelta, - troubleshootingDocsPath: 'help', - }); + createWrapper( + { + pipelineCoverageDelta: mockData.pipelineCoverageDelta, + }, + mount, + ); }); it('should render pipeline ID', () => { - expect(vm.$el.querySelector('.pipeline-id').textContent.trim()).toEqual( - `#${mockData.pipeline.id}`, - ); + expect( + findPipelineID() + .text() + .trim(), + ).toBe(`#${mockData.pipeline.id}`); }); it('should render pipeline status and commit id', () => { - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - mockData.pipeline.details.status.label, - ); + expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); - expect(vm.$el.querySelector('.js-commit-link').textContent.trim()).toEqual( - mockData.pipeline.commit.short_id, - ); + expect( + findCommitLink() + .text() + .trim(), + ).toBe(mockData.pipeline.commit.short_id); - expect(vm.$el.querySelector('.js-commit-link').getAttribute('href')).toEqual( - mockData.pipeline.commit.commit_path, - ); + expect(findCommitLink().attributes('href')).toBe(mockData.pipeline.commit.commit_path); }); it('should render pipeline graph', () => { - expect(vm.$el.querySelector('.mr-widget-pipeline-graph')).toBeDefined(); - expect(vm.$el.querySelectorAll('.stage-container').length).toEqual( - mockData.pipeline.details.stages.length, - ); + expect(findPipelineGraph().exists()).toBe(true); + expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); }); it('should render coverage information', () => { - expect(vm.$el.querySelector('.media-body').textContent).toContain( - `Coverage ${mockData.pipeline.coverage}`, - ); + expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); }); it('should render pipeline coverage delta information', () => { - expect(vm.$el.querySelector('.js-pipeline-coverage-delta.text-danger')).toBeDefined(); - expect(vm.$el.querySelector('.js-pipeline-coverage-delta').textContent).toContain( - `(${mockData.pipelineCoverageDelta}%)`, - ); + expect(findPipelineCoverageDelta().exists()).toBe(true); + expect(findPipelineCoverageDelta().text()).toBe(`(${mockData.pipelineCoverageDelta}%)`); }); }); @@ -192,71 +185,61 @@ describe('MRWidgetPipeline', () => { const mockCopy = JSON.parse(JSON.stringify(mockData)); delete mockCopy.pipeline.commit; - vm = mountComponent(Component, { - pipeline: mockCopy.pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', - }); + createWrapper({}, mount); }); it('should render pipeline ID', () => { - expect(vm.$el.querySelector('.pipeline-id').textContent.trim()).toEqual( - `#${mockData.pipeline.id}`, - ); + expect( + findPipelineID() + .text() + .trim(), + ).toBe(`#${mockData.pipeline.id}`); }); it('should render pipeline status', () => { - expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain( - mockData.pipeline.details.status.label, - ); - - expect(vm.$el.querySelector('.js-commit-link')).toBeNull(); + expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); }); it('should render pipeline graph', () => { - expect(vm.$el.querySelector('.mr-widget-pipeline-graph')).toBeDefined(); - expect(vm.$el.querySelectorAll('.stage-container').length).toEqual( - mockData.pipeline.details.stages.length, - ); + expect(findPipelineGraph().exists()).toBe(true); + expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); }); it('should render coverage information', () => { - expect(vm.$el.querySelector('.media-body').textContent).toContain( - `Coverage ${mockData.pipeline.coverage}`, - ); + expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); }); }); describe('without coverage', () => { - it('should not render a coverage', () => { + beforeEach(() => { const mockCopy = JSON.parse(JSON.stringify(mockData)); delete mockCopy.pipeline.coverage; - vm = mountComponent(Component, { - pipeline: mockCopy.pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', - }); + createWrapper( + { + pipeline: mockCopy.pipeline, + }, + mount, + ); + }); - expect(vm.$el.querySelector('.media-body').textContent).not.toContain('Coverage'); + it('should not render a coverage component', () => { + expect(findPipelineCoverage().exists()).toBe(false); }); }); describe('without a pipeline graph', () => { - it('should not render a pipeline graph', () => { + beforeEach(() => { const mockCopy = JSON.parse(JSON.stringify(mockData)); delete mockCopy.pipeline.details.stages; - vm = mountComponent(Component, { + createWrapper({ pipeline: mockCopy.pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', }); + }); - expect(vm.$el.querySelector('.js-mini-pipeline-graph')).toEqual(null); + it('should not render a pipeline graph', () => { + expect(findPipelineGraph().exists()).toBe(false); }); }); @@ -273,11 +256,8 @@ describe('MRWidgetPipeline', () => { }); const factory = () => { - vm = mountComponent(Component, { + createWrapper({ pipeline, - hasCi: true, - ciStatus: 'success', - troubleshootingDocsPath: 'help', sourceBranchLink: mockData.source_branch_link, }); }; @@ -289,7 +269,7 @@ describe('MRWidgetPipeline', () => { factory(); const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockData.source_branch_link}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText); + const actual = trimText(findPipelineInfoContainer().text()); expect(actual).toBe(expected); }); @@ -302,7 +282,7 @@ describe('MRWidgetPipeline', () => { factory(); const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText); + const actual = trimText(findPipelineInfoContainer().text()); expect(actual).toBe(expected); }); @@ -316,7 +296,7 @@ describe('MRWidgetPipeline', () => { factory(); const expected = `Detached merge request pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; - const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText); + const actual = trimText(findPipelineInfoContainer().text()); expect(actual).toBe(expected); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js index 8b0253dc01a..d6c996f7501 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js @@ -1,37 +1,44 @@ import { mount } from '@vue/test-utils'; -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlSprintf } from '@gitlab/ui'; import suggestPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue'; -import stubChildren from 'helpers/stub_children'; -import PipelineTourState from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue'; import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; +import { popoverProps, iconName } from './pipeline_tour_mock_data'; -describe('MRWidgetHeader', () => { +describe('MRWidgetSuggestPipeline', () => { let wrapper; - const pipelinePath = '/foo/bar/add/pipeline/path'; - const pipelineSvgPath = '/foo/bar/pipeline/svg/path'; - const humanAccess = 'maintainer'; - const iconName = 'status_notfound'; + let trackingSpy; + + const mockTrackingOnWrapper = () => { + unmockTracking(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }; beforeEach(() => { + document.body.dataset.page = 'projects:merge_requests:show'; + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + wrapper = mount(suggestPipelineComponent, { - propsData: { pipelinePath, pipelineSvgPath, humanAccess }, + propsData: popoverProps, stubs: { - ...stubChildren(PipelineTourState), + GlSprintf, }, }); }); afterEach(() => { wrapper.destroy(); + unmockTracking(); }); describe('template', () => { + const findOkBtn = () => wrapper.find('[data-testid="ok"]'); + it('renders add pipeline file link', () => { const link = wrapper.find(GlLink); expect(link.exists()).toBe(true); - expect(link.attributes().href).toBe(pipelinePath); + expect(link.attributes().href).toBe(popoverProps.pipelinePath); }); it('renders the expected text', () => { @@ -51,25 +58,60 @@ describe('MRWidgetHeader', () => { ); }); + it('renders the show me how button', () => { + const button = findOkBtn(); + + expect(button.exists()).toBe(true); + expect(button.classes('btn-info')).toEqual(true); + expect(button.attributes('href')).toBe(popoverProps.pipelinePath); + }); + + it('renders the help link', () => { + const link = wrapper.find('[data-testid="help"]'); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(wrapper.vm.$options.helpURL); + }); + + it('renders the empty pipelines image', () => { + const image = wrapper.find('[data-testid="pipeline-image"]'); + + expect(image.exists()).toBe(true); + expect(image.attributes().src).toBe(popoverProps.pipelineSvgPath); + }); + describe('tracking', () => { - let spy; + it('send event for basic view of the suggest pipeline widget', () => { + const expectedCategory = undefined; + const expectedAction = undefined; - beforeEach(() => { - spy = mockTracking('_category_', wrapper.element, jest.spyOn); + expect(trackingSpy).toHaveBeenCalledWith(expectedCategory, expectedAction, { + label: wrapper.vm.$options.trackLabel, + property: popoverProps.humanAccess, + }); }); - afterEach(() => { - unmockTracking(); + it('send an event when add pipeline link is clicked', () => { + mockTrackingOnWrapper(); + const link = wrapper.find('[data-testid="add-pipeline-link"]'); + triggerEvent(link.element); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', { + label: wrapper.vm.$options.trackLabel, + property: popoverProps.humanAccess, + value: '30', + }); }); it('send an event when ok button is clicked', () => { - const link = wrapper.find(GlLink); - triggerEvent(link.element); + mockTrackingOnWrapper(); + const okBtn = findOkBtn(); + triggerEvent(okBtn.element); - expect(spy).toHaveBeenCalledWith('_category_', 'click_link', { - label: 'no_pipeline_noticed', - property: humanAccess, - value: '30', + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { + label: wrapper.vm.$options.trackLabel, + property: popoverProps.humanAccess, + value: '10', }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js deleted file mode 100644 index 62c5c8e8531..00000000000 --- a/spec/frontend/vue_mr_widget/components/mr_widget_terraform_plan_spec.js +++ /dev/null @@ -1,107 +0,0 @@ -import { GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import axios from '~/lib/utils/axios_utils'; -import MockAdapter from 'axios-mock-adapter'; -import MrWidgetTerraformPlan from '~/vue_merge_request_widget/components/mr_widget_terraform_plan.vue'; -import Poll from '~/lib/utils/poll'; - -const plan = { - create: 10, - update: 20, - delete: 30, - job_path: '/path/to/ci/logs', -}; - -describe('MrWidgetTerraformPlan', () => { - let mock; - let wrapper; - - const propsData = { endpoint: '/path/to/terraform/report.json' }; - - const mockPollingApi = (response, body, header) => { - mock.onGet(propsData.endpoint).reply(response, body, header); - }; - - const mountWrapper = () => { - wrapper = shallowMount(MrWidgetTerraformPlan, { propsData }); - return axios.waitForAll(); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - wrapper.destroy(); - mock.restore(); - }); - - describe('loading poll', () => { - beforeEach(() => { - mockPollingApi(200, { '123': plan }, {}); - - return mountWrapper().then(() => { - wrapper.setData({ loading: true }); - return wrapper.vm.$nextTick(); - }); - }); - - it('Diplays loading icon when loading is true', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - - expect(wrapper.find(GlSprintf).exists()).toBe(false); - - expect(wrapper.text()).not.toContain( - 'A terraform report was generated in your pipelines. Changes are unknown', - ); - }); - }); - - describe('successful poll', () => { - let pollRequest; - let pollStop; - - beforeEach(() => { - pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); - pollStop = jest.spyOn(Poll.prototype, 'stop'); - - mockPollingApi(200, { '123': plan }, {}); - - return mountWrapper(); - }); - - afterEach(() => { - pollRequest.mockRestore(); - pollStop.mockRestore(); - }); - - it('content change text', () => { - expect(wrapper.find(GlSprintf).exists()).toBe(true); - }); - - it('renders button when url is found', () => { - expect(wrapper.find(GlLink).exists()).toBe(true); - }); - - 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('does not display changes text when api fails', () => { - expect(wrapper.text()).toContain( - 'A terraform report was generated in your pipelines. Changes are unknown', - ); - - expect(wrapper.find('.js-terraform-report-link').exists()).toBe(false); - expect(wrapper.find(GlLink).exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js b/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js new file mode 100644 index 00000000000..c749c434079 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js @@ -0,0 +1,7 @@ +export const popoverProps = { + pipelinePath: '/foo/bar/add/pipeline/path', + pipelineSvgPath: 'assets/illustrations/something.svg', + humanAccess: 'maintainer', +}; + +export const iconName = 'status_notfound'; diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index e2caa6e8092..ae0f605c419 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -8,6 +8,7 @@ import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; describe('MRWidgetAutoMergeEnabled', () => { let vm; + let oldWindowGl; const targetBranchPath = '/foo/bar'; const targetBranch = 'foo'; const sha = '1EA2EZ34'; @@ -16,6 +17,13 @@ describe('MRWidgetAutoMergeEnabled', () => { const Component = Vue.extend(autoMergeEnabledComponent); jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + oldWindowGl = window.gl; + window.gl = { + mrWidgetData: { + defaultAvatarUrl: 'no_avatar.png', + }, + }; + vm = mountComponent(Component, { mr: { shouldRemoveSourceBranch: false, @@ -35,6 +43,7 @@ describe('MRWidgetAutoMergeEnabled', () => { afterEach(() => { vm.$destroy(); + window.gl = oldWindowGl; }); describe('computed', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js index 56d55c9afac..afe6bd0e767 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_checking_spec.js @@ -25,7 +25,7 @@ describe('MRWidgetChecking', () => { it('renders information about merging', () => { expect(vm.$el.querySelector('.media-body').textContent.trim()).toEqual( - 'Checking ability to merge automatically…', + 'Checking if merge request can be merged…', ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_tour_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_tour_spec.js deleted file mode 100644 index e8f95e099cc..00000000000 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_pipeline_tour_spec.js +++ /dev/null @@ -1,143 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlPopover } from '@gitlab/ui'; -import Cookies from 'js-cookie'; -import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; -import pipelineTourState from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue'; -import { popoverProps, cookieKey } from './pipeline_tour_mock_data'; - -describe('MRWidgetPipelineTour', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('template', () => { - describe(`when ${cookieKey} cookie is set`, () => { - beforeEach(() => { - Cookies.set(cookieKey, true); - wrapper = shallowMount(pipelineTourState, { - propsData: popoverProps, - }); - }); - - it('does not render the popover', () => { - const popover = wrapper.find(GlPopover); - - expect(popover.exists()).toBe(false); - }); - - describe('tracking', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - it('does not call tracking', () => { - expect(trackingSpy).not.toHaveBeenCalled(); - }); - }); - }); - - describe(`when ${cookieKey} cookie is not set`, () => { - const findOkBtn = () => wrapper.find({ ref: 'ok' }); - const findDismissBtn = () => wrapper.find({ ref: 'no-thanks' }); - - beforeEach(() => { - Cookies.remove(cookieKey); - wrapper = shallowMount(pipelineTourState, { - propsData: popoverProps, - }); - }); - - it('renders the popover', () => { - const popover = wrapper.find(GlPopover); - - expect(popover.exists()).toBe(true); - }); - - it('renders the show me how button', () => { - const button = findOkBtn(); - - expect(button.exists()).toBe(true); - expect(button.attributes().category).toBe('primary'); - }); - - it('renders the dismiss button', () => { - const button = findDismissBtn(); - - expect(button.exists()).toBe(true); - expect(button.attributes().category).toBe('secondary'); - }); - - it('renders the empty pipelines image', () => { - const image = wrapper.find('img'); - - expect(image.exists()).toBe(true); - expect(image.attributes().src).toBe(popoverProps.pipelineSvgPath); - }); - - describe('tracking', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('send event for basic view of popover', () => { - document.body.dataset.page = 'projects:merge_requests:show'; - - wrapper.vm.trackOnShow(); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, undefined, { - label: popoverProps.trackLabel, - property: popoverProps.humanAccess, - }); - }); - - it('send an event when ok button is clicked', () => { - const okBtn = findOkBtn(); - triggerEvent(okBtn.element); - - expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { - label: popoverProps.trackLabel, - property: popoverProps.humanAccess, - value: '10', - }); - }); - - it('send an event when dismiss button is clicked', () => { - const dismissBtn = findDismissBtn(); - triggerEvent(dismissBtn.element); - - expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { - label: popoverProps.trackLabel, - property: popoverProps.humanAccess, - value: '20', - }); - }); - }); - - describe('dismissPopover', () => { - it('updates popoverDismissed', () => { - const button = findDismissBtn(); - const popover = wrapper.find(GlPopover); - button.vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(Cookies.get(cookieKey)).toBe('true'); - expect(popover.exists()).toBe(false); - }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 1f0d6a7378c..5eb24315ca6 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -34,6 +34,9 @@ const createTestMr = customConfig => { ciStatus: null, sha: '12345678', squash: false, + squashIsEnabledByDefault: false, + squashIsReadonly: false, + squashIsSelected: false, commitMessage, squashCommitMessage, commitMessageWithDescription, @@ -694,6 +697,37 @@ describe('ReadyToMerge', () => { expect(findCheckboxElement().exists()).toBeFalsy(); }); + + 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 }) => { + createLocalComponent({ + mr: { commitsCount: 2, enableSquashBeforeMerge: true, [squashState]: expectation }, + }); + + expect(findCheckboxElement().props(prop)).toBe(expectation); + }, + ); + + it('is not rendered for "Do not allow" option', () => { + createLocalComponent({ + mr: { + commitsCount: 2, + enableSquashBeforeMerge: true, + squashIsReadonly: true, + squashIsSelected: false, + }, + }); + + expect(findCheckboxElement().exists()).toBe(false); + }); + }); }); describe('commits count collapsible header', () => { @@ -709,7 +743,7 @@ describe('ReadyToMerge', () => { mr: { ffOnlyEnabled: true, enableSquashBeforeMerge: true, - squash: true, + squashIsSelected: true, commitsCount: 2, }, }); @@ -803,7 +837,7 @@ describe('ReadyToMerge', () => { createLocalComponent({ mr: { ffOnlyEnabled: true, - squash: true, + squashIsSelected: true, enableSquashBeforeMerge: true, commitsCount: 2, }, @@ -824,7 +858,7 @@ describe('ReadyToMerge', () => { createLocalComponent({ mr: { commitsCount: 2, - squash: true, + squashIsSelected: true, enableSquashBeforeMerge: true, }, }); @@ -854,7 +888,7 @@ describe('ReadyToMerge', () => { createLocalComponent({ mr: { commitsCount: 2, - squash: true, + squashIsSelected: true, enableSquashBeforeMerge: true, }, }); @@ -872,7 +906,7 @@ describe('ReadyToMerge', () => { it('should be rendered if squash is enabled and there is more than 1 commit', () => { createLocalComponent({ - mr: { enableSquashBeforeMerge: true, squash: true, commitsCount: 2 }, + mr: { enableSquashBeforeMerge: true, squashIsSelected: true, commitsCount: 2 }, }); expect(findCommitDropdownElement().exists()).toBeTruthy(); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js index b70d580ed04..1542b0939aa 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -19,6 +19,8 @@ describe('Squash before merge component', () => { wrapper.destroy(); }); + const findLabel = () => wrapper.find('[data-testid="squashLabel"]'); + describe('checkbox', () => { const findCheckbox = () => wrapper.find('.js-squash-checkbox'); @@ -63,6 +65,46 @@ describe('Squash before merge component', () => { }); }); + describe('label', () => { + describe.each` + isDisabled | expectation + ${true} | ${'grays out text if it is true'} + ${false} | ${'does not gray out text if it is false'} + `('isDisabled prop', ({ isDisabled, expectation }) => { + beforeEach(() => { + createComponent({ + value: false, + isDisabled, + }); + }); + + it(expectation, () => { + expect(findLabel().classes('gl-text-gray-600')).toBe(isDisabled); + }); + }); + }); + + describe('tooltip', () => { + const tooltipTitle = () => findLabel().element.dataset.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('Required in this project.'); + }); + }); + describe('about link', () => { it('is not rendered if no help path is passed', () => { createComponent({ diff --git a/spec/frontend/vue_mr_widget/components/states/pipeline_tour_mock_data.js b/spec/frontend/vue_mr_widget/components/states/pipeline_tour_mock_data.js deleted file mode 100644 index 39bc89e459c..00000000000 --- a/spec/frontend/vue_mr_widget/components/states/pipeline_tour_mock_data.js +++ /dev/null @@ -1,10 +0,0 @@ -export const popoverProps = { - pipelinePath: '/foo/bar/add/pipeline/path', - pipelineSvgPath: 'assets/illustrations/something.svg', - humanAccess: 'maintainer', - popoverTarget: 'suggest-popover', - popoverContainer: 'suggest-pipeline', - trackLabel: 'some_tracking_label', -}; - -export const cookieKey = 'suggest_pipeline_dismissed'; diff --git a/spec/frontend/vue_mr_widget/components/terraform/mock_data.js b/spec/frontend/vue_mr_widget/components/terraform/mock_data.js new file mode 100644 index 00000000000..ae280146c22 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/terraform/mock_data.js @@ -0,0 +1,31 @@ +export const invalidPlanWithName = { + job_name: 'Invalid Plan', + job_path: '/path/to/ci/logs/1', + 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/1', +}; + +export const plans = { + invalid_plan_one: invalidPlanWithName, + invalid_plan_two: invalidPlanWithName, + valid_plan_one: validPlanWithName, + valid_plan_two: validPlanWithoutName, +}; diff --git a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js new file mode 100644 index 00000000000..be43f10c03e --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js @@ -0,0 +1,172 @@ +import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { invalidPlanWithName, plans, validPlanWithName } from './mock_data'; +import { shallowMount } from '@vue/test-utils'; +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +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 Poll from '~/lib/utils/poll'; +import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue'; + +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(() => { + mockPollingApi(200, plans, {}); + + return mountWrapper().then(() => { + wrapper.setData({ loading: true }); + return wrapper.vm.$nextTick(); + }); + }); + + it('diplays loading skeleton', () => { + expect(wrapper.contains(GlSkeletonLoading)).toBe(true); + expect(wrapper.contains(MrWidgetExpanableSection)).toBe(false); + }); + }); + + describe('when data has finished loading', () => { + beforeEach(() => { + mockPollingApi(200, plans, {}); + return mountWrapper(); + }); + + it('displays terraform content', () => { + expect(wrapper.contains(GlSkeletonLoading)).toBe(false); + expect(wrapper.contains(MrWidgetExpanableSection)).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.contains(GlSkeletonLoading)).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_mr_widget/components/terraform/terraform_plan_spec.js b/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js new file mode 100644 index 00000000000..cc68ba0d9df --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js @@ -0,0 +1,95 @@ +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 Terraform report ${validPlanWithName.job_name} was generated in your pipelines.`, + ); + }); + + 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 Terraform 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 Terraform report ${invalidPlanWithName.job_name} failed to generate.`, + ); + }); + + 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 Terraform report failed to generate.'); + }); + + it('does not render button because url is missing', () => { + expect(findLogButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index 8ed153658fd..e00456a78b5 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -211,6 +211,15 @@ export default { can_revert_on_current_merge_request: true, can_cherry_pick_on_current_merge_request: true, }, + codeclimate: { + head_path: 'head.json', + base_path: 'base.json', + }, + blob_path: { + base_path: 'blob_path', + head_path: 'blob_path', + }, + codequality_help_path: 'code_quality.html', target_branch_path: '/root/acets-app/branches/master', source_branch_path: '/root/acets-app/branches/daaaa', conflict_resolution_ui_path: '/root/acets-app/-/merge_requests/22/conflicts', @@ -239,7 +248,8 @@ export default { commit_change_content_path: '/root/acets-app/-/merge_requests/22/commit_change_content', merge_commit_path: 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', - troubleshooting_docs_path: 'help', + mr_troubleshooting_docs_path: 'help', + ci_troubleshooting_docs_path: 'help2', merge_request_pipelines_docs_path: '/help/ci/merge_request_pipelines/index.md', merge_train_when_pipeline_succeeds_docs_path: '/help/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/#startadd-to-merge-train-when-pipeline-succeeds', @@ -312,7 +322,8 @@ export const mockStore = { { id: 0, name: 'prod', status: SUCCESS }, { id: 1, name: 'prod-docs', status: SUCCESS }, ], - troubleshootingDocsPath: 'troubleshooting-docs-path', + 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_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index e022f68fdec..93659fa54fb 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -609,6 +609,12 @@ describe('mrWidgetOptions', () => { }); }); + describe('code quality widget', () => { + it('renders the component', () => { + expect(vm.$el.querySelector('.js-codequality-widget')).toExist(); + }); + }); + describe('pipeline for target branch after merge', () => { describe('with information for target branch pipeline', () => { beforeEach(done => { diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js index e54cd345a37..1cb2c6c669b 100644 --- a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js @@ -49,14 +49,18 @@ describe('getStateKey', () => { expect(bound()).toEqual('unresolvedDiscussions'); + data.work_in_progress = true; + + expect(bound()).toEqual('workInProgress'); + context.onlyAllowMergeIfPipelineSucceeds = true; context.isPipelineFailed = true; expect(bound()).toEqual('pipelineFailed'); - data.work_in_progress = true; + context.shouldBeRebased = true; - expect(bound()).toEqual('workInProgress'); + expect(bound()).toEqual('rebase'); data.has_conflicts = true; -- cgit v1.2.3