From 6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Mon, 19 Sep 2022 23:18:09 +0000 Subject: Add latest changes from gitlab-org/gitlab@15-4-stable-ee --- .../components/added_commit_message_spec.js | 13 +- .../components/artifacts_list_spec.js | 6 +- .../components/mr_widget_pipeline_spec.js | 28 +- .../components/mr_widget_rebase_spec.js | 75 +++- .../components/mr_widget_status_icon_spec.js | 40 +- .../components/mr_widget_suggest_pipeline_spec.js | 2 +- .../mr_widget_auto_merge_enabled_spec.js.snap | 468 +++++++++++++-------- .../mr_widget_pipeline_failed_spec.js.snap | 24 -- .../components/states/mr_widget_archived_spec.js | 21 +- .../components/states/mr_widget_checking_spec.js | 22 +- .../components/states/mr_widget_closed_spec.js | 65 +-- .../mr_widget_commit_message_dropdown_spec.js | 2 +- .../states/mr_widget_failed_to_merge_spec.js | 15 +- .../states/mr_widget_not_allowed_spec.js | 20 +- .../states/mr_widget_pipeline_blocked_spec.js | 19 +- .../states/mr_widget_pipeline_failed_spec.js | 17 +- .../states/mr_widget_ready_to_merge_spec.js | 4 +- .../mr_widget_terraform_container_spec.js | 3 +- .../components/widget/app_spec.js | 4 +- .../widget/widget_content_section_spec.js | 39 ++ .../components/widget/widget_spec.js | 174 +++++++- .../deployment/deployment_actions_spec.js | 2 +- .../extensions/test_report/index_spec.js | 10 + .../mr_widget_options_spec.js | 4 +- .../stores/artifacts_list/actions_spec.js | 4 +- 25 files changed, 751 insertions(+), 330 deletions(-) delete mode 100644 spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_pipeline_failed_spec.js.snap create mode 100644 spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js (limited to 'spec/frontend/vue_merge_request_widget') 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 index cb53dc1fb61..063425454d7 100644 --- 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 @@ -1,10 +1,10 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue'; let wrapper; function factory(propsData) { - wrapper = shallowMount(AddedCommentMessage, { + wrapper = mount(AddedCommentMessage, { propsData: { isFastForwardEnabled: false, targetBranch: 'main', @@ -23,4 +23,13 @@ describe('Widget added commit message', () => { expect(wrapper.element.outerHTML).toContain('The changes were not merged'); }); + + it('renders merge commit as a link', () => { + factory({ state: 'merged', mergeCommitPath: 'https://test.host/merge-commit-link' }); + + expect(wrapper.find('[data-testid="merge-commit-sha"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="merge-commit-sha"]').attributes('href')).toBe( + 'https://test.host/merge-commit-link', + ); + }); }); 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 index 712abfe228a..d519ad2cdb0 100644 --- a/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/artifacts_list_spec.js @@ -39,10 +39,12 @@ describe('Artifacts List', () => { }); it('renders job url', () => { - expect(wrapper.findAll(GlLink).at(1).attributes('href')).toEqual(data.artifacts[0].job_path); + expect(wrapper.findAllComponents(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); + expect(wrapper.findAllComponents(GlLink).at(1).text()).toEqual(data.artifacts[0].job_name); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js index 6347e3c3be3..7f0173b7445 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js @@ -4,9 +4,8 @@ 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 MRWidgetPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import { SUCCESS } from '~/vue_merge_request_widget/constants'; import mockData from '../mock_data'; @@ -30,14 +29,13 @@ describe('MRWidgetPipeline', () => { 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 findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); const findMonitoringPipelineMessage = () => wrapper.findByTestId('monitoring-pipeline-message'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); @@ -45,7 +43,7 @@ describe('MRWidgetPipeline', () => { const createWrapper = (props = {}, mountFn = shallowMount) => { wrapper = extendedWrapper( - mountFn(PipelineComponent, { + mountFn(MRWidgetPipelineComponent, { propsData: { ...defaultProps, ...props, @@ -106,8 +104,10 @@ describe('MRWidgetPipeline', () => { }); it('should render pipeline graph', () => { + const stagesCount = mockData.pipeline.details.stages.length; + expect(findPipelineMiniGraph().exists()).toBe(true); - expect(findAllPipelineStages()).toHaveLength(mockData.pipeline.details.stages.length); + expect(findPipelineMiniGraph().props('stages')).toHaveLength(stagesCount); }); describe('should render pipeline coverage information', () => { @@ -176,15 +176,11 @@ describe('MRWidgetPipeline', () => { expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); }); - it('should render pipeline graph with correct styles', () => { + it('should render pipeline graph', () => { 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); + expect(findPipelineMiniGraph().props('stages')).toHaveLength(stagesCount); }); it('should render coverage information', () => { @@ -266,13 +262,13 @@ describe('MRWidgetPipeline', () => { }); describe('for a detached merge request pipeline', () => { - it('renders a pipeline widget that reads "Detached merge request pipeline for "', () => { - pipeline.details.name = 'Detached merge request pipeline'; + it('renders a pipeline widget that reads "Merge request pipeline for "', () => { + pipeline.details.name = '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 expected = `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 index 534c0baf35d..05c259de370 100644 --- 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 @@ -110,7 +110,7 @@ describe('Merge request widget rebase component', () => { expect(findRebaseMessageText()).toContain('Something went wrong!'); }); - describe('Rebase buttons with', () => { + describe('Rebase buttons', () => { beforeEach(() => { createWrapper( { @@ -148,6 +148,79 @@ describe('Merge request widget rebase component', () => { expect(rebaseMock).toHaveBeenCalledWith({ skipCi: true }); }); }); + + describe('Rebase when pipelines must succeed is enabled', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + onlyAllowMergeIfPipelineSucceeds: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('renders only the rebase button', () => { + expect(findRebaseWithoutCiButton().exists()).toBe(false); + expect(findStandardRebaseButton().exists()).toBe(true); + }); + + it('starts the rebase when clicking', async () => { + findStandardRebaseButton().vm.$emit('click'); + + await nextTick(); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false }); + }); + }); + + describe('Rebase when pipelines must succeed and skipped pipelines are considered successful are enabled', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + onlyAllowMergeIfPipelineSucceeds: true, + allowMergeOnSkippedPipeline: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('renders both rebase 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', () => { diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js index 11373be578a..530549b7b9c 100644 --- a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js @@ -1,14 +1,16 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import mrStatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; describe('MR widget status icon component', () => { let wrapper; - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findStatusIcon = () => wrapper.findComponent(StatusIcon); + const findIcon = () => wrapper.findComponent(GlIcon); - const createWrapper = (props, mountFn = shallowMount) => { - wrapper = mountFn(mrStatusIcon, { + const createWrapper = (props) => { + wrapper = shallowMount(mrStatusIcon, { propsData: { ...props, }, @@ -17,27 +19,45 @@ describe('MR widget status icon component', () => { afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('while loading', () => { it('renders loading icon', () => { createWrapper({ status: 'loading' }); - expect(findLoadingIcon().exists()).toBe(true); + expect(findStatusIcon().exists()).toBe(true); + expect(findStatusIcon().props().isLoading).toBe(true); }); }); describe('with status icon', () => { it('renders success status icon', () => { - createWrapper({ status: 'success' }, mount); + createWrapper({ status: 'success' }); - expect(wrapper.find('[data-testid="status_success-icon"]').exists()).toBe(true); + expect(findStatusIcon().exists()).toBe(true); + expect(findStatusIcon().props().iconName).toBe('success'); }); it('renders failed status icon', () => { - createWrapper({ status: 'failed' }, mount); + createWrapper({ status: 'failed' }); - expect(wrapper.find('[data-testid="status_failed-icon"]').exists()).toBe(true); + expect(findStatusIcon().exists()).toBe(true); + expect(findStatusIcon().props().iconName).toBe('failed'); + }); + + it('renders merged status icon', () => { + createWrapper({ status: 'merged' }); + + expect(findIcon().exists()).toBe(true); + expect(findIcon().props().name).toBe('merge'); + }); + + it('renders closed status icon', () => { + createWrapper({ status: 'closed' }); + + expect(findIcon().exists()).toBe(true); + expect(findIcon().props().name).toBe('merge-request-close'); }); }); }); 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 index 352bc1a08ea..d6c67dab381 100644 --- 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 @@ -128,7 +128,7 @@ describe('MRWidgetSuggestPipeline', () => { it('emits dismiss upon dismissal button click', () => { findDismissContainer().vm.$emit('dismiss'); - expect(wrapper.emitted().dismiss).toBeTruthy(); + expect(wrapper.emitted().dismiss).toHaveLength(1); }); }); }); 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 index de25e2a0450..635ef0f6b0d 100644 --- 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 @@ -4,117 +4,171 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have
- -
- -

- Set by - - - - - - - - to be merged automatically when the pipeline succeeds -

- +
+
+ + + +
+
+
+
+ + + +
-
- + +
+ -
+ + + + +
@@ -124,117 +178,171 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c
- -
- -

- Set by - - - - - - - - to be merged automatically when the pipeline succeeds -

- +
+
+ + + +
+
+
+
+ + + +
-
- + +
+ -
+ + + + +
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 deleted file mode 100644 index 7e741bf4660..00000000000 --- a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_pipeline_failed_spec.js.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PipelineFailed should render error message with a disabled merge button 1`] = ` -
- - -
- - - -
-
-`; 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 index 9332b7e334a..5c07f4ce143 100644 --- 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 @@ -1,25 +1,26 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; import archivedComponent from '~/vue_merge_request_widget/components/states/mr_widget_archived.vue'; +import StateContainer from '~/vue_merge_request_widget/components/state_container.vue'; describe('MRWidgetArchived', () => { - let vm; + let wrapper; beforeEach(() => { - const Component = Vue.extend(archivedComponent); - vm = mountComponent(Component); + wrapper = shallowMount(archivedComponent, { propsData: { mr: {} } }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); - it('renders a ci status failed icon', () => { - expect(vm.$el.querySelector('.ci-status-icon')).not.toBeNull(); + it('renders error icon', () => { + expect(wrapper.findComponent(StateContainer).exists()).toBe(true); + expect(wrapper.findComponent(StateContainer).props().status).toBe('failed'); }); - it('renders information', () => { - expect(vm.$el.querySelector('.bold').textContent.trim()).toEqual( + it('renders information about merging', () => { + expect(wrapper.text()).toContain( 'Merge unavailable: merge requests are read-only on archived projects.', ); }); 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 index 02de426204b..ac18ccf9e26 100644 --- 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 @@ -1,27 +1,25 @@ -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'; +import { shallowMount } from '@vue/test-utils'; +import CheckingComponent from '~/vue_merge_request_widget/components/states/mr_widget_checking.vue'; +import StateContainer from '~/vue_merge_request_widget/components/state_container.vue'; describe('MRWidgetChecking', () => { - let Component; - let vm; + let wrapper; beforeEach(() => { - Component = Vue.extend(checkingComponent); - vm = mountComponent(Component); + wrapper = shallowMount(CheckingComponent, { propsData: { mr: {} } }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); it('renders loading icon', () => { - expect(vm.$el.querySelector('.mr-widget-icon span').classList).toContain('gl-spinner'); + expect(wrapper.findComponent(StateContainer).exists()).toBe(true); + expect(wrapper.findComponent(StateContainer).props().status).toBe('loading'); }); it('renders information about merging', () => { - expect(vm.$el.querySelector('.media-body').textContent.trim()).toEqual( - 'Checking if merge request can be merged…', - ); + expect(wrapper.text()).toContain('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 index f7d046eb8f9..06ee017dee7 100644 --- 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 @@ -1,39 +1,54 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed.vue'; +import MrWidgetAuthorTime from '~/vue_merge_request_widget/components/mr_widget_author_time.vue'; +import StateContainer from '~/vue_merge_request_widget/components/state_container.vue'; + +const MOCK_DATA = { + 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', +}; describe('MRWidgetClosed', () => { - let vm; + let wrapper; 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', + wrapper = shallowMount(closedComponent, { + propsData: { + mr: MOCK_DATA, }, }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; + }); + + it('renders closed icon', () => { + expect(wrapper.findComponent(StateContainer).exists()).toBe(true); + expect(wrapper.findComponent(StateContainer).props().status).toBe('closed'); }); - it('renders warning icon', () => { - expect(vm.$el.querySelector('.js-ci-status-icon-warning')).not.toBeNull(); + it('renders mr widget author time', () => { + expect(wrapper.findComponent(MrWidgetAuthorTime).exists()).toBe(true); + expect(wrapper.findComponent(MrWidgetAuthorTime).props()).toEqual({ + actionText: 'Closed by', + author: MOCK_DATA.metrics.closedBy, + dateTitle: MOCK_DATA.metrics.closedAt, + dateReadable: MOCK_DATA.metrics.readableClosedAt, + }); }); }); 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 index 663fabb761c..5d2d1fdd6f1 100644 --- 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 @@ -40,7 +40,7 @@ describe('Commits message dropdown component', () => { wrapper.destroy(); }); - const findDropdownElements = () => wrapper.findAll(GlDropdownItem); + const findDropdownElements = () => wrapper.findAllComponents(GlDropdownItem); const findFirstDropdownElement = () => findDropdownElements().at(0); it('should have 3 elements in dropdown list', () => { 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 index 989aa76f09b..833fa27d453 100644 --- 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 @@ -1,6 +1,7 @@ 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 StateContainer from '~/vue_merge_request_widget/components/state_container.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; describe('MRWidgetFailedToMerge', () => { @@ -39,7 +40,7 @@ describe('MRWidgetFailedToMerge', () => { expect(wrapper.vm.intervalId).toBe(dummyIntervalId); }); - it('clears interval when destroying ', () => { + it('clears interval when destroying', () => { createComponent(); wrapper.destroy(); @@ -128,7 +129,11 @@ describe('MRWidgetFailedToMerge', () => { await nextTick(); - expect(wrapper.find('.js-refresh-label').text().trim()).toBe('Refreshing now'); + const stateContainerWrapper = wrapper.findComponent(StateContainer); + + expect(stateContainerWrapper.exists()).toBe(true); + expect(stateContainerWrapper.props('status')).toBe('loading'); + expect(stateContainerWrapper.text().trim()).toBe('Refreshing now'); }); }); @@ -146,9 +151,9 @@ describe('MRWidgetFailedToMerge', () => { }); it('renders refresh button', () => { - expect( - wrapper.find('[data-testid="merge-request-failed-refresh-button"]').text().trim(), - ).toBe('Refresh now'); + expect(wrapper.findComponent(StateContainer).props('actions')).toMatchObject([ + { text: 'Refresh now', onClick: expect.any(Function) }, + ]); }); it('renders remaining time', () => { 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 index 63e93074857..c6e7198c678 100644 --- 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 @@ -1,25 +1,27 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; import notAllowedComponent from '~/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; describe('MRWidgetNotAllowed', () => { - let vm; + let wrapper; + beforeEach(() => { - const Component = Vue.extend(notAllowedComponent); - vm = mountComponent(Component); + wrapper = shallowMount(notAllowedComponent); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); it('renders success icon', () => { - expect(vm.$el.querySelector('.ci-status-icon-success')).not.toBe(null); + expect(wrapper.findComponent(StatusIcon).exists()).toBe(true); + expect(wrapper.findComponent(StatusIcon).props().status).toBe('success'); }); it('renders informative text', () => { - expect(vm.$el.innerText).toContain('Ready to be merged automatically.'); - expect(vm.$el.innerText).toContain( + expect(wrapper.text()).toContain('Ready to be merged automatically.'); + expect(wrapper.text()).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_pipeline_blocked_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked_spec.js index 9b10b078e89..4219ad70b4c 100644 --- 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 @@ -1,26 +1,25 @@ -import { shallowMount, mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import PipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; describe('MRWidgetPipelineBlocked', () => { let wrapper; - const createWrapper = (mountFn = shallowMount) => { - wrapper = mountFn(PipelineBlockedComponent); - }; + beforeEach(() => { + wrapper = shallowMount(PipelineBlockedComponent); + }); afterEach(() => { wrapper.destroy(); + wrapper = null; }); - it('renders warning icon', () => { - createWrapper(mount); - - expect(wrapper.find('.ci-status-icon-warning').exists()).toBe(true); + it('renders error icon', () => { + expect(wrapper.findComponent(StatusIcon).exists()).toBe(true); + expect(wrapper.findComponent(StatusIcon).props().status).toBe('failed'); }); 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 index 4e44ac539f2..d5619d4996d 100644 --- 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 @@ -1,11 +1,17 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import PipelineFailed from '~/vue_merge_request_widget/components/states/pipeline_failed.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; describe('PipelineFailed', () => { let wrapper; const createComponent = () => { - wrapper = shallowMount(PipelineFailed); + wrapper = shallowMount(PipelineFailed, { + stubs: { + GlSprintf, + }, + }); }; beforeEach(() => { @@ -17,7 +23,14 @@ describe('PipelineFailed', () => { wrapper = null; }); + it('should render error status icon', () => { + expect(wrapper.findComponent(StatusIcon).exists()).toBe(true); + expect(wrapper.findComponent(StatusIcon).props().status).toBe('failed'); + }); + it('should render error message with a disabled merge button', () => { - expect(wrapper.element).toMatchSnapshot(); + expect(wrapper.text()).toContain('Merge blocked: pipeline must succeed.'); + expect(wrapper.text()).toContain('Push a commit that fixes the failure'); + expect(wrapper.findComponent(GlLink).text()).toContain('learn about other solutions'); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js index 6e89cd41559..9a6bf66909e 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -111,7 +111,7 @@ const createComponent = ( }; const findCheckboxElement = () => wrapper.find(SquashBeforeMerge); -const findCommitEditElements = () => wrapper.findAll(CommitEdit); +const findCommitEditElements = () => wrapper.findAllComponents(CommitEdit); const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown); const findFirstCommitEditLabel = () => findCommitEditElements().at(0).props('label'); const findTipLink = () => wrapper.find(GlSprintf); @@ -549,7 +549,7 @@ describe('ReadyToMerge', () => { ${'squashIsSelected'} | ${'selected'} | ${'value'} | ${false} ${'squashIsSelected'} | ${'unselected'} | ${'value'} | ${false} `( - 'is $state when squashIsReadonly returns $expectation ', + 'is $state when squashIsReadonly returns $expectation', ({ squashState, prop, expectation }) => { createComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: true, [squashState]: expectation }, 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 index 8f20d6a8fc9..7a868eb8cc9 100644 --- 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 @@ -16,7 +16,8 @@ describe('MrWidgetTerraformConainer', () => { 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 findPlans = () => + wrapper.findAllComponents(TerraformPlan).wrappers.map((x) => x.props('plan')); const mockPollingApi = (response, body, header) => { mock.onGet(propsData.endpoint).reply(response, body, header); 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 index 6bb718082a4..8dbee9b370c 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js @@ -12,8 +12,8 @@ describe('MR Widget App', () => { }); }; - it('mounts the component', () => { + it('does not mount if widgets array is empty', () => { createComponent(); - expect(wrapper.findByTestId('mr-widget-app').exists()).toBe(true); + expect(wrapper.findByTestId('mr-widget-app').exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js new file mode 100644 index 00000000000..c2128d3ff33 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_section_spec.js @@ -0,0 +1,39 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WidgetContentSection from '~/vue_merge_request_widget/components/widget/widget_content_section.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; + +describe('~/vue_merge_request_widget/components/widget/widget_content_section.vue', () => { + let wrapper; + + const findStatusIcon = () => wrapper.findComponent(StatusIcon); + + const createComponent = ({ propsData, slots } = {}) => { + wrapper = shallowMountExtended(WidgetContentSection, { + propsData: { + widgetName: 'MyWidget', + ...propsData, + }, + slots, + }); + }; + + it('does not render the status icon when it is not provided', () => { + createComponent(); + expect(findStatusIcon().exists()).toBe(false); + }); + + it('renders the status icon when provided', () => { + createComponent({ propsData: { statusIconName: 'failed' } }); + expect(findStatusIcon().exists()).toBe(true); + }); + + it('renders the default slot', () => { + createComponent({ + slots: { + default: 'Hello world', + }, + }); + + expect(wrapper.findByText('Hello world').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 index 3c08ffdef18..b67b5703ad5 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js @@ -3,16 +3,21 @@ 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 ActionButtons from '~/vue_merge_request_widget/components/action_buttons.vue'; import Widget from '~/vue_merge_request_widget/components/widget/widget.vue'; describe('MR Widget', () => { let wrapper; const findStatusIcon = () => wrapper.findComponent(StatusIcon); + const findExpandedSection = () => wrapper.findByTestId('widget-extension-collapsed-section'); + const findActionButtons = () => wrapper.findComponent(ActionButtons); + const findToggleButton = () => wrapper.findByTestId('toggle-button'); const createComponent = ({ propsData, slots } = {}) => { wrapper = shallowMountExtended(Widget, { propsData: { + isCollapsible: false, loadingText: 'Loading widget', widgetName: 'MyWidget', value: { @@ -38,14 +43,15 @@ describe('MR Widget', () => { createComponent({ propsData: { fetchCollapsedData } }); await waitForPromises(); expect(fetchCollapsedData).toHaveBeenCalled(); - expect(wrapper.vm.error).toBe(null); + expect(wrapper.vm.summaryError).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'); + expect(wrapper.findByText('Failed to load').exists()).toBe(true); + expect(findStatusIcon().props()).toMatchObject({ iconName: 'failed', isLoading: false }); }); it('displays loading icon until request is made and then displays status icon when the request is complete', async () => { @@ -111,7 +117,7 @@ describe('MR Widget', () => { jest.spyOn(Sentry, 'captureException').mockImplementation(); createComponent({ propsData: { - fetchCollapsedData: async () => Promise.reject(error), + fetchCollapsedData: () => Promise.reject(error), }, }); await waitForPromises(); @@ -125,7 +131,7 @@ describe('MR Widget', () => { createComponent({ propsData: { summary: 'Hello world', - fetchCollapsedData: async () => Promise.resolve(), + fetchCollapsedData: () => Promise.resolve(), }, }); @@ -137,7 +143,7 @@ describe('MR Widget', () => { it('displays the summary slot when provided', () => { createComponent({ propsData: { - fetchCollapsedData: async () => Promise.resolve(), + fetchCollapsedData: () => Promise.resolve(), }, slots: { summary: 'More complex summary', @@ -149,19 +155,167 @@ describe('MR Widget', () => { ); }); - it('displays the content slot when provided', () => { + it('does not display action buttons if actionButtons is not provided', () => { createComponent({ propsData: { - fetchCollapsedData: async () => Promise.resolve(), + fetchCollapsedData: () => Promise.resolve(), + }, + }); + + expect(findActionButtons().exists()).toBe(false); + }); + + it('does display action buttons if actionButtons is provided', () => { + const actionButtons = [{ text: 'click-me', href: '#' }]; + + createComponent({ + propsData: { + fetchCollapsedData: () => Promise.resolve(), + actionButtons, + }, + }); + + expect(findActionButtons().props('tertiaryButtons')).toEqual(actionButtons); + }); + }); + + describe('handle collapse toggle', () => { + it('displays the toggle button correctly', () => { + createComponent({ + propsData: { + isCollapsible: true, + fetchCollapsedData: () => Promise.resolve(), }, slots: { content: 'More complex content', }, }); - expect(wrapper.findByTestId('widget-extension-collapsed-section').text()).toBe( - 'More complex content', - ); + expect(findToggleButton().attributes('title')).toBe('Show details'); + expect(findToggleButton().attributes('aria-label')).toBe('Show details'); + }); + + it('does not display the content slot until toggle is clicked', async () => { + createComponent({ + propsData: { + isCollapsible: true, + fetchCollapsedData: () => Promise.resolve(), + }, + slots: { + content: 'More complex content', + }, + }); + + expect(findExpandedSection().exists()).toBe(false); + findToggleButton().vm.$emit('click'); + await nextTick(); + expect(findExpandedSection().text()).toBe('More complex content'); + }); + + it('does not display the toggle button if isCollapsible is false', () => { + createComponent({ + propsData: { + isCollapsible: false, + fetchCollapsedData: () => Promise.resolve(), + }, + }); + + expect(findToggleButton().exists()).toBe(false); + }); + + it('fetches expanded data when clicked for the first time', async () => { + const mockDataCollapsed = { + headers: {}, + status: 200, + data: { vulnerabilities: [{ vuln: 1 }] }, + }; + + const mockDataExpanded = { + headers: {}, + status: 200, + data: { vulnerabilities: [{ vuln: 2 }] }, + }; + + const fetchExpandedData = jest.fn().mockResolvedValue(mockDataExpanded); + + createComponent({ + propsData: { + isCollapsible: true, + fetchCollapsedData: () => Promise.resolve(mockDataCollapsed), + fetchExpandedData, + }, + }); + + findToggleButton().vm.$emit('click'); + await waitForPromises(); + + // First fetches the collapsed data + expect(wrapper.emitted('input')[0][0]).toEqual({ + collapsed: mockDataCollapsed.data, + expanded: null, + }); + + // Then fetches the expanded data + expect(wrapper.emitted('input')[1][0]).toEqual({ + collapsed: null, + expanded: mockDataExpanded.data, + }); + + // Triggering a click does not call the expanded data again + findToggleButton().vm.$emit('click'); + await waitForPromises(); + expect(fetchExpandedData).toHaveBeenCalledTimes(1); + }); + + it('allows refetching when fetch expanded data returns an error', async () => { + const fetchExpandedData = jest.fn().mockRejectedValue({ error: true }); + + createComponent({ + propsData: { + isCollapsible: true, + fetchCollapsedData: () => Promise.resolve([]), + fetchExpandedData, + }, + }); + + findToggleButton().vm.$emit('click'); + await waitForPromises(); + + // First fetches the collapsed data + expect(wrapper.emitted('input')[0][0]).toEqual({ + collapsed: undefined, + expanded: null, + }); + + expect(fetchExpandedData).toHaveBeenCalledTimes(1); + expect(wrapper.emitted('input')).toHaveLength(1); // Should not an emit an input call because request failed + + findToggleButton().vm.$emit('click'); + await waitForPromises(); + expect(fetchExpandedData).toHaveBeenCalledTimes(2); + }); + + it('resets the error message when another request is fetched', async () => { + const fetchExpandedData = jest.fn().mockRejectedValue({ error: true }); + + createComponent({ + propsData: { + isCollapsible: true, + fetchCollapsedData: () => Promise.resolve([]), + fetchExpandedData, + }, + }); + + findToggleButton().vm.$emit('click'); + await waitForPromises(); + + expect(wrapper.findByText('Failed to load').exists()).toBe(true); + fetchExpandedData.mockImplementation(() => new Promise(() => {})); + + findToggleButton().vm.$emit('click'); + await nextTick(); + + expect(wrapper.findByText('Failed to load').exists()).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 index a285d26f404..a8912405fa8 100644 --- a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js +++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js @@ -189,7 +189,7 @@ describe('DeploymentAction component', () => { }); }); - describe('it should call the executeAction method ', () => { + describe('it should call the executeAction method', () => { beforeEach(async () => { jest.spyOn(wrapper.vm, 'executeAction').mockImplementation(); diff --git a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js index 5c1d3c8e8e8..82743275739 100644 --- a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js @@ -15,6 +15,7 @@ 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 newFailedTestWithNullFilesReport from 'jest/reports/mock_data/new_failures_with_null_files_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'; @@ -157,6 +158,15 @@ describe('Test report extension', () => { ); }); + it('hides copy failed tests button when endpoint returns null files', async () => { + mockApi(httpStatusCodes.OK, newFailedTestWithNullFilesReport); + createComponent(); + + await waitForPromises(); + + expect(findCopyFailedSpecsBtn().exists()).toBe(false); + }); + it('copy failed tests button updates tooltip text when clicked', async () => { mockApi(httpStatusCodes.OK, newFailedTestReports); createComponent(); 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 index 819841317f9..cc894f94f80 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -845,7 +845,7 @@ describe('MrWidgetOptions', () => { ${'closed'} | ${false} | ${'hides'} ${'merged'} | ${true} | ${'shows'} ${'open'} | ${true} | ${'shows'} - `('it $showText merge error when state is $state', ({ state, show }) => { + `('$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); @@ -1133,7 +1133,7 @@ describe('MrWidgetOptions', () => { widgetName | nonStandardEvent ${'WidgetCodeQuality'} | ${'i_testing_code_quality_widget_total'} ${'WidgetTerraform'} | ${'i_testing_terraform_widget_total'} - ${'WidgetIssues'} | ${'i_testing_load_performance_widget_total'} + ${'WidgetIssues'} | ${'i_testing_issues_widget_total'} ${'WidgetTestReport'} | ${'i_testing_summary_widget_total'} `( "sends non-standard events for the '$widgetName' widget", 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 index 22562bb4ddb..1a109aad911 100644 --- 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 @@ -60,7 +60,7 @@ describe('Artifacts App Store Actions', () => { }); describe('success', () => { - it('dispatches requestArtifacts and receiveArtifactsSuccess ', () => { + it('dispatches requestArtifacts and receiveArtifactsSuccess', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [ { text: 'result.txt', @@ -103,7 +103,7 @@ describe('Artifacts App Store Actions', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); }); - it('dispatches requestArtifacts and receiveArtifactsError ', () => { + it('dispatches requestArtifacts and receiveArtifactsError', () => { return testAction( fetchArtifacts, null, -- cgit v1.2.3