diff options
Diffstat (limited to 'spec/frontend/pipelines/graph')
10 files changed, 1776 insertions, 604 deletions
diff --git a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js new file mode 100644 index 00000000000..3b1909b6564 --- /dev/null +++ b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js @@ -0,0 +1,306 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { setHTMLFixture } from 'helpers/fixtures'; +import PipelineStore from '~/pipelines/stores/pipeline_store'; +import GraphComponentLegacy from '~/pipelines/components/graph/graph_component_legacy.vue'; +import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue'; +import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; +import graphJSON from './mock_data_legacy'; +import linkedPipelineJSON from './linked_pipelines_mock_data'; +import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; + +describe('graph component', () => { + let store; + let mediator; + let wrapper; + + const findExpandPipelineBtn = () => wrapper.find('[data-testid="expand-pipeline-button"]'); + const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expand-pipeline-button"]'); + const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy); + const findStageColumnAt = i => findStageColumns().at(i); + + beforeEach(() => { + mediator = new PipelinesMediator({ endpoint: '' }); + store = new PipelineStore(); + store.storePipeline(linkedPipelineJSON); + + setHTMLFixture('<div class="layout-page"></div>'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('while is loading', () => { + it('should render a loading icon', () => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: true, + pipeline: {}, + mediator, + }, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('with data', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: graphJSON, + mediator, + }, + }); + }); + + it('renders the graph', () => { + expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); + expect(wrapper.find('.loading-icon').exists()).toBe(false); + expect(wrapper.find('.stage-column-list').exists()).toBe(true); + }); + + it('renders columns in the graph', () => { + expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length); + }); + }); + + describe('when linked pipelines are present', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + }); + + describe('rendered output', () => { + it('should include the pipelines graph', () => { + expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); + }); + + it('should not include the loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + it('should include the stage column', () => { + expect(findStageColumnAt(0).exists()).toBe(true); + }); + + it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => { + expect(findStageColumnAt(0).classes()).toEqual( + expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']), + ); + }); + + it('should include the left-margin class on the second child', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + + it('should include the left-connector class in the build of the second child', () => { + expect( + findStageColumnAt(1) + .find('.build:nth-child(1)') + .classes('left-connector'), + ).toBe(true); + }); + + it('should include the js-has-linked-pipelines flag', () => { + expect(wrapper.find('.js-has-linked-pipelines').exists()).toBe(true); + }); + }); + + describe('computeds and methods', () => { + describe('capitalizeStageName', () => { + it('it capitalizes the stage name', () => { + expect( + wrapper + .findAll('.stage-column .stage-name') + .at(1) + .text(), + ).toBe('Prebuild'); + }); + }); + + describe('stageConnectorClass', () => { + it('it returns left-margin when there is a triggerer', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + }); + }); + + describe('linked pipelines components', () => { + beforeEach(() => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + }); + + it('should render an upstream pipelines column at first position', () => { + expect(wrapper.find(LinkedPipelinesColumnLegacy).exists()).toBe(true); + expect(wrapper.find('.stage-column .stage-name').text()).toBe('Upstream'); + }); + + it('should render a downstream pipelines column at last position', () => { + const stageColumnNames = wrapper.findAll('.stage-column .stage-name'); + + expect(wrapper.find(LinkedPipelinesColumnLegacy).exists()).toBe(true); + expect(stageColumnNames.at(stageColumnNames.length - 1).text()).toBe('Downstream'); + }); + + describe('triggered by', () => { + describe('on click', () => { + it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', () => { + const btnWrapper = findExpandPipelineBtn(); + + btnWrapper.trigger('click'); + + btnWrapper.vm.$nextTick(() => { + expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([ + store.state.pipeline.triggered_by, + ]); + }); + }); + }); + + describe('with expanded pipeline', () => { + it('should render expanded pipeline', done => { + // expand the pipeline + store.state.pipeline.triggered_by[0].isExpanded = true; + + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + + Vue.nextTick() + .then(() => { + expect(wrapper.find('.js-upstream-pipeline-12').exists()).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('triggered', () => { + describe('on click', () => { + it('should emit `onClickTriggered`', () => { + // We have to mock this method since we do both style change and + // emit and event, not mocking returns an error. + wrapper.setMethods({ + handleClickedDownstream: jest.fn(() => + wrapper.vm.$emit('onClickTriggered', ...store.state.pipeline.triggered), + ), + }); + + const btnWrappers = findAllExpandPipelineBtns(); + const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); + + downstreamBtnWrapper.trigger('click'); + + downstreamBtnWrapper.vm.$nextTick(() => { + expect(wrapper.emitted().onClickTriggered).toEqual([store.state.pipeline.triggered]); + }); + }); + }); + + describe('with expanded pipeline', () => { + it('should render expanded pipeline', done => { + // expand the pipeline + store.state.pipeline.triggered[0].isExpanded = true; + + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: store.state.pipeline, + mediator, + }, + }); + + Vue.nextTick() + .then(() => { + expect(wrapper.find('.js-downstream-pipeline-34993051')).not.toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when column requests a refresh', () => { + beforeEach(() => { + findStageColumnAt(0).vm.$emit('refreshPipelineGraph'); + }); + + it('refreshPipelineGraph is emitted', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); + }); + }); + }); + }); + + describe('when linked pipelines are not present', () => { + beforeEach(() => { + const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null }); + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline, + mediator, + }, + }); + }); + + describe('rendered output', () => { + it('should include the first column with a no margin', () => { + const firstColumn = wrapper.find('.stage-column'); + + expect(firstColumn.classes('no-margin')).toBe(true); + }); + + it('should not render a linked pipelines column', () => { + expect(wrapper.find('.linked-pipelines-column').exists()).toBe(false); + }); + }); + + describe('stageConnectorClass', () => { + it('it returns no-margin when no triggerer and there is one job', () => { + expect(findStageColumnAt(0).classes('no-margin')).toBe(true); + }); + + it('it returns left-margin when no triggerer and not the first stage', () => { + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); + }); + }); + + describe('capitalizeStageName', () => { + it('capitalizes and escapes stage name', () => { + wrapper = mount(GraphComponentLegacy, { + propsData: { + isLoading: false, + pipeline: graphJSON, + mediator, + }, + }); + + expect(findStageColumnAt(1).props('title')).toEqual( + 'Deploy <img src=x onerror=alert(document.domain)>', + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 5a17be1af23..7572dd83798 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,305 +1,83 @@ -import Vue from 'vue'; -import { mount } from '@vue/test-utils'; -import { setHTMLFixture } from 'helpers/fixtures'; -import PipelineStore from '~/pipelines/stores/pipeline_store'; -import graphComponent from '~/pipelines/components/graph/graph_component.vue'; +import { mount, shallowMount } from '@vue/test-utils'; +import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; -import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; -import graphJSON from './mock_data'; -import linkedPipelineJSON from './linked_pipelines_mock_data'; -import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; +import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; +import { GRAPHQL } from '~/pipelines/components/graph/constants'; +import { + generateResponse, + mockPipelineResponse, + pipelineWithUpstreamDownstream, +} from './mock_data'; describe('graph component', () => { - let store; - let mediator; let wrapper; - const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); - const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]'); + const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findStageColumns = () => wrapper.findAll(StageColumnComponent); - const findStageColumnAt = i => findStageColumns().at(i); - beforeEach(() => { - mediator = new PipelinesMediator({ endpoint: '' }); - store = new PipelineStore(); - store.storePipeline(linkedPipelineJSON); - - setHTMLFixture('<div class="layout-page"></div>'); - }); + const defaultProps = { + pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), + }; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(PipelineGraph, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + dataMethod: GRAPHQL, + }, + }); + }; afterEach(() => { wrapper.destroy(); wrapper = null; }); - describe('while is loading', () => { - it('should render a loading icon', () => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: true, - pipeline: {}, - mediator, - }, - }); - - expect(wrapper.find('.gl-spinner').exists()).toBe(true); - }); - }); - describe('with data', () => { beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: graphJSON, - mediator, - }, - }); + createComponent({ mountFn: mount }); }); - it('renders the graph', () => { - expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - expect(wrapper.find('.loading-icon').exists()).toBe(false); - expect(wrapper.find('.stage-column-list').exists()).toBe(true); - }); - - it('renders columns in the graph', () => { - expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length); - }); - }); - - describe('when linked pipelines are present', () => { - beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - }); - - describe('rendered output', () => { - it('should include the pipelines graph', () => { - expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - }); - - it('should not include the loading icon', () => { - expect(wrapper.find('.fa-spinner').exists()).toBe(false); - }); - - it('should include the stage column', () => { - expect(findStageColumnAt(0).exists()).toBe(true); - }); - - it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => { - expect(findStageColumnAt(0).classes()).toEqual( - expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']), - ); - }); - - it('should include the left-margin class on the second child', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - - it('should include the left-connector class in the build of the second child', () => { - expect( - findStageColumnAt(1) - .find('.build:nth-child(1)') - .classes('left-connector'), - ).toBe(true); - }); - - it('should include the js-has-linked-pipelines flag', () => { - expect(wrapper.find('.js-has-linked-pipelines').exists()).toBe(true); - }); - }); - - describe('computeds and methods', () => { - describe('capitalizeStageName', () => { - it('it capitalizes the stage name', () => { - expect( - wrapper - .findAll('.stage-column .stage-name') - .at(1) - .text(), - ).toBe('Prebuild'); - }); - }); - - describe('stageConnectorClass', () => { - it('it returns left-margin when there is a triggerer', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); - }); + it('renders the main columns in the graph', () => { + expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); }); - describe('linked pipelines components', () => { + describe('when column requests a refresh', () => { beforeEach(() => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); + findStageColumns() + .at(0) + .vm.$emit('refreshPipelineGraph'); }); - it('should render an upstream pipelines column at first position', () => { - expect(wrapper.find(linkedPipelinesColumn).exists()).toBe(true); - expect(wrapper.find('.stage-column .stage-name').text()).toBe('Upstream'); - }); - - it('should render a downstream pipelines column at last position', () => { - const stageColumnNames = wrapper.findAll('.stage-column .stage-name'); - - expect(wrapper.find(linkedPipelinesColumn).exists()).toBe(true); - expect(stageColumnNames.at(stageColumnNames.length - 1).text()).toBe('Downstream'); - }); - - describe('triggered by', () => { - describe('on click', () => { - it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', () => { - const btnWrapper = findExpandPipelineBtn(); - - btnWrapper.trigger('click'); - - btnWrapper.vm.$nextTick(() => { - expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([ - store.state.pipeline.triggered_by, - ]); - }); - }); - }); - - describe('with expanded pipeline', () => { - it('should render expanded pipeline', done => { - // expand the pipeline - store.state.pipeline.triggered_by[0].isExpanded = true; - - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - - Vue.nextTick() - .then(() => { - expect(wrapper.find('.js-upstream-pipeline-12').exists()).toBe(true); - }) - .then(done) - .catch(done.fail); - }); - }); - }); - - describe('triggered', () => { - describe('on click', () => { - it('should emit `onClickTriggered`', () => { - // We have to mock this method since we do both style change and - // emit and event, not mocking returns an error. - wrapper.setMethods({ - handleClickedDownstream: jest.fn(() => - wrapper.vm.$emit('onClickTriggered', ...store.state.pipeline.triggered), - ), - }); - - const btnWrappers = findAllExpandPipelineBtns(); - const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); - - downstreamBtnWrapper.trigger('click'); - - downstreamBtnWrapper.vm.$nextTick(() => { - expect(wrapper.emitted().onClickTriggered).toEqual([store.state.pipeline.triggered]); - }); - }); - }); - - describe('with expanded pipeline', () => { - it('should render expanded pipeline', done => { - // expand the pipeline - store.state.pipeline.triggered[0].isExpanded = true; - - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: store.state.pipeline, - mediator, - }, - }); - - Vue.nextTick() - .then(() => { - expect(wrapper.find('.js-downstream-pipeline-34993051')).not.toBeNull(); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('when column requests a refresh', () => { - beforeEach(() => { - findStageColumnAt(0).vm.$emit('refreshPipelineGraph'); - }); - - it('refreshPipelineGraph is emitted', () => { - expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); - }); - }); + it('refreshPipelineGraph is emitted', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); }); }); }); describe('when linked pipelines are not present', () => { beforeEach(() => { - const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null }); - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline, - mediator, - }, - }); + createComponent({ mountFn: mount }); }); - describe('rendered output', () => { - it('should include the first column with a no margin', () => { - const firstColumn = wrapper.find('.stage-column'); - - expect(firstColumn.classes('no-margin')).toBe(true); - }); - - it('should not render a linked pipelines column', () => { - expect(wrapper.find('.linked-pipelines-column').exists()).toBe(false); - }); - }); - - describe('stageConnectorClass', () => { - it('it returns no-margin when no triggerer and there is one job', () => { - expect(findStageColumnAt(0).classes('no-margin')).toBe(true); - }); - - it('it returns left-margin when no triggerer and not the first stage', () => { - expect(findStageColumnAt(1).classes('left-margin')).toBe(true); - }); + it('should not render a linked pipelines column', () => { + expect(findLinkedColumns()).toHaveLength(0); }); }); - describe('capitalizeStageName', () => { - it('capitalizes and escapes stage name', () => { - wrapper = mount(graphComponent, { - propsData: { - isLoading: false, - pipeline: graphJSON, - mediator, - }, + describe('when linked pipelines are present', () => { + beforeEach(() => { + createComponent({ + mountFn: mount, + props: { pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse) }, }); + }); - expect(findStageColumnAt(1).props('title')).toEqual( - 'Deploy <img src=x onerror=alert(document.domain)>', - ); + it('should render linked pipelines columns', () => { + expect(findLinkedColumns()).toHaveLength(2); }); }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js new file mode 100644 index 00000000000..875aaa48037 --- /dev/null +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -0,0 +1,124 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; +import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; +import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql'; +import { mockPipelineResponse } from './mock_data'; + +const defaultProvide = { + pipelineProjectPath: 'frog/amphibirama', + pipelineIid: '22', +}; + +describe('Pipeline graph wrapper', () => { + Vue.use(VueApollo); + + let wrapper; + const getAlert = () => wrapper.find(GlAlert); + const getLoadingIcon = () => wrapper.find(GlLoadingIcon); + const getGraph = () => wrapper.find(PipelineGraph); + + const createComponent = ({ + apolloProvider, + data = {}, + provide = defaultProvide, + mountFn = shallowMount, + } = {}) => { + wrapper = mountFn(PipelineGraphWrapper, { + provide, + apolloProvider, + data() { + return { + ...data, + }; + }, + }); + }; + + const createComponentWithApollo = ( + getPipelineDetailsHandler = jest.fn().mockResolvedValue(mockPipelineResponse), + ) => { + const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; + + const apolloProvider = createMockApollo(requestHandlers); + createComponent({ apolloProvider }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when data is loading', () => { + it('displays the loading icon', () => { + createComponentWithApollo(); + expect(getLoadingIcon().exists()).toBe(true); + }); + + it('does not display the alert', () => { + createComponentWithApollo(); + expect(getAlert().exists()).toBe(false); + }); + + it('does not display the graph', () => { + createComponentWithApollo(); + expect(getGraph().exists()).toBe(false); + }); + }); + + describe('when data has loaded', () => { + beforeEach(async () => { + createComponentWithApollo(); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('does not display the loading icon', () => { + expect(getLoadingIcon().exists()).toBe(false); + }); + + it('does not display the alert', () => { + expect(getAlert().exists()).toBe(false); + }); + + it('displays the graph', () => { + expect(getGraph().exists()).toBe(true); + }); + }); + + describe('when there is an error', () => { + beforeEach(async () => { + createComponentWithApollo(jest.fn().mockRejectedValue(new Error('GraphQL error'))); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }); + + it('does not display the loading icon', () => { + expect(getLoadingIcon().exists()).toBe(false); + }); + + it('displays the alert', () => { + expect(getAlert().exists()).toBe(true); + }); + + it('does not display the graph', () => { + expect(getGraph().exists()).toBe(false); + }); + }); + + describe('when refresh action is emitted', () => { + beforeEach(async () => { + createComponentWithApollo(); + jest.spyOn(wrapper.vm.$apollo.queries.pipeline, 'refetch'); + await wrapper.vm.$nextTick(); + getGraph().vm.$emit('refreshPipelineGraph'); + }); + + it('calls refetch', () => { + expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index 67986ca7739..fb005d628a9 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -17,7 +17,7 @@ describe('Linked pipeline', () => { const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]'); - const findExpandButton = () => wrapper.find('[data-testid="expandPipelineButton"]'); + const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); const createWrapper = (propsData, data = []) => { wrapper = mount(LinkedPipelineComponent, { @@ -40,20 +40,13 @@ describe('Linked pipeline', () => { projectId: invalidTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, + expanded: false, }; beforeEach(() => { createWrapper(props); }); - it('should render a list item as the containing element', () => { - expect(wrapper.element.tagName).toBe('LI'); - }); - - it('should render a button', () => { - expect(findButton().exists()).toBe(true); - }); - it('should render the project name', () => { expect(wrapper.text()).toContain(props.pipeline.project.name); }); @@ -105,12 +98,14 @@ describe('Linked pipeline', () => { projectId: validTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, + expanded: false, }; const upstreamProps = { ...downstreamProps, columnTitle: 'Upstream', type: UPSTREAM, + expanded: false, }; it('parent/child label container should exist', () => { @@ -173,7 +168,7 @@ describe('Linked pipeline', () => { `( '$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded', ({ pipelineType, anglePosition, expanded }) => { - createWrapper(pipelineType, { expanded }); + createWrapper({ ...pipelineType, expanded }); expect(findExpandButton().props('icon')).toBe(anglePosition); }, ); @@ -185,6 +180,7 @@ describe('Linked pipeline', () => { projectId: invalidTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, + expanded: false, }; beforeEach(() => { @@ -202,6 +198,7 @@ describe('Linked pipeline', () => { projectId: validTriggeredPipelineId, columnTitle: 'Downstream', type: DOWNSTREAM, + expanded: false, }; beforeEach(() => { @@ -219,10 +216,7 @@ describe('Linked pipeline', () => { jest.spyOn(wrapper.vm.$root, '$emit'); findButton().trigger('click'); - expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual([ - 'bv::hide::tooltip', - 'js-linked-pipeline-34993051', - ]); + expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual(['bv::hide::tooltip']); }); it('should emit downstreamHovered with job name on mouseover', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js new file mode 100644 index 00000000000..b6c700c65d2 --- /dev/null +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; +import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; +import { UPSTREAM } from '~/pipelines/components/graph/constants'; +import mockData from './linked_pipelines_mock_data'; + +describe('Linked Pipelines Column', () => { + const propsData = { + columnTitle: 'Upstream', + linkedPipelines: mockData.triggered, + graphPosition: 'right', + projectId: 19, + type: UPSTREAM, + }; + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(LinkedPipelinesColumnLegacy, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the pipeline orientation', () => { + const titleElement = wrapper.find('.linked-pipelines-column-title'); + + expect(titleElement.text()).toBe(propsData.columnTitle); + }); + + it('renders the correct number of linked pipelines', () => { + const linkedPipelineElements = wrapper.findAll(LinkedPipeline); + + expect(linkedPipelineElements.length).toBe(propsData.linkedPipelines.length); + }); + + it('renders cross project triangle when column is upstream', () => { + expect(wrapper.find('.cross-project-triangle').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index e6ae3154d1d..37eb5f900dd 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -1,40 +1,120 @@ -import { shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; -import { UPSTREAM } from '~/pipelines/components/graph/constants'; -import mockData from './linked_pipelines_mock_data'; +import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql'; +import { DOWNSTREAM, GRAPHQL } from '~/pipelines/components/graph/constants'; +import { LOAD_FAILURE } from '~/pipelines/constants'; +import { + mockPipelineResponse, + pipelineWithUpstreamDownstream, + wrappedPipelineReturn, +} from './mock_data'; + +const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse); describe('Linked Pipelines Column', () => { - const propsData = { + const defaultProps = { columnTitle: 'Upstream', - linkedPipelines: mockData.triggered, - graphPosition: 'right', - projectId: 19, - type: UPSTREAM, + linkedPipelines: processedPipeline.downstream, + type: DOWNSTREAM, }; + let wrapper; + const findLinkedColumnTitle = () => wrapper.find('[data-testid="linked-column-title"]'); + const findLinkedPipelineElements = () => wrapper.findAll(LinkedPipeline); + const findPipelineGraph = () => wrapper.find(PipelineGraph); + const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); - beforeEach(() => { - wrapper = shallowMount(LinkedPipelinesColumn, { propsData }); - }); + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const createComponent = ({ apolloProvider, mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(LinkedPipelinesColumn, { + apolloProvider, + localVue, + propsData: { + ...defaultProps, + ...props, + }, + provide: { + dataMethod: GRAPHQL, + }, + }); + }; + + const createComponentWithApollo = ( + mountFn = shallowMount, + getPipelineDetailsHandler = jest.fn().mockResolvedValue(wrappedPipelineReturn), + ) => { + const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; + + const apolloProvider = createMockApollo(requestHandlers); + createComponent({ apolloProvider, mountFn }); + }; afterEach(() => { wrapper.destroy(); + wrapper = null; }); - it('renders the pipeline orientation', () => { - const titleElement = wrapper.find('.linked-pipelines-column-title'); + describe('it renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the pipeline title', () => { + expect(findLinkedColumnTitle().text()).toBe(defaultProps.columnTitle); + }); - expect(titleElement.text()).toBe(propsData.columnTitle); + it('renders the correct number of linked pipelines', () => { + expect(findLinkedPipelineElements()).toHaveLength(defaultProps.linkedPipelines.length); + }); }); - it('renders the correct number of linked pipelines', () => { - const linkedPipelineElements = wrapper.findAll(LinkedPipeline); + describe('click action', () => { + const clickExpandButton = async () => { + await findExpandButton().trigger('click'); + await wrapper.vm.$nextTick(); + }; - expect(linkedPipelineElements.length).toBe(propsData.linkedPipelines.length); - }); + const clickExpandButtonAndAwaitTimers = async () => { + await clickExpandButton(); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + }; + + describe('when successful', () => { + beforeEach(() => { + createComponentWithApollo(mount); + }); + + it('toggles the pipeline visibility', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().exists()).toBe(true); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + + describe('on error', () => { + beforeEach(() => { + createComponentWithApollo(mount, jest.fn().mockRejectedValue(new Error('GraphQL error'))); + }); + + it('emits the error', async () => { + await clickExpandButton(); + expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]); + }); - it('renders cross project triangle when column is upstream', () => { - expect(wrapper.find('.cross-project-triangle').exists()).toBe(true); + it('does not show the pipeline', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index a4a5d78f906..d53a11eea0e 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -1,261 +1,665 @@ -export default { - id: 123, - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', - }, - active: false, - coverage: null, - path: '/root/ci-mock/pipelines/123', - details: { - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - duration: 9, - finished_at: '2017-04-19T14:30:27.542Z', - stages: [ - { - name: 'test', - title: 'test: passed', - groups: [ - { - name: 'test', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4153', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', +import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; + +export const mockPipelineResponse = { + data: { + project: { + __typename: 'Project', + pipeline: { + __typename: 'Pipeline', + id: 163, + iid: '22', + downstream: null, + upstream: null, + stages: { + __typename: 'CiStageConnection', + nodes: [ + { + __typename: 'CiStage', + name: 'build', + status: { + __typename: 'DetailedStatus', + action: null, }, - }, - jobs: [ - { - id: 4153, - name: 'test', - build_path: '/root/ci-mock/builds/4153', - retry_path: '/root/ci-mock/builds/4153/retry', - playable: false, - created_at: '2017-04-13T09:25:18.959Z', - updated_at: '2017-04-13T09:25:23.118Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4153', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4153/retry', - method: 'post', + groups: { + __typename: 'CiGroupConnection', + nodes: [ + { + __typename: 'CiGroup', + name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1482', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1482/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, }, - }, - }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123#test', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - path: '/root/ci-mock/pipelines/123#test', - dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', - }, - { - name: 'deploy <img src=x onerror=alert(document.domain)>', - title: 'deploy: passed', - groups: [ - { - name: 'deploy to production', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4166', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4166/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 4166, - name: 'deploy to production', - build_path: '/root/ci-mock/builds/4166', - retry_path: '/root/ci-mock/builds/4166/retry', - playable: false, - created_at: '2017-04-19T14:29:46.463Z', - updated_at: '2017-04-19T14:30:27.498Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4166', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4166/retry', - method: 'post', + { + __typename: 'CiGroup', + name: 'build_b', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_b', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1515', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1515/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, }, - }, - }, - ], - }, - { - name: 'deploy to staging', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4159', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4159/retry', - method: 'post', + { + __typename: 'CiGroup', + name: 'build_c', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1484', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1484/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'build_d', + size: 3, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 1/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1485', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1485/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1486', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1486/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + { + __typename: 'CiJob', + name: 'build_d 3/3', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1487', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1487/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [], + }, + }, + ], + }, + }, + ], }, }, - jobs: [ - { - id: 4159, - name: 'deploy to staging', - build_path: '/root/ci-mock/builds/4159', - retry_path: '/root/ci-mock/builds/4159/retry', - playable: false, - created_at: '2017-04-18T16:32:08.420Z', - updated_at: '2017-04-18T16:32:12.631Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/builds/4159', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4159/retry', - method: 'post', + { + __typename: 'CiStage', + name: 'test', + status: { + __typename: 'DetailedStatus', + action: null, + }, + groups: { + __typename: 'CiGroupConnection', + nodes: [ + { + __typename: 'CiGroup', + name: 'test_a', + size: 1, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_a', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1514', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1514/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_b', + size: 2, + status: { + __typename: 'DetailedStatus', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_b 1/2', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1489', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1489/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 3/3', + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + }, + { + __typename: 'CiJob', + name: 'build_d 1/3', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + { + __typename: 'CiJob', + name: 'test_b 2/2', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1490', + group: 'success', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/abcd-dag/-/jobs/1490/retry', + title: 'Retry', + }, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_d 3/3', + }, + { + __typename: 'CiJob', + name: 'build_d 2/3', + }, + { + __typename: 'CiJob', + name: 'build_d 1/3', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_c', + size: 1, + status: { + __typename: 'DetailedStatus', + label: null, + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_c', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/kinder-pipe/-/pipelines/154', + group: 'success', + action: null, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_c', + }, + { + __typename: 'CiJob', + name: 'build_b', + }, + { + __typename: 'CiJob', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, + }, + ], + }, + }, + { + __typename: 'CiGroup', + name: 'test_d', + size: 1, + status: { + __typename: 'DetailedStatus', + label: null, + group: 'success', + icon: 'status_success', + }, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'test_d', + scheduledAt: null, + status: { + __typename: 'DetailedStatus', + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/abcd-dag/-/pipelines/153', + group: 'success', + action: null, + }, + needs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + name: 'build_b', + }, + ], + }, + }, + ], + }, }, - }, + ], }, - ], - }, - ], - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/123#deploy', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + ], }, - path: '/root/ci-mock/pipelines/123#deploy', - dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy', - }, - ], - artifacts: [], - manual_actions: [ - { - name: 'deploy to production', - path: '/root/ci-mock/builds/4166/play', - playable: false, }, - ], + }, }, - flags: { - latest: true, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: false, - cancelable: false, +}; + +export const downstream = { + nodes: [ + { + id: 175, + iid: '31', + path: '/root/elemenohpee/-/pipelines/175', + status: { + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: { + name: 'test_c', + __typename: 'CiJob', + }, + project: { + id: 'gid://gitlab/Project/25', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + __typename: 'Project', + }, + __typename: 'Pipeline', + multiproject: true, + }, + { + id: 181, + iid: '27', + path: '/root/abcd-dag/-/pipelines/181', + status: { + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: { + name: 'test_d', + __typename: 'CiJob', + }, + project: { + id: 'gid://gitlab/Project/23', + name: 'abcd-dag', + fullPath: 'root/abcd-dag', + __typename: 'Project', + }, + __typename: 'Pipeline', + multiproject: false, + }, + ], +}; + +export const upstream = { + id: 161, + iid: '24', + path: '/root/abcd-dag/-/pipelines/161', + status: { + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', }, - ref: { - name: 'master', - path: '/root/ci-mock/tree/master', - tag: false, - branch: true, + sourceJob: null, + project: { + id: 'gid://gitlab/Project/23', + name: 'abcd-dag', + fullPath: 'root/abcd-dag', + __typename: 'Project', }, - commit: { - id: '798e5f902592192afaba73f4668ae30e56eae492', - short_id: '798e5f90', - title: "Merge branch 'new-branch' into 'master'\r", - created_at: '2017-04-13T10:25:17.000+01:00', - parent_ids: [ - '54d483b1ed156fbbf618886ddf7ab023e24f8738', - 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', - ], - message: - "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", - author_name: 'Root', - author_email: 'admin@example.com', - authored_date: '2017-04-13T10:25:17.000+01:00', - committer_name: 'Root', - committer_email: 'admin@example.com', - committed_date: '2017-04-13T10:25:17.000+01:00', - author: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: null, - web_url: 'http://localhost:3000/root', + __typename: 'Pipeline', + multiproject: true, +}; + +export const wrappedPipelineReturn = { + data: { + project: { + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/175', + iid: '38', + downstream: { + nodes: [], + }, + upstream: { + id: 'gid://gitlab/Ci::Pipeline/174', + iid: '37', + path: '/root/elemenohpee/-/pipelines/174', + status: { + group: 'success', + label: 'passed', + icon: 'status_success', + }, + sourceJob: { + name: 'test_c', + }, + project: { + id: 'gid://gitlab/Project/25', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + }, + }, + stages: { + nodes: [ + { + name: 'build', + status: { + action: null, + }, + groups: { + nodes: [ + { + status: { + label: 'passed', + group: 'success', + icon: 'status_success', + }, + name: 'build_n', + size: 1, + jobs: { + nodes: [ + { + name: 'build_n', + scheduledAt: null, + needs: { + nodes: [], + }, + status: { + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/elemenohpee/-/jobs/1662', + group: 'success', + action: { + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/elemenohpee/-/jobs/1662/retry', + title: 'Retry', + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, }, - author_gravatar_url: null, - commit_url: - 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', - commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', }, - created_at: '2017-04-13T09:25:18.881Z', - updated_at: '2017-04-19T14:30:27.561Z', +}; + +export const generateResponse = (raw, mockPath) => unwrapPipelineData(mockPath, raw.data); + +export const pipelineWithUpstreamDownstream = base => { + const pip = { ...base }; + pip.data.project.pipeline.downstream = downstream; + pip.data.project.pipeline.upstream = upstream; + + return generateResponse(pip, 'root/abcd-dag'); }; diff --git a/spec/frontend/pipelines/graph/mock_data_legacy.js b/spec/frontend/pipelines/graph/mock_data_legacy.js new file mode 100644 index 00000000000..a4a5d78f906 --- /dev/null +++ b/spec/frontend/pipelines/graph/mock_data_legacy.js @@ -0,0 +1,261 @@ +export default { + id: 123, + user: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + }, + active: false, + coverage: null, + path: '/root/ci-mock/pipelines/123', + details: { + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + duration: 9, + finished_at: '2017-04-19T14:30:27.542Z', + stages: [ + { + name: 'test', + title: 'test: passed', + groups: [ + { + name: 'test', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4153', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4153, + name: 'test', + build_path: '/root/ci-mock/builds/4153', + retry_path: '/root/ci-mock/builds/4153/retry', + playable: false, + created_at: '2017-04-13T09:25:18.959Z', + updated_at: '2017-04-13T09:25:23.118Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4153', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123#test', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + path: '/root/ci-mock/pipelines/123#test', + dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', + }, + { + name: 'deploy <img src=x onerror=alert(document.domain)>', + title: 'deploy: passed', + groups: [ + { + name: 'deploy to production', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4166', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4166/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4166, + name: 'deploy to production', + build_path: '/root/ci-mock/builds/4166', + retry_path: '/root/ci-mock/builds/4166/retry', + playable: false, + created_at: '2017-04-19T14:29:46.463Z', + updated_at: '2017-04-19T14:30:27.498Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4166', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4166/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'deploy to staging', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4159', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4159/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 4159, + name: 'deploy to staging', + build_path: '/root/ci-mock/builds/4159', + retry_path: '/root/ci-mock/builds/4159/retry', + playable: false, + created_at: '2017-04-18T16:32:08.420Z', + updated_at: '2017-04-18T16:32:12.631Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/builds/4159', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4159/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/123#deploy', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + path: '/root/ci-mock/pipelines/123#deploy', + dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy', + }, + ], + artifacts: [], + manual_actions: [ + { + name: 'deploy to production', + path: '/root/ci-mock/builds/4166/play', + playable: false, + }, + ], + }, + flags: { + latest: true, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: false, + cancelable: false, + }, + ref: { + name: 'master', + path: '/root/ci-mock/tree/master', + tag: false, + branch: true, + }, + commit: { + id: '798e5f902592192afaba73f4668ae30e56eae492', + short_id: '798e5f90', + title: "Merge branch 'new-branch' into 'master'\r", + created_at: '2017-04-13T10:25:17.000+01:00', + parent_ids: [ + '54d483b1ed156fbbf618886ddf7ab023e24f8738', + 'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc', + ], + message: + "Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1", + author_name: 'Root', + author_email: 'admin@example.com', + authored_date: '2017-04-13T10:25:17.000+01:00', + committer_name: 'Root', + committer_email: 'admin@example.com', + committed_date: '2017-04-13T10:25:17.000+01:00', + author: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + }, + author_gravatar_url: null, + commit_url: + 'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', + commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492', + }, + created_at: '2017-04-13T09:25:18.881Z', + updated_at: '2017-04-19T14:30:27.561Z', +}; diff --git a/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js b/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js new file mode 100644 index 00000000000..463e4c12c7d --- /dev/null +++ b/spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js @@ -0,0 +1,135 @@ +import { shallowMount } from '@vue/test-utils'; +import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue'; + +describe('stage column component', () => { + const mockJob = { + id: 4250, + name: 'test', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4250', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4250/retry', + method: 'post', + }, + }, + }; + + let wrapper; + + beforeEach(() => { + const mockGroups = []; + for (let i = 0; i < 3; i += 1) { + const mockedJob = { ...mockJob }; + mockedJob.id += i; + mockGroups.push(mockedJob); + } + + wrapper = shallowMount(StageColumnComponentLegacy, { + propsData: { + title: 'foo', + groups: mockGroups, + hasTriggeredBy: false, + }, + }); + }); + + it('should render provided title', () => { + expect( + wrapper + .find('.stage-name') + .text() + .trim(), + ).toBe('foo'); + }); + + it('should render the provided groups', () => { + expect(wrapper.findAll('.builds-container > ul > li').length).toBe( + wrapper.props('groups').length, + ); + }); + + describe('jobId', () => { + it('escapes job name', () => { + wrapper = shallowMount(StageColumnComponentLegacy, { + propsData: { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + }, + ], + title: 'test', + hasTriggeredBy: false, + }, + }); + + expect(wrapper.find('.builds-container li').attributes('id')).toBe( + 'ci-badge-<img src=x onerror=alert(document.domain)>', + ); + }); + }); + + describe('with action', () => { + it('renders action button', () => { + wrapper = shallowMount(StageColumnComponentLegacy, { + propsData: { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + }, + ], + title: 'test', + hasTriggeredBy: false, + action: { + icon: 'play', + title: 'Play all', + path: 'action', + }, + }, + }); + + expect(wrapper.find('.js-stage-action').exists()).toBe(true); + }); + }); + + describe('without action', () => { + it('does not render action button', () => { + wrapper = shallowMount(StageColumnComponentLegacy, { + propsData: { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + }, + ], + title: 'test', + hasTriggeredBy: false, + }, + }); + + expect(wrapper.find('.js-stage-action').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js index d32534326c5..44803929f6d 100644 --- a/spec/frontend/pipelines/graph/stage_column_component_spec.js +++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js @@ -1,64 +1,101 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; +import ActionComponent from '~/pipelines/components/graph/action_component.vue'; +import JobItem from '~/pipelines/components/graph/job_item.vue'; +import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; -import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; - -describe('stage column component', () => { - const mockJob = { - id: 4250, - name: 'test', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - details_path: '/root/ci-mock/builds/4250', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4250/retry', - method: 'post', - }, +const mockJob = { + id: 4250, + name: 'test', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4250', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4250/retry', + method: 'post', }, - }; + }, +}; +const mockGroups = Array(4) + .fill(0) + .map((item, idx) => { + return { ...mockJob, id: idx, name: `fish-${idx}` }; + }); + +const defaultProps = { + title: 'Fish', + groups: mockGroups, +}; + +describe('stage column component', () => { let wrapper; - beforeEach(() => { - const mockGroups = []; - for (let i = 0; i < 3; i += 1) { - const mockedJob = { ...mockJob }; - mockedJob.id += i; - mockGroups.push(mockedJob); - } + const findStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]'); + const findStageColumnGroup = () => wrapper.find('[data-testid="stage-column-group"]'); + const findAllStageColumnGroups = () => wrapper.findAll('[data-testid="stage-column-group"]'); + const findJobItem = () => wrapper.find(JobItem); + const findActionComponent = () => wrapper.find(ActionComponent); - wrapper = shallowMount(stageColumnComponent, { + const createComponent = ({ method = shallowMount, props = {} } = {}) => { + wrapper = method(StageColumnComponent, { propsData: { - title: 'foo', - groups: mockGroups, - hasTriggeredBy: false, + ...defaultProps, + ...props, }, }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); - it('should render provided title', () => { - expect( - wrapper - .find('.stage-name') - .text() - .trim(), - ).toBe('foo'); + describe('when mounted', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('should render provided title', () => { + expect(findStageColumnTitle().text()).toBe(defaultProps.title); + }); + + it('should render the provided groups', () => { + expect(findAllStageColumnGroups().length).toBe(mockGroups.length); + }); }); - it('should render the provided groups', () => { - expect(wrapper.findAll('.builds-container > ul > li').length).toBe( - wrapper.props('groups').length, - ); + describe('when job notifies action is complete', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { + groups: [ + { + title: 'Fish', + size: 1, + jobs: [mockJob], + }, + ], + }, + }); + findJobItem().vm.$emit('pipelineActionRequestComplete'); + }); + + it('emits refreshPipelineGraph', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); }); - describe('jobId', () => { - it('escapes job name', () => { - wrapper = shallowMount(stageColumnComponent, { - propsData: { + describe('job', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { groups: [ { id: 4259, @@ -70,21 +107,29 @@ describe('stage column component', () => { }, }, ], - title: 'test', - hasTriggeredBy: false, + title: 'test <img src=x onerror=alert(document.domain)>', }, }); + }); - expect(wrapper.find('.builds-container li').attributes('id')).toBe( + it('capitalizes and escapes name', () => { + expect(findStageColumnTitle().text()).toBe( + 'Test <img src=x onerror=alert(document.domain)>', + ); + }); + + it('escapes id', () => { + expect(findStageColumnGroup().attributes('id')).toBe( 'ci-badge-<img src=x onerror=alert(document.domain)>', ); }); }); describe('with action', () => { - it('renders action button', () => { - wrapper = shallowMount(stageColumnComponent, { - propsData: { + beforeEach(() => { + createComponent({ + method: mount, + props: { groups: [ { id: 4259, @@ -105,15 +150,18 @@ describe('stage column component', () => { }, }, }); + }); - expect(wrapper.find('.js-stage-action').exists()).toBe(true); + it('renders action button', () => { + expect(findActionComponent().exists()).toBe(true); }); }); describe('without action', () => { - it('does not render action button', () => { - wrapper = shallowMount(stageColumnComponent, { - propsData: { + beforeEach(() => { + createComponent({ + method: mount, + props: { groups: [ { id: 4259, @@ -129,8 +177,10 @@ describe('stage column component', () => { hasTriggeredBy: false, }, }); + }); - expect(wrapper.find('.js-stage-action').exists()).toBe(false); + it('does not render action button', () => { + expect(findActionComponent().exists()).toBe(false); }); }); }); |