From 8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 17 Dec 2020 11:59:07 +0000 Subject: Add latest changes from gitlab-org/gitlab@13-7-stable-ee --- spec/frontend/pipelines/empty_state_spec.js | 86 +- .../pipelines/graph/graph_component_legacy_spec.js | 306 +++++++ .../pipelines/graph/graph_component_spec.js | 312 ++----- .../graph/graph_component_wrapper_spec.js | 124 +++ .../pipelines/graph/linked_pipeline_spec.js | 22 +- .../graph/linked_pipelines_column_legacy_spec.js | 40 + .../graph/linked_pipelines_column_spec.js | 120 ++- spec/frontend/pipelines/graph/mock_data.js | 896 +++++++++++++++------ spec/frontend/pipelines/graph/mock_data_legacy.js | 261 ++++++ .../graph/stage_column_component_legacy_spec.js | 135 ++++ .../pipelines/graph/stage_column_component_spec.js | 164 ++-- .../gitlab_ci_yaml_visualization_spec.js | 47 -- .../frontend/pipelines/pipeline_graph/mock_data.js | 10 +- .../pipeline_graph/pipeline_graph_spec.js | 148 ++-- .../pipelines/pipeline_graph/utils_spec.js | 181 +---- spec/frontend/pipelines/pipeline_url_spec.js | 15 + spec/frontend/pipelines/pipelines_spec.js | 4 +- .../pipelines/test_reports/stores/getters_spec.js | 33 +- .../test_reports/stores/mutations_spec.js | 13 + .../test_reports/test_suite_table_spec.js | 24 +- .../pipelines/tokens/pipeline_status_token_spec.js | 16 +- .../tokens/pipeline_trigger_author_token_spec.js | 16 +- spec/frontend/pipelines/unwrapping_utils_spec.js | 151 ++++ 23 files changed, 2206 insertions(+), 918 deletions(-) create mode 100644 spec/frontend/pipelines/graph/graph_component_legacy_spec.js create mode 100644 spec/frontend/pipelines/graph/graph_component_wrapper_spec.js create mode 100644 spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js create mode 100644 spec/frontend/pipelines/graph/mock_data_legacy.js create mode 100644 spec/frontend/pipelines/graph/stage_column_component_legacy_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js create mode 100644 spec/frontend/pipelines/unwrapping_utils_spec.js (limited to 'spec/frontend/pipelines') diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 79356664834..28a73c8863c 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,58 +1,52 @@ -import Vue from 'vue'; -import emptyStateComp from '~/pipelines/components/pipelines_list/empty_state.vue'; -import mountComponent from '../helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; +import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; describe('Pipelines Empty State', () => { - let component; - let EmptyStateComponent; + let wrapper; + + const findGetStartedButton = () => wrapper.find('[data-testid="get-started-pipelines"]'); + const findInfoText = () => wrapper.find('[data-testid="info-text"]').text(); + const createWrapper = () => { + wrapper = shallowMount(EmptyState, { + propsData: { + helpPagePath: 'foo', + emptyStateSvgPath: 'foo', + canSetCi: true, + }, + }); + }; - beforeEach(() => { - EmptyStateComponent = Vue.extend(emptyStateComp); + describe('renders', () => { + beforeEach(() => { + createWrapper(); + }); - component = mountComponent(EmptyStateComponent, { - helpPagePath: 'foo', - emptyStateSvgPath: 'foo', - canSetCi: true, + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); - }); - afterEach(() => { - component.$destroy(); - }); + it('should render empty state SVG', () => { + expect(wrapper.find('img').attributes('src')).toBe('foo'); + }); - it('should render empty state SVG', () => { - expect(component.$el.querySelector('.svg-content svg')).toBeDefined(); - }); + it('should render empty state header', () => { + expect(wrapper.find('[data-testid="header-text"]').text()).toBe('Build with confidence'); + }); - it('should render empty state information', () => { - expect(component.$el.querySelector('h4').textContent).toContain('Build with confidence'); - - expect( - component.$el - .querySelector('p') - .innerHTML.trim() - .replace(/\n+\s+/m, ' ') - .replace(/\s\s+/g, ' '), - ).toContain('Continuous Integration can help catch bugs by running your tests automatically,'); - - expect( - component.$el - .querySelector('p') - .innerHTML.trim() - .replace(/\n+\s+/m, ' ') - .replace(/\s\s+/g, ' '), - ).toContain( - 'while Continuous Deployment can help you deliver code to your product environment', - ); - }); + it('should render a link with provided help path', () => { + expect(findGetStartedButton().attributes('href')).toBe('foo'); + }); - it('should render a link with provided help path', () => { - expect(component.$el.querySelector('.js-get-started-pipelines').getAttribute('href')).toEqual( - 'foo', - ); + it('should render empty state information', () => { + expect(findInfoText()).toContain( + 'Continuous Integration can help catch bugs by running your tests automatically', + 'while Continuous Deployment can help you deliver code to your product environment', + ); + }); - expect(component.$el.querySelector('.js-get-started-pipelines').textContent).toContain( - 'Get started with Pipelines', - ); + it('should render a button', () => { + expect(findGetStartedButton().text()).toBe('Get started with Pipelines'); + }); }); }); 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('
'); + }); + + 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('
'); - }); + 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 ', - 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 ', + 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: '', + status: { + icon: 'status_success', + label: 'success', + tooltip: '', + }, + }, + ], + 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: '', + status: { + icon: 'status_success', + label: 'success', + tooltip: '', + }, + }, + ], + 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: '', + status: { + icon: 'status_success', + label: 'success', + tooltip: '', + }, + }, + ], + 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 ', }, }); + }); - 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); }); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js b/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js deleted file mode 100644 index fea42350959..00000000000 --- a/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js +++ /dev/null @@ -1,47 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlTab } from '@gitlab/ui'; -import { yamlString } from './mock_data'; -import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import GitlabCiYamlVisualization from '~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'; - -describe('gitlab yaml visualization component', () => { - const defaultProps = { blobData: yamlString }; - let wrapper; - - const createComponent = props => { - return shallowMount(GitlabCiYamlVisualization, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - const findGlTabComponents = () => wrapper.findAll(GlTab); - const findPipelineGraph = () => wrapper.find(PipelineGraph); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('tabs component', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - it('renders the file and visualization tabs', () => { - expect(findGlTabComponents()).toHaveLength(2); - }); - }); - - describe('graph component', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - it('is hidden by default', () => { - expect(findPipelineGraph().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index 4f55fdd6b28..a77973b293c 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -1,4 +1,4 @@ -import { createUniqueJobId } from '~/pipelines/utils'; +import { createUniqueLinkId } from '~/pipelines/utils'; export const yamlString = `stages: - empty @@ -41,10 +41,10 @@ deploy_a: script: echo hello `; -const jobId1 = createUniqueJobId('build', 'build_1'); -const jobId2 = createUniqueJobId('test', 'test_1'); -const jobId3 = createUniqueJobId('test', 'test_2'); -const jobId4 = createUniqueJobId('deploy', 'deploy_1'); +const jobId1 = createUniqueLinkId('build', 'build_1'); +const jobId2 = createUniqueLinkId('test', 'test_1'); +const jobId3 = createUniqueLinkId('test', 'test_2'); +const jobId4 = createUniqueLinkId('deploy', 'deploy_1'); export const pipelineData = { stages: [ diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index 7c8ebc27974..6704ee06c1a 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -1,5 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; import { pipelineData, singleStageData } from './mock_data'; +import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; +import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; @@ -8,15 +11,16 @@ describe('pipeline graph component', () => { const defaultProps = { pipelineData }; let wrapper; - const createComponent = props => { + const createComponent = (props = defaultProps) => { return shallowMount(PipelineGraph, { propsData: { - ...defaultProps, ...props, }, }); }; + const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); + const findAlert = () => wrapper.find(GlAlert); const findAllStagePills = () => wrapper.findAll(StagePill); const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]'); const findStageBackgroundElementAt = index => findAllStageBackgroundElements().at(index); @@ -33,54 +37,92 @@ describe('pipeline graph component', () => { }); it('renders an empty section', () => { - expect(wrapper.text()).toContain( - 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', - ); + expect(wrapper.text()).toBe(wrapper.vm.$options.warningTexts[EMPTY_PIPELINE_DATA]); + expect(findPipelineGraph().exists()).toBe(false); expect(findAllStagePills()).toHaveLength(0); expect(findAllJobPills()).toHaveLength(0); }); }); - describe('with data', () => { + describe('with `INVALID` status', () => { + beforeEach(() => { + wrapper = createComponent({ pipelineData: { status: CI_CONFIG_STATUS_INVALID } }); + }); + + it('renders an error message and does not render the graph', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(wrapper.vm.$options.warningTexts[INVALID_CI_CONFIG]); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + + describe('without `INVALID` status', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the graph with no status error', () => { + expect(findAlert().text()).not.toBe(wrapper.vm.$options.warningTexts[INVALID_CI_CONFIG]); + expect(findPipelineGraph().exists()).toBe(true); + }); + }); + + describe('with error while rendering the links', () => { beforeEach(() => { wrapper = createComponent(); }); + it('renders the error that link could not be drawn', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[DRAW_FAILURE]); + }); + }); + + describe('with only one stage', () => { + beforeEach(() => { + wrapper = createComponent({ pipelineData: singleStageData }); + }); + it('renders the right number of stage pills', () => { - const expectedStagesLength = pipelineData.stages.length; + const expectedStagesLength = singleStageData.stages.length; expect(findAllStagePills()).toHaveLength(expectedStagesLength); }); - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${true} - ${'gl-rounded-top-left-6'} | ${true} - ${'gl-rounded-top-right-6'} | ${false} - ${'gl-rounded-bottom-right-6'} | ${false} - `( - 'rounds corner: $class should be $expectedState on the first element', - ({ cssClass, expectedState }) => { + it('renders the right number of job pills', () => { + // We count the number of jobs in the mock data + const expectedJobsLength = singleStageData.stages.reduce((acc, val) => { + return acc + val.groups.length; + }, 0); + + expect(findAllJobPills()).toHaveLength(expectedJobsLength); + }); + + describe('rounds corner', () => { + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${true} + ${'gl-rounded-top-left-6'} | ${true} + ${'gl-rounded-top-right-6'} | ${true} + ${'gl-rounded-bottom-right-6'} | ${true} + `('$cssClass should be $expectedState on the only element', ({ cssClass, expectedState }) => { const classes = findStageBackgroundElementAt(0).classes(); expect(classes.includes(cssClass)).toBe(expectedState); - }, - ); - - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${false} - ${'gl-rounded-top-left-6'} | ${false} - ${'gl-rounded-top-right-6'} | ${true} - ${'gl-rounded-bottom-right-6'} | ${true} - `( - 'rounds corner: $class should be $expectedState on the last element', - ({ cssClass, expectedState }) => { - const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes(); + }); + }); + }); - expect(classes.includes(cssClass)).toBe(expectedState); - }, - ); + describe('with multiple stages and jobs', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the right number of stage pills', () => { + const expectedStagesLength = pipelineData.stages.length; + + expect(findAllStagePills()).toHaveLength(expectedStagesLength); + }); it('renders the right number of job pills', () => { // We count the number of jobs in the mock data @@ -90,26 +132,34 @@ describe('pipeline graph component', () => { expect(findAllJobPills()).toHaveLength(expectedJobsLength); }); - }); - describe('with only one stage', () => { - beforeEach(() => { - wrapper = createComponent({ pipelineData: singleStageData }); - }); + describe('rounds corner', () => { + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${true} + ${'gl-rounded-top-left-6'} | ${true} + ${'gl-rounded-top-right-6'} | ${false} + ${'gl-rounded-bottom-right-6'} | ${false} + `( + '$cssClass should be $expectedState on the first element', + ({ cssClass, expectedState }) => { + const classes = findStageBackgroundElementAt(0).classes(); + + expect(classes.includes(cssClass)).toBe(expectedState); + }, + ); - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${true} - ${'gl-rounded-top-left-6'} | ${true} - ${'gl-rounded-top-right-6'} | ${true} - ${'gl-rounded-bottom-right-6'} | ${true} - `( - 'rounds corner: $class should be $expectedState on the only element', - ({ cssClass, expectedState }) => { - const classes = findStageBackgroundElementAt(0).classes(); + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${false} + ${'gl-rounded-top-left-6'} | ${false} + ${'gl-rounded-top-right-6'} | ${true} + ${'gl-rounded-bottom-right-6'} | ${true} + `('$cssClass should be $expectedState on the last element', ({ cssClass, expectedState }) => { + const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes(); expect(classes.includes(cssClass)).toBe(expectedState); - }, - ); + }); + }); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js index ade026c7053..12154df6fcf 100644 --- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js @@ -1,19 +1,24 @@ -import { - preparePipelineGraphData, - createUniqueJobId, - generateJobNeedsDict, -} from '~/pipelines/utils'; +import { createJobsHash, generateJobNeedsDict } from '~/pipelines/utils'; describe('utils functions', () => { - const emptyResponse = { stages: [], jobs: {} }; const jobName1 = 'build_1'; const jobName2 = 'build_2'; const jobName3 = 'test_1'; const jobName4 = 'deploy_1'; - const job1 = { script: 'echo hello', stage: 'build' }; - const job2 = { script: 'echo build', stage: 'build' }; - const job3 = { script: 'echo test', stage: 'test', needs: [jobName1, jobName2] }; - const job4 = { script: 'echo deploy', stage: 'deploy', needs: [jobName3] }; + const job1 = { name: jobName1, script: 'echo hello', stage: 'build' }; + const job2 = { name: jobName2, script: 'echo build', stage: 'build' }; + const job3 = { + name: jobName3, + script: 'echo test', + stage: 'test', + needs: [jobName1, jobName2], + }; + const job4 = { + name: jobName4, + script: 'echo deploy', + stage: 'deploy', + needs: [jobName3], + }; const userDefinedStage = 'myStage'; const pipelineGraphData = { @@ -28,7 +33,6 @@ describe('utils functions', () => { { name: jobName4, jobs: [{ ...job4 }], - id: createUniqueJobId(job4.stage, jobName4), }, ], }, @@ -38,12 +42,10 @@ describe('utils functions', () => { { name: jobName1, jobs: [{ ...job1 }], - id: createUniqueJobId(job1.stage, jobName1), }, { name: jobName2, jobs: [{ ...job2 }], - id: createUniqueJobId(job2.stage, jobName2), }, ], }, @@ -53,158 +55,59 @@ describe('utils functions', () => { { name: jobName3, jobs: [{ ...job3 }], - id: createUniqueJobId(job3.stage, jobName3), }, ], }, ], - jobs: { - [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, - [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, - [jobName3]: { ...job3, id: createUniqueJobId(job3.stage, jobName3) }, - [jobName4]: { ...job4, id: createUniqueJobId(job4.stage, jobName4) }, - }, }; - describe('preparePipelineGraphData', () => { - describe('returns an empty array of stages and empty job objects if', () => { - it('no data is passed', () => { - expect(preparePipelineGraphData({})).toEqual(emptyResponse); - }); - - it('no stages are found', () => { - expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual( - emptyResponse, - ); - }); + describe('createJobsHash', () => { + it('returns an empty object if there are no jobs received as argument', () => { + expect(createJobsHash([])).toEqual({}); }); - describe('returns the correct array of stages and object of jobs', () => { - it('when multiple jobs are in the same stage', () => { - const expectedData = { - stages: [ - { - name: job1.stage, - groups: [ - { - name: jobName1, - jobs: [{ ...job1 }], - id: createUniqueJobId(job1.stage, jobName1), - }, - { - name: jobName2, - jobs: [{ ...job2 }], - id: createUniqueJobId(job2.stage, jobName2), - }, - ], - }, - ], - jobs: { - [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, - [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, - }, - }; - expect( - preparePipelineGraphData({ [jobName1]: { ...job1 }, [jobName2]: { ...job2 } }), - ).toEqual(expectedData); - }); - - it('when stages are defined by the user', () => { - const userDefinedStage2 = 'myStage2'; - - const expectedData = { - stages: [ - { - name: userDefinedStage, - groups: [], - }, - { - name: userDefinedStage2, - groups: [], - }, - ], - jobs: {}, - }; - - expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual( - expectedData, - ); - }); - - it('by combining user defined stage and job stages, it preserves user defined order', () => { - const userDefinedStageThatOverlaps = 'deploy'; - - expect( - preparePipelineGraphData({ - stages: [userDefinedStage, userDefinedStageThatOverlaps], - [jobName1]: { ...job1 }, - [jobName2]: { ...job2 }, - [jobName3]: { ...job3 }, - [jobName4]: { ...job4 }, - }), - ).toEqual(pipelineGraphData); - }); - - it('with only unique values', () => { - const expectedData = { - stages: [ - { - name: job1.stage, - groups: [ - { - name: jobName1, - jobs: [{ ...job1 }], - id: createUniqueJobId(job1.stage, jobName1), - }, - ], - }, - ], - jobs: { - [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, - }, - }; + it('returns a hash with the jobname as key and all its data as value', () => { + const jobs = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: job4, + }; - expect( - preparePipelineGraphData({ - stages: ['build'], - [jobName1]: { ...job1 }, - [jobName1]: { ...job1 }, - }), - ).toEqual(expectedData); - }); + expect(createJobsHash(pipelineGraphData.stages)).toEqual(jobs); }); }); describe('generateJobNeedsDict', () => { it('generates an empty object if it receives no jobs', () => { - expect(generateJobNeedsDict({ jobs: {} })).toEqual({}); + expect(generateJobNeedsDict({})).toEqual({}); }); it('generates a dict with empty needs if there are no dependencies', () => { const smallGraph = { - jobs: { - [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, - [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, - }, + [jobName1]: job1, + [jobName2]: job2, }; expect(generateJobNeedsDict(smallGraph)).toEqual({ - [pipelineGraphData.jobs[jobName1].id]: [], - [pipelineGraphData.jobs[jobName2].id]: [], + [jobName1]: [], + [jobName2]: [], }); }); it('generates a dict where key is the a job and its value is an array of all its needs', () => { - const uniqueJobName1 = pipelineGraphData.jobs[jobName1].id; - const uniqueJobName2 = pipelineGraphData.jobs[jobName2].id; - const uniqueJobName3 = pipelineGraphData.jobs[jobName3].id; - const uniqueJobName4 = pipelineGraphData.jobs[jobName4].id; + const jobsWithNeeds = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: job4, + }; - expect(generateJobNeedsDict(pipelineGraphData)).toEqual({ - [uniqueJobName1]: [], - [uniqueJobName2]: [], - [uniqueJobName3]: [uniqueJobName1, uniqueJobName2], - [uniqueJobName4]: [uniqueJobName3, uniqueJobName1, uniqueJobName2], + expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ + [jobName1]: [], + [jobName2]: [], + [jobName3]: [jobName1, jobName2], + [jobName4]: [jobName3, jobName1, jobName2], }); }); }); diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 0bcc3f96f7c..fc45af2c254 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -16,6 +16,7 @@ describe('Pipeline Url Component', () => { const findAutoDevopsTag = () => wrapper.find('[data-testid="pipeline-url-autodevops"]'); const findStuckTag = () => wrapper.find('[data-testid="pipeline-url-stuck"]'); const findDetachedTag = () => wrapper.find('[data-testid="pipeline-url-detached"]'); + const findForkTag = () => wrapper.find('[data-testid="pipeline-url-fork"]'); const defaultProps = { pipeline: { @@ -30,6 +31,9 @@ describe('Pipeline Url Component', () => { const createComponent = props => { wrapper = shallowMount(PipelineUrlComponent, { propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: 'test/test', + }, }); }; @@ -137,4 +141,15 @@ describe('Pipeline Url Component', () => { expect(findScheduledTag().exists()).toBe(true); expect(findScheduledTag().text()).toContain('Scheduled'); }); + it('should render the fork badge when the pipeline was run in a fork', () => { + createComponent({ + pipeline: { + flags: {}, + project: { fullPath: 'test/forked' }, + }, + }); + + expect(findForkTag().exists()).toBe(true); + expect(findForkTag().text()).toBe('fork'); + }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index a272803f9b6..ce0e76ba22d 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -31,7 +31,7 @@ describe('Pipelines', () => { const paths = { endpoint: 'twitter/flight/pipelines.json', - autoDevopsPath: '/help/topics/autodevops/index.md', + autoDevopsHelpPath: '/help/topics/autodevops/index.md', helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', @@ -43,7 +43,7 @@ describe('Pipelines', () => { const noPermissions = { endpoint: 'twitter/flight/pipelines.json', - autoDevopsPath: '/help/topics/autodevops/index.md', + autoDevopsHelpPath: '/help/topics/autodevops/index.md', helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js index 58e8065033f..8cef499fdb9 100644 --- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -10,11 +10,19 @@ describe('Getters TestReports Store', () => { const defaultState = { testReports, selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage: 2, + }, }; const emptyState = { testReports: {}, selectedSuite: null, + pageInfo: { + page: 1, + perPage: 2, + }, }; beforeEach(() => { @@ -59,15 +67,17 @@ describe('Getters TestReports Store', () => { }); describe('getSuiteTests', () => { - it('should return the test cases inside the suite', () => { + it('should return the current page of test cases inside the suite', () => { setupState(); const cases = getters.getSuiteTests(state); - const expected = testReports.test_suites[0].test_cases.map(x => ({ - ...x, - formattedTime: formattedTime(x.execution_time), - icon: iconForTestStatus(x.status), - })); + const expected = testReports.test_suites[0].test_cases + .map(x => ({ + ...x, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); expect(cases).toEqual(expected); }); @@ -78,4 +88,15 @@ describe('Getters TestReports Store', () => { expect(getters.getSuiteTests(state)).toEqual([]); }); }); + + describe('getSuiteTestCount', () => { + it('should return the total number of test cases', () => { + setupState(); + + const testCount = getters.getSuiteTestCount(state); + const expected = testReports.test_suites[0].test_cases.length; + + expect(testCount).toEqual(expected); + }); + }); }); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index b935029bc6a..191e9e7391c 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -12,12 +12,25 @@ describe('Mutations TestReports Store', () => { testReports: {}, selectedSuite: null, isLoading: false, + pageInfo: { + page: 1, + perPage: 2, + }, }; beforeEach(() => { mockState = { ...defaultState }; }); + describe('set page', () => { + it('should set the current page to display', () => { + const pageToDisplay = 3; + mutations[types.SET_PAGE](mockState, pageToDisplay); + + expect(mockState.pageInfo.page).toEqual(pageToDisplay); + }); + }); + describe('set suite', () => { it('should set the suite at the given index', () => { mockState.testReports = testReports; diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index 284099b000b..0e00ca670a7 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { getJSONFixture } from 'helpers/fixtures'; -import { GlButton, GlFriendlyWrap } from '@gitlab/ui'; +import { GlButton, GlFriendlyWrap, GlPagination } from '@gitlab/ui'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { TestStatus } from '~/pipelines/constants'; @@ -26,13 +26,17 @@ describe('Test reports suite table', () => { const findCaseRowAtIndex = index => wrapper.findAll('.js-case-row').at(index); const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); - const createComponent = (suite = testSuite) => { + const createComponent = (suite = testSuite, perPage = 20) => { store = new Vuex.Store({ state: { testReports: { test_suites: [suite], }, selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage, + }, }, getters, }); @@ -86,4 +90,20 @@ describe('Test reports suite table', () => { expect(button.attributes('data-clipboard-text')).toBe(file); }); }); + + describe('when a test suite has more test cases than the pagination size', () => { + const perPage = 2; + + beforeEach(() => { + createComponent(testSuite, perPage); + }); + + it('renders one page of test cases', () => { + expect(allCaseRows().length).toBe(perPage); + }); + + it('renders a pagination component', () => { + expect(wrapper.find(GlPagination).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js index b53955ab743..1db736ba01e 100644 --- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js @@ -1,18 +1,12 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; import PipelineStatusToken from '~/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue'; describe('Pipeline Status Token', () => { let wrapper; - const stubs = { - GlFilteredSearchToken: { - props: GlFilteredSearchToken.props, - template: `
`, - }, - }; - - const findFilteredSearchToken = () => wrapper.find(stubs.GlFilteredSearchToken); + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); const findAllGlIcons = () => wrapper.findAll(GlIcon); @@ -33,7 +27,11 @@ describe('Pipeline Status Token', () => { propsData: { ...defaultProps, }, - stubs, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `
`, + }), + }, }); }; diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js index 9363944a719..375325c0c6a 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -1,4 +1,5 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; import { shallowMount } from '@vue/test-utils'; import Api from '~/api'; import PipelineTriggerAuthorToken from '~/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue'; @@ -7,14 +8,7 @@ import { users } from '../mock_data'; describe('Pipeline Trigger Author Token', () => { let wrapper; - const stubs = { - GlFilteredSearchToken: { - props: GlFilteredSearchToken.props, - template: `
`, - }, - }; - - const findFilteredSearchToken = () => wrapper.find(stubs.GlFilteredSearchToken); + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); @@ -42,7 +36,11 @@ describe('Pipeline Trigger Author Token', () => { ...data, }; }, - stubs, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `
`, + }), + }, }); }; diff --git a/spec/frontend/pipelines/unwrapping_utils_spec.js b/spec/frontend/pipelines/unwrapping_utils_spec.js new file mode 100644 index 00000000000..3533599611f --- /dev/null +++ b/spec/frontend/pipelines/unwrapping_utils_spec.js @@ -0,0 +1,151 @@ +import { + unwrapArrayOfJobs, + unwrapGroups, + unwrapNodesWithName, + unwrapStagesWithNeeds, +} from '~/pipelines/components/unwrapping_utils'; + +const groupsArray = [ + { + name: 'build_a', + size: 1, + status: { + label: 'passed', + group: 'success', + icon: 'status_success', + }, + }, + { + name: 'bob_the_build', + size: 1, + status: { + label: 'passed', + group: 'success', + icon: 'status_success', + }, + }, +]; + +const basicStageInfo = { + name: 'center_stage', + status: { + action: null, + }, +}; + +const stagesAndGroups = [ + { + ...basicStageInfo, + groups: { + nodes: groupsArray, + }, + }, +]; + +const needArray = [ + { + name: 'build_b', + }, +]; + +const elephantArray = [ + { + name: 'build_b', + elephant: 'gray', + }, +]; + +const baseJobs = { + name: 'test_d', + status: { + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/abcd-dag/-/pipelines/162', + group: 'success', + action: null, + }, +}; + +const jobArrayWithNeeds = [ + { + ...baseJobs, + needs: { + nodes: needArray, + }, + }, +]; + +const jobArrayWithElephant = [ + { + ...baseJobs, + needs: { + nodes: elephantArray, + }, + }, +]; + +const completeMock = [ + { + ...basicStageInfo, + groups: { + nodes: groupsArray.map(group => ({ ...group, jobs: { nodes: jobArrayWithNeeds } })), + }, + }, +]; + +describe('Shared pipeline unwrapping utils', () => { + describe('unwrapArrayOfJobs', () => { + it('returns an empty array if the input is an empty undefined', () => { + expect(unwrapArrayOfJobs(undefined)).toEqual([]); + }); + + it('returns an empty array if the input is an empty array', () => { + expect(unwrapArrayOfJobs([])).toEqual([]); + }); + + it('returns a flatten array of each job with their data and stage name', () => { + expect( + unwrapArrayOfJobs([ + { name: 'build', groups: [{ name: 'job_a_1' }, { name: 'job_a_2' }] }, + { name: 'test', groups: [{ name: 'job_b' }] }, + ]), + ).toMatchObject([ + { category: 'build', name: 'job_a_1' }, + { category: 'build', name: 'job_a_2' }, + { category: 'test', name: 'job_b' }, + ]); + }); + }); + + describe('unwrapGroups', () => { + it('takes stages without nodes and returns the unwrapped groups', () => { + expect(unwrapGroups(stagesAndGroups)[0].groups).toEqual(groupsArray); + }); + + it('keeps other stage properties intact', () => { + expect(unwrapGroups(stagesAndGroups)[0]).toMatchObject(basicStageInfo); + }); + }); + + describe('unwrapNodesWithName', () => { + it('works with no field argument', () => { + expect(unwrapNodesWithName(jobArrayWithNeeds, 'needs')[0].needs).toEqual([needArray[0].name]); + }); + + it('works with custom field argument', () => { + expect(unwrapNodesWithName(jobArrayWithElephant, 'needs', 'elephant')[0].needs).toEqual([ + elephantArray[0].elephant, + ]); + }); + }); + + describe('unwrapStagesWithNeeds', () => { + it('removes nodes from groups, jobs, and needs', () => { + const firstProcessedGroup = unwrapStagesWithNeeds(completeMock)[0].groups[0]; + expect(firstProcessedGroup).toMatchObject(groupsArray[0]); + expect(firstProcessedGroup.jobs[0]).toMatchObject(baseJobs); + expect(firstProcessedGroup.jobs[0].needs[0]).toBe(needArray[0].name); + }); + }); +}); -- cgit v1.2.1