Commit 72c8b986 authored by Mark Florian's avatar Mark Florian

Merge branch '276949-pipeline-restructure-1' into 'master'

Pipeline Graph Restructure: Graph Component Changes

See merge request gitlab-org/gitlab!48330
parents 75547a72 d8646823
<script> <script>
import { escape, capitalize } from 'lodash'; import { escape, capitalize } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue'; import StageColumnComponent from './stage_column_component.vue';
import GraphWidthMixin from '../../mixins/graph_width_mixin';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; import { MAIN } from './constants';
export default { export default {
name: 'PipelineGraph', name: 'PipelineGraph',
components: { components: {
StageColumnComponent, StageColumnComponent,
GlLoadingIcon,
LinkedPipelinesColumn,
}, },
mixins: [GraphWidthMixin, GraphBundleMixin], mixins: [GraphBundleMixin],
props: { props: {
isLoading: {
type: Boolean,
required: true,
},
pipeline: {
type: Object,
required: true,
},
isLinkedPipeline: { isLinkedPipeline: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
mediator: { pipeline: {
type: Object, type: Object,
required: true, required: true,
}, },
...@@ -39,60 +26,9 @@ export default { ...@@ -39,60 +26,9 @@ export default {
default: MAIN, default: MAIN,
}, },
}, },
upstream: UPSTREAM,
downstream: DOWNSTREAM,
data() {
return {
downstreamMarginTop: null,
jobName: null,
pipelineExpanded: {
jobName: '',
expanded: false,
},
};
},
computed: { computed: {
graph() { graph() {
return this.pipeline.details?.stages; return this.pipeline.stages;
},
hasUpstream() {
return (
this.type !== this.$options.downstream &&
this.upstreamPipelines &&
this.pipeline.triggered_by !== null
);
},
upstreamPipelines() {
return this.pipeline.triggered_by;
},
hasDownstream() {
return (
this.type !== this.$options.upstream &&
this.downstreamPipelines &&
this.pipeline.triggered.length > 0
);
},
downstreamPipelines() {
return this.pipeline.triggered;
},
expandedUpstream() {
return (
this.pipeline.triggered_by &&
Array.isArray(this.pipeline.triggered_by) &&
this.pipeline.triggered_by.find(el => el.isExpanded)
);
},
expandedDownstream() {
return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
},
pipelineTypeUpstream() {
return this.type !== this.$options.downstream && this.expandedUpstream;
},
pipelineTypeDownstream() {
return this.type !== this.$options.upstream && this.expandedDownstream;
},
pipelineProjectId() {
return this.pipeline.project.id;
}, },
}, },
methods: { methods: {
...@@ -158,22 +94,6 @@ export default { ...@@ -158,22 +94,6 @@ export default {
hasUpstreamColumn(index) { hasUpstreamColumn(index) {
return index === 0 && this.hasUpstream; return index === 0 && this.hasUpstream;
}, },
setJob(jobName) {
this.jobName = jobName;
},
setPipelineExpanded(jobName, expanded) {
if (expanded) {
this.pipelineExpanded = {
jobName,
expanded,
};
} else {
this.pipelineExpanded = {
expanded,
jobName: '',
};
}
},
}, },
}; };
</script> </script>
...@@ -183,48 +103,12 @@ export default { ...@@ -183,48 +103,12 @@ export default {
class="pipeline-visualization pipeline-graph" class="pipeline-visualization pipeline-graph"
:class="{ 'pipeline-tab-content': !isLinkedPipeline }" :class="{ 'pipeline-tab-content': !isLinkedPipeline }"
> >
<div <div>
:style="{ <ul class="stage-column-list align-top">
paddingLeft: `${graphLeftPadding}px`,
paddingRight: `${graphRightPadding}px`,
}"
>
<gl-loading-icon v-if="isLoading" class="m-auto" size="lg" />
<pipeline-graph
v-if="pipelineTypeUpstream"
:type="$options.upstream"
class="d-inline-block upstream-pipeline"
:class="`js-upstream-pipeline-${expandedUpstream.id}`"
:is-loading="false"
:pipeline="expandedUpstream"
:is-linked-pipeline="true"
:mediator="mediator"
@onClickUpstreamPipeline="clickUpstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
<linked-pipelines-column
v-if="hasUpstream"
:type="$options.upstream"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:project-id="pipelineProjectId"
@linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
/>
<ul
v-if="!isLoading"
:class="{
'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
}"
class="stage-column-list align-top"
>
<stage-column-component <stage-column-component
v-for="(stage, index) in graph" v-for="(stage, index) in graph"
:key="stage.name" :key="stage.name"
:class="{ :class="{
'has-upstream gl-ml-11': hasUpstreamColumn(index),
'has-only-one-job': hasOnlyOneJob(stage), 'has-only-one-job': hasOnlyOneJob(stage),
'gl-mr-26': shouldAddRightMargin(index), 'gl-mr-26': shouldAddRightMargin(index),
}" }"
...@@ -232,38 +116,10 @@ export default { ...@@ -232,38 +116,10 @@ export default {
:groups="stage.groups" :groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)" :stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)" :is-first-column="isFirstColumn(index)"
:has-upstream="hasUpstream"
:action="stage.status.action" :action="stage.status.action"
:job-hovered="jobName"
:pipeline-expanded="pipelineExpanded"
@refreshPipelineGraph="refreshPipelineGraph" @refreshPipelineGraph="refreshPipelineGraph"
/> />
</ul> </ul>
<linked-pipelines-column
v-if="hasDownstream"
:type="$options.downstream"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:project-id="pipelineProjectId"
@linkedPipelineClick="handleClickedDownstream"
@downstreamHovered="setJob"
@pipelineExpandToggle="setPipelineExpanded"
/>
<pipeline-graph
v-if="pipelineTypeDownstream"
:type="$options.downstream"
class="d-inline-block"
:class="`js-downstream-pipeline-${expandedDownstream.id}`"
:is-loading="false"
:pipeline="expandedDownstream"
:is-linked-pipeline="true"
:style="{ 'margin-top': downstreamMarginTop }"
:mediator="mediator"
@onClickDownstreamPipeline="clickDownstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
</div> </div>
</div> </div>
</div> </div>
......
<script>
import { escape, capitalize } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
import GraphWidthMixin from '../../mixins/graph_width_mixin';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
export default {
name: 'PipelineGraphLegacy',
components: {
StageColumnComponent,
GlLoadingIcon,
LinkedPipelinesColumn,
},
mixins: [GraphWidthMixin, GraphBundleMixin],
props: {
isLoading: {
type: Boolean,
required: true,
},
pipeline: {
type: Object,
required: true,
},
isLinkedPipeline: {
type: Boolean,
required: false,
default: false,
},
mediator: {
type: Object,
required: true,
},
type: {
type: String,
required: false,
default: MAIN,
},
},
upstream: UPSTREAM,
downstream: DOWNSTREAM,
data() {
return {
downstreamMarginTop: null,
jobName: null,
pipelineExpanded: {
jobName: '',
expanded: false,
},
};
},
computed: {
graph() {
return this.pipeline.details?.stages;
},
hasUpstream() {
return (
this.type !== this.$options.downstream &&
this.upstreamPipelines &&
this.pipeline.triggered_by !== null
);
},
upstreamPipelines() {
return this.pipeline.triggered_by;
},
hasDownstream() {
return (
this.type !== this.$options.upstream &&
this.downstreamPipelines &&
this.pipeline.triggered.length > 0
);
},
downstreamPipelines() {
return this.pipeline.triggered;
},
expandedUpstream() {
return (
this.pipeline.triggered_by &&
Array.isArray(this.pipeline.triggered_by) &&
this.pipeline.triggered_by.find(el => el.isExpanded)
);
},
expandedDownstream() {
return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
},
pipelineTypeUpstream() {
return this.type !== this.$options.downstream && this.expandedUpstream;
},
pipelineTypeDownstream() {
return this.type !== this.$options.upstream && this.expandedDownstream;
},
pipelineProjectId() {
return this.pipeline.project.id;
},
},
methods: {
capitalizeStageName(name) {
const escapedName = escape(name);
return capitalize(escapedName);
},
isFirstColumn(index) {
return index === 0;
},
stageConnectorClass(index, stage) {
let className;
// If it's the first stage column and only has one job
if (this.isFirstColumn(index) && stage.groups.length === 1) {
className = 'no-margin';
} else if (index > 0) {
// If it is not the first column
className = 'left-margin';
}
return className;
},
refreshPipelineGraph() {
this.$emit('refreshPipelineGraph');
},
/**
* CSS class is applied:
* - if pipeline graph contains only one stage column component
*
* @param {number} index
* @returns {boolean}
*/
shouldAddRightMargin(index) {
return !(index === this.graph.length - 1);
},
handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
/**
* Calculates the margin top of the clicked downstream pipeline by
* subtracting the clicked downstream pipelines offsetTop by it's parent's
* offsetTop and then subtracting 15
*/
this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15);
/**
* If the expanded trigger is defined and the id is different than the
* pipeline we clicked, then it means we clicked on a sibling downstream link
* and we want to reset the pipeline store. Triggering the reset without
* this condition would mean not allowing downstreams of downstreams to expand
*/
if (this.expandedDownstream?.id !== pipeline.id) {
this.$emit('onResetDownstream', this.pipeline, pipeline);
}
this.$emit('onClickDownstreamPipeline', pipeline);
},
calculateMarginTop(downstreamNode, pixelDiff) {
return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`;
},
hasOnlyOneJob(stage) {
return stage.groups.length === 1;
},
hasUpstreamColumn(index) {
return index === 0 && this.hasUpstream;
},
setJob(jobName) {
this.jobName = jobName;
},
setPipelineExpanded(jobName, expanded) {
if (expanded) {
this.pipelineExpanded = {
jobName,
expanded,
};
} else {
this.pipelineExpanded = {
expanded,
jobName: '',
};
}
},
},
};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
<div
class="pipeline-visualization pipeline-graph"
:class="{ 'pipeline-tab-content': !isLinkedPipeline }"
>
<div
:style="{
paddingLeft: `${graphLeftPadding}px`,
paddingRight: `${graphRightPadding}px`,
}"
>
<gl-loading-icon v-if="isLoading" class="m-auto" size="lg" />
<pipeline-graph-legacy
v-if="pipelineTypeUpstream"
:type="$options.upstream"
class="d-inline-block upstream-pipeline"
:class="`js-upstream-pipeline-${expandedUpstream.id}`"
:is-loading="false"
:pipeline="expandedUpstream"
:is-linked-pipeline="true"
:mediator="mediator"
@onClickUpstreamPipeline="clickUpstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
<linked-pipelines-column
v-if="hasUpstream"
:type="$options.upstream"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:project-id="pipelineProjectId"
@linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
/>
<ul
v-if="!isLoading"
:class="{
'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
}"
class="stage-column-list align-top"
>
<stage-column-component
v-for="(stage, index) in graph"
:key="stage.name"
:class="{
'has-upstream gl-ml-11': hasUpstreamColumn(index),
'has-only-one-job': hasOnlyOneJob(stage),
'gl-mr-26': shouldAddRightMargin(index),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
:has-upstream="hasUpstream"
:action="stage.status.action"
:job-hovered="jobName"
:pipeline-expanded="pipelineExpanded"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
<linked-pipelines-column
v-if="hasDownstream"
:type="$options.downstream"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:project-id="pipelineProjectId"
@linkedPipelineClick="handleClickedDownstream"
@downstreamHovered="setJob"
@pipelineExpandToggle="setPipelineExpanded"
/>
<pipeline-graph-legacy
v-if="pipelineTypeDownstream"
:type="$options.downstream"
class="d-inline-block"
:class="`js-downstream-pipeline-${expandedDownstream.id}`"
:is-loading="false"
:pipeline="expandedDownstream"
:is-linked-pipeline="true"
:style="{ 'margin-top': downstreamMarginTop }"
:mediator="mediator"
@onClickDownstreamPipeline="clickDownstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
</div>
</div>
</div>
</template>
...@@ -64,7 +64,7 @@ export default { ...@@ -64,7 +64,7 @@ export default {
</script> </script>
<template> <template>
<li :class="stageConnectorClass" class="stage-column"> <li :class="stageConnectorClass" class="stage-column">
<div class="stage-name position-relative"> <div class="stage-name position-relative" data-testid="stage-column-title">
{{ title }} {{ title }}
<action-component <action-component
v-if="hasAction" v-if="hasAction"
......
const unwrapPipelineData = (mainPipelineId, data) => {
if (!data?.project?.pipeline) {
return null;
}
const {
id,
upstream,
downstream,
stages: { nodes: stages },
} = data.project.pipeline;
const unwrappedNestedGroups = stages.map(stage => {
const {
groups: { nodes: groups },
} = stage;
return { ...stage, groups };
});
const nodes = unwrappedNestedGroups.map(({ name, status, groups }) => {
const groupsWithJobs = groups.map(group => {
const jobs = group.jobs.nodes.map(job => {
const { needs } = job;
return { ...job, needs: needs.nodes.map(need => need.name) };
});
return { ...group, jobs };
});
return { name, status, groups: groupsWithJobs };
});
const addMulti = pipeline => {
return { ...pipeline, multiproject: mainPipelineId !== pipeline.id };
};
return {
id,
stages: nodes,
upstream: upstream ? [upstream].map(addMulti) : [],
downstream: downstream ? downstream.map(addMulti) : [],
};
};
export { unwrapPipelineData };
...@@ -3,7 +3,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; ...@@ -3,7 +3,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import pipelineGraph from './components/graph/graph_component.vue'; import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue';
import createDagApp from './pipeline_details_dag'; import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import legacyPipelineHeader from './components/legacy_header_component.vue'; import legacyPipelineHeader from './components/legacy_header_component.vue';
...@@ -28,7 +28,7 @@ const createLegacyPipelinesDetailApp = mediator => { ...@@ -28,7 +28,7 @@ const createLegacyPipelinesDetailApp = mediator => {
new Vue({ new Vue({
el: SELECTORS.PIPELINE_GRAPH, el: SELECTORS.PIPELINE_GRAPH,
components: { components: {
pipelineGraph, PipelineGraphLegacy,
}, },
mixins: [GraphBundleMixin], mixins: [GraphBundleMixin],
data() { data() {
...@@ -37,7 +37,7 @@ const createLegacyPipelinesDetailApp = mediator => { ...@@ -37,7 +37,7 @@ const createLegacyPipelinesDetailApp = mediator => {
}; };
}, },
render(createElement) { render(createElement) {
return createElement('pipeline-graph', { return createElement('pipeline-graph-legacy', {
props: { props: {
isLoading: this.mediator.state.isLoading, isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline, pipeline: this.mediator.store.state.pipeline,
......
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import { setHTMLFixture } from 'helpers/fixtures';
import PipelineStore from '~/pipelines/stores/pipeline_store';
import GraphComponentLegacy from '~/pipelines/components/graph/graph_component_legacy.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_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="expandPipelineButton"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]');
const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const findStageColumnAt = i => findStageColumns().at(i);
beforeEach(() => {
mediator = new PipelinesMediator({ endpoint: '' });
store = new PipelineStore();
store.storePipeline(linkedPipelineJSON);
setHTMLFixture('<div class="layout-page"></div>');
});
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('.gl-spinner').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('.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);
});
});
});
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(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(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 &lt;img src=x onerror=alert(document.domain)&gt;',
);
});
});
});
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import { setHTMLFixture } from 'helpers/fixtures';
import PipelineStore from '~/pipelines/stores/pipeline_store';
import graphComponent from '~/pipelines/components/graph/graph_component.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import graphJSON from './mock_data'; import { unwrapPipelineData } from '~/pipelines/components/graph/utils';
import linkedPipelineJSON from './linked_pipelines_mock_data'; import { mockPipelineResponse } from './mock_data';
import PipelinesMediator from '~/pipelines/pipeline_details_mediator';
describe('graph component', () => { describe('graph component', () => {
let store;
let mediator;
let wrapper; let wrapper;
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]');
const findStageColumns = () => wrapper.findAll(StageColumnComponent); const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const findStageColumnAt = i => findStageColumns().at(i);
beforeEach(() => { const generateResponse = raw => unwrapPipelineData(raw.data.project.pipeline.id, raw.data);
mediator = new PipelinesMediator({ endpoint: '' });
store = new PipelineStore();
store.storePipeline(linkedPipelineJSON);
setHTMLFixture('<div class="layout-page"></div>'); const defaultProps = {
}); pipeline: generateResponse(mockPipelineResponse),
};
const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => {
wrapper = mountFn(PipelineGraph, {
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; 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', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
wrapper = mount(graphComponent, { createComponent();
propsData: {
isLoading: false,
pipeline: graphJSON,
mediator,
},
});
}); });
it('renders the graph', () => { it('renders the main columns in the graph', () => {
expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length);
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);
});
});
});
describe('linked pipelines components', () => {
beforeEach(() => {
wrapper = mount(graphComponent, {
propsData: {
isLoading: false,
pipeline: store.state.pipeline,
mediator,
},
});
});
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);
});
});
});
}); });
}); });
describe('when linked pipelines are not present', () => { describe('when linked pipelines are not present', () => {
beforeEach(() => { beforeEach(() => {
const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null }); createComponent();
wrapper = mount(graphComponent, {
propsData: {
isLoading: false,
pipeline,
mediator,
},
});
}); });
describe('rendered output', () => { it('should not render a linked pipelines column', () => {
it('should include the first column with a no margin', () => { expect(findLinkedColumns()).toHaveLength(0);
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(graphComponent, {
propsData: {
isLoading: false,
pipeline: graphJSON,
mediator,
},
});
expect(findStageColumnAt(1).props('title')).toEqual(
'Deploy &lt;img src=x onerror=alert(document.domain)&gt;',
);
}); });
}); });
}); });
export default { export const mockPipelineResponse = {
id: 123, data: {
user: { project: {
name: 'Root', __typename: 'Project',
username: 'root', pipeline: {
id: 1, __typename: 'Pipeline',
state: 'active', id: '22',
avatar_url: null, stages: {
web_url: 'http://localhost:3000/root', __typename: 'CiStageConnection',
}, nodes: [
active: false, {
coverage: null, __typename: 'CiStage',
path: '/root/ci-mock/pipelines/123', name: 'build',
details: { status: {
status: { __typename: 'DetailedStatus',
icon: 'status_success', action: null,
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',
}, },
}, groups: {
jobs: [ __typename: 'CiGroupConnection',
{ nodes: [
id: 4153, {
name: 'test', __typename: 'CiGroup',
build_path: '/root/ci-mock/builds/4153', name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
retry_path: '/root/ci-mock/builds/4153/retry', size: 1,
playable: false, status: {
created_at: '2017-04-13T09:25:18.959Z', __typename: 'DetailedStatus',
updated_at: '2017-04-13T09:25:23.118Z', label: 'passed',
status: { group: 'success',
icon: 'status_success', icon: 'status_success',
text: 'passed', },
label: 'passed', jobs: {
group: 'success', __typename: 'CiJobConnection',
has_details: true, nodes: [
details_path: '/root/ci-mock/builds/4153', {
favicon: __typename: 'CiJob',
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
action: { scheduledAt: null,
icon: 'retry', status: {
title: 'Retry', __typename: 'DetailedStatus',
path: '/root/ci-mock/builds/4153/retry', icon: 'status_success',
method: 'post', 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: [],
},
},
],
},
}, },
}, {
}, __typename: 'CiGroup',
], name: 'build_b',
}, size: 1,
], status: {
status: { __typename: 'DetailedStatus',
icon: 'status_success', label: 'passed',
text: 'passed', group: 'success',
label: 'passed', icon: 'status_success',
group: 'success', },
has_details: true, jobs: {
details_path: '/root/ci-mock/pipelines/123#test', __typename: 'CiJobConnection',
favicon: nodes: [
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', {
}, __typename: 'CiJob',
path: '/root/ci-mock/pipelines/123#test', name: 'build_b',
dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test', scheduledAt: null,
}, status: {
{ __typename: 'DetailedStatus',
name: 'deploy <img src=x onerror=alert(document.domain)>', icon: 'status_success',
title: 'deploy: passed', tooltip: 'passed',
groups: [ hasDetails: true,
{ detailsPath: '/root/abcd-dag/-/jobs/1515',
name: 'deploy to production', group: 'success',
size: 1, action: {
status: { __typename: 'StatusAction',
icon: 'status_success', buttonTitle: 'Retry this job',
text: 'passed', icon: 'retry',
label: 'passed', path: '/root/abcd-dag/-/jobs/1515/retry',
group: 'success', title: 'Retry',
has_details: true, },
details_path: '/root/ci-mock/builds/4166', },
favicon: needs: {
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', __typename: 'CiJobConnection',
action: { nodes: [],
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_c',
}, size: 1,
{ status: {
name: 'deploy to staging', __typename: 'DetailedStatus',
size: 1, label: 'passed',
status: { group: 'success',
icon: 'status_success', icon: 'status_success',
text: 'passed', },
label: 'passed', jobs: {
group: 'success', __typename: 'CiJobConnection',
has_details: true, nodes: [
details_path: '/root/ci-mock/builds/4159', {
favicon: __typename: 'CiJob',
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', name: 'build_c',
action: { scheduledAt: null,
icon: 'retry', status: {
title: 'Retry', __typename: 'DetailedStatus',
path: '/root/ci-mock/builds/4159/retry', icon: 'status_success',
method: 'post', 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: [ {
{ __typename: 'CiStage',
id: 4159, name: 'test',
name: 'deploy to staging', status: {
build_path: '/root/ci-mock/builds/4159', __typename: 'DetailedStatus',
retry_path: '/root/ci-mock/builds/4159/retry', action: null,
playable: false, },
created_at: '2017-04-18T16:32:08.420Z', groups: {
updated_at: '2017-04-18T16:32:12.631Z', __typename: 'CiGroupConnection',
status: { nodes: [
icon: 'status_success', {
text: 'passed', __typename: 'CiGroup',
label: 'passed', name: 'test_a',
group: 'success', size: 1,
has_details: true, status: {
details_path: '/root/ci-mock/builds/4159', __typename: 'DetailedStatus',
favicon: label: 'passed',
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', group: 'success',
action: { icon: 'status_success',
icon: 'retry', },
title: 'Retry', jobs: {
path: '/root/ci-mock/builds/4159/retry', __typename: 'CiJobConnection',
method: 'post', 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,
},
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',
}; };
export default {
id: 123,
user: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: null,
web_url: 'http://localhost:3000/root',
},
active: false,
coverage: null,
path: '/root/ci-mock/pipelines/123',
details: {
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/pipelines/123',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
},
duration: 9,
finished_at: '2017-04-19T14:30:27.542Z',
stages: [
{
name: 'test',
title: 'test: passed',
groups: [
{
name: 'test',
size: 1,
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/builds/4153',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
action: {
icon: 'retry',
title: 'Retry',
path: '/root/ci-mock/builds/4153/retry',
method: 'post',
},
},
jobs: [
{
id: 4153,
name: 'test',
build_path: '/root/ci-mock/builds/4153',
retry_path: '/root/ci-mock/builds/4153/retry',
playable: false,
created_at: '2017-04-13T09:25:18.959Z',
updated_at: '2017-04-13T09:25:23.118Z',
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/builds/4153',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
action: {
icon: 'retry',
title: 'Retry',
path: '/root/ci-mock/builds/4153/retry',
method: 'post',
},
},
},
],
},
],
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/pipelines/123#test',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
},
path: '/root/ci-mock/pipelines/123#test',
dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=test',
},
{
name: 'deploy <img src=x onerror=alert(document.domain)>',
title: 'deploy: passed',
groups: [
{
name: 'deploy to production',
size: 1,
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/builds/4166',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
action: {
icon: 'retry',
title: 'Retry',
path: '/root/ci-mock/builds/4166/retry',
method: 'post',
},
},
jobs: [
{
id: 4166,
name: 'deploy to production',
build_path: '/root/ci-mock/builds/4166',
retry_path: '/root/ci-mock/builds/4166/retry',
playable: false,
created_at: '2017-04-19T14:29:46.463Z',
updated_at: '2017-04-19T14:30:27.498Z',
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/builds/4166',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
action: {
icon: 'retry',
title: 'Retry',
path: '/root/ci-mock/builds/4166/retry',
method: 'post',
},
},
},
],
},
{
name: 'deploy to staging',
size: 1,
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/builds/4159',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
action: {
icon: 'retry',
title: 'Retry',
path: '/root/ci-mock/builds/4159/retry',
method: 'post',
},
},
jobs: [
{
id: 4159,
name: 'deploy to staging',
build_path: '/root/ci-mock/builds/4159',
retry_path: '/root/ci-mock/builds/4159/retry',
playable: false,
created_at: '2017-04-18T16:32:08.420Z',
updated_at: '2017-04-18T16:32:12.631Z',
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/builds/4159',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
action: {
icon: 'retry',
title: 'Retry',
path: '/root/ci-mock/builds/4159/retry',
method: 'post',
},
},
},
],
},
],
status: {
icon: 'status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/pipelines/123#deploy',
favicon:
'/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png',
},
path: '/root/ci-mock/pipelines/123#deploy',
dropdown_path: '/root/ci-mock/pipelines/123/stage.json?stage=deploy',
},
],
artifacts: [],
manual_actions: [
{
name: 'deploy to production',
path: '/root/ci-mock/builds/4166/play',
playable: false,
},
],
},
flags: {
latest: true,
triggered: false,
stuck: false,
yaml_errors: false,
retryable: false,
cancelable: false,
},
ref: {
name: 'master',
path: '/root/ci-mock/tree/master',
tag: false,
branch: true,
},
commit: {
id: '798e5f902592192afaba73f4668ae30e56eae492',
short_id: '798e5f90',
title: "Merge branch 'new-branch' into 'master'\r",
created_at: '2017-04-13T10:25:17.000+01:00',
parent_ids: [
'54d483b1ed156fbbf618886ddf7ab023e24f8738',
'c8e2d38a6c538822e81c57022a6e3a0cfedebbcc',
],
message:
"Merge branch 'new-branch' into 'master'\r\n\r\nAdd new file\r\n\r\nSee merge request !1",
author_name: 'Root',
author_email: 'admin@example.com',
authored_date: '2017-04-13T10:25:17.000+01:00',
committer_name: 'Root',
committer_email: 'admin@example.com',
committed_date: '2017-04-13T10:25:17.000+01:00',
author: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: null,
web_url: 'http://localhost:3000/root',
},
author_gravatar_url: null,
commit_url:
'http://localhost:3000/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492',
commit_path: '/root/ci-mock/commit/798e5f902592192afaba73f4668ae30e56eae492',
},
created_at: '2017-04-13T09:25:18.881Z',
updated_at: '2017-04-19T14:30:27.561Z',
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment