diff options
Diffstat (limited to 'spec/frontend/ci/pipeline_details')
43 files changed, 12943 insertions, 0 deletions
diff --git a/spec/frontend/ci/pipeline_details/dag/components/__snapshots__/dag_graph_spec.js.snap b/spec/frontend/ci/pipeline_details/dag/components/__snapshots__/dag_graph_spec.js.snap new file mode 100644 index 00000000000..624c89a237c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/components/__snapshots__/dag_graph_spec.js.snap @@ -0,0 +1,743 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The DAG graph in the basic case renders the graph svg 1`] = ` +<svg + height="540" + viewBox="0,0,1000,540" + width="1000" +> + <g + fill="none" + stroke-opacity="0.8" + > + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-0" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-1" + x1="116" + x2="361.3333333333333" + > + <stop + offset="0%" + stop-color="#e17223" + /> + <stop + offset="100%" + stop-color="#83ab4a" + /> + </lineargradient> + <clippath + id="reference-2" + > + <path + d=" + M100, 129 + V158 + H377.3333333333333 + V100 + H100 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip63)" + d="M108,129L190,129L190,129L369.3333333333333,129" + stroke="url(#dag-grad53)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-3" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-4" + x1="377.3333333333333" + x2="622.6666666666666" + > + <stop + offset="0%" + stop-color="#83ab4a" + /> + <stop + offset="100%" + stop-color="#6f3500" + /> + </lineargradient> + <clippath + id="reference-5" + > + <path + d=" + M361.3333333333333, 129.0000000000002 + V158.0000000000002 + H638.6666666666666 + V100 + H361.3333333333333 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip64)" + d="M369.3333333333333,129L509.3333333333333,129L509.3333333333333,129.0000000000002L630.6666666666666,129.0000000000002" + stroke="url(#dag-grad54)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-6" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-7" + x1="116" + x2="622.6666666666666" + > + <stop + offset="0%" + stop-color="#5772ff" + /> + <stop + offset="100%" + stop-color="#6f3500" + /> + </lineargradient> + <clippath + id="reference-8" + > + <path + d=" + M100, 187.0000000000002 + V241.00000000000003 + H638.6666666666666 + V158.0000000000002 + H100 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip65)" + d="M108,212.00000000000003L306,212.00000000000003L306,187.0000000000002L630.6666666666666,187.0000000000002" + stroke="url(#dag-grad55)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-9" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-10" + x1="116" + x2="361.3333333333333" + > + <stop + offset="0%" + stop-color="#b24800" + /> + <stop + offset="100%" + stop-color="#006887" + /> + </lineargradient> + <clippath + id="reference-11" + > + <path + d=" + M100, 269.9999999999998 + V324 + H377.3333333333333 + V240.99999999999977 + H100 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip66)" + d="M108,295L338.93333333333334,295L338.93333333333334,269.9999999999998L369.3333333333333,269.9999999999998" + stroke="url(#dag-grad56)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-12" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-13" + x1="116" + x2="361.3333333333333" + > + <stop + offset="0%" + stop-color="#25d2d2" + /> + <stop + offset="100%" + stop-color="#487900" + /> + </lineargradient> + <clippath + id="reference-14" + > + <path + d=" + M100, 352.99999999999994 + V407.00000000000006 + H377.3333333333333 + V323.99999999999994 + H100 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip67)" + d="M108,378.00000000000006L144.66666666666669,378.00000000000006L144.66666666666669,352.99999999999994L369.3333333333333,352.99999999999994" + stroke="url(#dag-grad57)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-15" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-16" + x1="377.3333333333333" + x2="622.6666666666666" + > + <stop + offset="0%" + stop-color="#006887" + /> + <stop + offset="100%" + stop-color="#d84280" + /> + </lineargradient> + <clippath + id="reference-17" + > + <path + d=" + M361.3333333333333, 270.0000000000001 + V299.0000000000001 + H638.6666666666666 + V240.99999999999977 + H361.3333333333333 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip68)" + d="M369.3333333333333,269.9999999999998L464,269.9999999999998L464,270.0000000000001L630.6666666666666,270.0000000000001" + stroke="url(#dag-grad58)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-18" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-19" + x1="377.3333333333333" + x2="622.6666666666666" + > + <stop + offset="0%" + stop-color="#487900" + /> + <stop + offset="100%" + stop-color="#d84280" + /> + </lineargradient> + <clippath + id="reference-20" + > + <path + d=" + M361.3333333333333, 328.0000000000001 + V381.99999999999994 + H638.6666666666666 + V299.0000000000001 + H361.3333333333333 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip69)" + d="M369.3333333333333,352.99999999999994L522,352.99999999999994L522,328.0000000000001L630.6666666666666,328.0000000000001" + stroke="url(#dag-grad59)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-21" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-22" + x1="377.3333333333333" + x2="622.6666666666666" + > + <stop + offset="0%" + stop-color="#487900" + /> + <stop + offset="100%" + stop-color="#3547de" + /> + </lineargradient> + <clippath + id="reference-23" + > + <path + d=" + M361.3333333333333, 411 + V440 + H638.6666666666666 + V381.99999999999994 + H361.3333333333333 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip70)" + d="M369.3333333333333,410.99999999999994L580,410.99999999999994L580,411L630.6666666666666,411" + stroke="url(#dag-grad60)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-24" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-25" + x1="638.6666666666666" + x2="884" + > + <stop + offset="0%" + stop-color="#d84280" + /> + <stop + offset="100%" + stop-color="#006887" + /> + </lineargradient> + <clippath + id="reference-26" + > + <path + d=" + M622.6666666666666, 270.1890725105691 + V299.1890725105691 + H900 + V241.0000000000001 + H622.6666666666666 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip71)" + d="M630.6666666666666,270.0000000000001L861.6,270.0000000000001L861.6,270.1890725105691L892,270.1890725105691" + stroke="url(#dag-grad61)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + <g + class="dag-link gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke-opacity gl-transition-timing-function-ease" + id="reference-27" + > + <lineargradient + gradientUnits="userSpaceOnUse" + id="reference-28" + x1="638.6666666666666" + x2="884" + > + <stop + offset="0%" + stop-color="#3547de" + /> + <stop + offset="100%" + stop-color="#275600" + /> + </lineargradient> + <clippath + id="reference-29" + > + <path + d=" + M622.6666666666666, 411 + V440 + H900 + V382 + H622.6666666666666 + Z + " + /> + </clippath> + <path + clip-path="url(#dag-clip72)" + d="M630.6666666666666,411L679.9999999999999,411L679.9999999999999,411L892,411" + stroke="url(#dag-grad62)" + stroke-width="56" + style="stroke-linejoin: round;" + /> + </g> + </g> + <g> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-30" + stroke="#e17223" + stroke-linecap="round" + stroke-width="16" + x1="108" + x2="108" + y1="104" + y2="154.00000000000003" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-31" + stroke="#83ab4a" + stroke-linecap="round" + stroke-width="16" + x1="369" + x2="369" + y1="104" + y2="154" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-32" + stroke="#5772ff" + stroke-linecap="round" + stroke-width="16" + x1="108" + x2="108" + y1="187.00000000000003" + y2="237.00000000000003" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-33" + stroke="#b24800" + stroke-linecap="round" + stroke-width="16" + x1="108" + x2="108" + y1="270" + y2="320.00000000000006" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-34" + stroke="#25d2d2" + stroke-linecap="round" + stroke-width="16" + x1="108" + x2="108" + y1="353.00000000000006" + y2="403.0000000000001" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-35" + stroke="#6f3500" + stroke-linecap="round" + stroke-width="16" + x1="630" + x2="630" + y1="104.0000000000002" + y2="212.00000000000009" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-36" + stroke="#006887" + stroke-linecap="round" + stroke-width="16" + x1="369" + x2="369" + y1="244.99999999999977" + y2="294.99999999999994" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-37" + stroke="#487900" + stroke-linecap="round" + stroke-width="16" + x1="369" + x2="369" + y1="327.99999999999994" + y2="436" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-38" + stroke="#d84280" + stroke-linecap="round" + stroke-width="16" + x1="630" + x2="630" + y1="245.00000000000009" + y2="353" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-39" + stroke="#3547de" + stroke-linecap="round" + stroke-width="16" + x1="630" + x2="630" + y1="386" + y2="436" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-40" + stroke="#006887" + stroke-linecap="round" + stroke-width="16" + x1="892" + x2="892" + y1="245.18907251056908" + y2="295.1890725105691" + /> + <line + class="dag-node gl-cursor-pointer gl-transition-duration-slow gl-transition-property-stroke gl-transition-timing-function-ease" + id="reference-41" + stroke="#275600" + stroke-linecap="round" + stroke-width="16" + x1="892" + x2="892" + y1="386" + y2="436" + /> + </g> + <g + class="gl-font-sm" + > + <foreignobject + class="gl-overflow-visible" + height="58.00000000000003px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="8" + y="100" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 58.00000000000003px; text-align: right;" + > + build_a + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="25px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="369.3333333333333" + y="75" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 25px; text-align: left;" + > + test_a + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="58px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="8" + y="183.00000000000003" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 58px; text-align: right;" + > + test_b + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="58.00000000000006px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="8" + y="266" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 58.00000000000006px; text-align: right;" + > + post_test_a + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="58.00000000000006px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="8" + y="349.00000000000006" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 58.00000000000006px; text-align: right;" + > + post_test_b + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="25px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="630.6666666666666" + y="75.0000000000002" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 25px; text-align: right;" + > + post_test_c + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="25px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="369.3333333333333" + y="215.99999999999977" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 25px; text-align: left;" + > + staging_a + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="25px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="369.3333333333333" + y="298.99999999999994" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 25px; text-align: left;" + > + staging_b + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="25px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="630.6666666666666" + y="216.00000000000009" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 25px; text-align: right;" + > + canary_a + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="25px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="630.6666666666666" + y="357" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 25px; text-align: right;" + > + canary_c + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="58px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="908" + y="241.18907251056908" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 58px; text-align: left;" + > + production_a + </div> + </foreignobject> + <foreignobject + class="gl-overflow-visible" + height="58px" + requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" + width="84" + x="908" + y="382" + > + <div + class="gl-display-flex gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break gl-pointer-events-none" + style="height: 58px; text-align: left;" + > + production_d + </div> + </foreignobject> + </g> +</svg> +`; diff --git a/spec/frontend/ci/pipeline_details/dag/components/dag_annotations_spec.js b/spec/frontend/ci/pipeline_details/dag/components/dag_annotations_spec.js new file mode 100644 index 00000000000..d1c338e50c6 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/components/dag_annotations_spec.js @@ -0,0 +1,98 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DagAnnotations from '~/ci/pipeline_details/dag/components/dag_annotations.vue'; +import { singleNote, multiNote } from '../mock_data'; + +describe('The DAG annotations', () => { + let wrapper; + + const getColorBlock = () => wrapper.find('[data-testid="dag-color-block"]'); + const getAllColorBlocks = () => wrapper.findAll('[data-testid="dag-color-block"]'); + const getTextBlock = () => wrapper.find('[data-testid="dag-note-text"]'); + const getAllTextBlocks = () => wrapper.findAll('[data-testid="dag-note-text"]'); + const getToggleButton = () => wrapper.findComponent(GlButton); + + const createComponent = (propsData = {}, method = shallowMount) => { + wrapper = method(DagAnnotations, { + propsData, + data() { + return { + showList: true, + }; + }, + }); + }; + + describe('when there is one annotation', () => { + const currentNote = singleNote['dag-link103']; + + beforeEach(() => { + createComponent({ annotations: singleNote }); + }); + + it('displays the color block', () => { + expect(getColorBlock().exists()).toBe(true); + }); + + it('displays the text block', () => { + expect(getTextBlock().exists()).toBe(true); + expect(getTextBlock().text()).toBe(`${currentNote.source.name} → ${currentNote.target.name}`); + }); + + it('does not display the list toggle link', () => { + expect(getToggleButton().exists()).toBe(false); + }); + }); + + describe('when there are multiple annoataions', () => { + beforeEach(() => { + createComponent({ annotations: multiNote }); + }); + + it('displays a color block for each link', () => { + expect(getAllColorBlocks().length).toBe(Object.keys(multiNote).length); + }); + + it('displays a text block for each link', () => { + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + + Object.values(multiNote).forEach((item, idx) => { + expect(getAllTextBlocks().at(idx).text()).toBe(`${item.source.name} → ${item.target.name}`); + }); + }); + + it('displays the list toggle link', () => { + expect(getToggleButton().exists()).toBe(true); + expect(getToggleButton().text()).toBe('Hide list'); + }); + }); + + describe('the list toggle', () => { + beforeEach(() => { + createComponent({ annotations: multiNote }, mount); + }); + + describe('clicking hide', () => { + it('hides listed items and changes text to show', async () => { + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + expect(getToggleButton().text()).toBe('Hide list'); + getToggleButton().trigger('click'); + await nextTick(); + expect(getAllTextBlocks().length).toBe(0); + expect(getToggleButton().text()).toBe('Show list'); + }); + }); + + describe('clicking show', () => { + it('shows listed items and changes text to hide', async () => { + getToggleButton().trigger('click'); + getToggleButton().trigger('click'); + + await nextTick(); + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + expect(getToggleButton().text()).toBe('Hide list'); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/dag/components/dag_graph_spec.js b/spec/frontend/ci/pipeline_details/dag/components/dag_graph_spec.js new file mode 100644 index 00000000000..aff83c00e79 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/components/dag_graph_spec.js @@ -0,0 +1,209 @@ +import { shallowMount } from '@vue/test-utils'; +import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/ci/pipeline_details/dag/constants'; +import DagGraph from '~/ci/pipeline_details/dag/components/dag_graph.vue'; +import { createSankey } from '~/ci/pipeline_details/dag/utils/drawing_utils'; +import { highlightIn, highlightOut } from '~/ci/pipeline_details/dag/utils/interactions'; +import { removeOrphanNodes } from '~/ci/pipeline_details/utils/parsing_utils'; +import { parsedData } from '../mock_data'; + +describe('The DAG graph', () => { + let wrapper; + + const getGraph = () => wrapper.find('.dag-graph-container > svg'); + const getAllLinks = () => wrapper.findAll(`.${LINK_SELECTOR}`); + const getAllNodes = () => wrapper.findAll(`.${NODE_SELECTOR}`); + const getAllLabels = () => wrapper.findAll('foreignObject'); + + const createComponent = (propsData = {}) => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = shallowMount(DagGraph, { + attachTo: document.body, + propsData, + data() { + return { + color: () => {}, + width: 0, + height: 0, + }; + }, + }); + }; + + beforeEach(() => { + createComponent({ graphData: parsedData }); + }); + + describe('in the basic case', () => { + beforeEach(() => { + /* + The graph uses random to offset links. To keep the snapshot consistent, + we mock Math.random. Wheeeee! + */ + const randomNumber = jest.spyOn(global.Math, 'random'); + randomNumber.mockImplementation(() => 0.2); + createComponent({ graphData: parsedData }); + }); + + it('renders the graph svg', () => { + expect(getGraph().exists()).toBe(true); + expect(getGraph().html()).toMatchSnapshot(); + }); + }); + + describe('links', () => { + it('renders the expected number of links', () => { + expect(getAllLinks()).toHaveLength(parsedData.links.length); + }); + + it('renders the expected number of gradients', () => { + expect(wrapper.findAll('linearGradient')).toHaveLength(parsedData.links.length); + }); + + it('renders the expected number of clip paths', () => { + expect(wrapper.findAll('clipPath')).toHaveLength(parsedData.links.length); + }); + }); + + describe('nodes and labels', () => { + const sankeyNodes = createSankey()(parsedData).nodes; + const processedNodes = removeOrphanNodes(sankeyNodes); + + describe('nodes', () => { + it('renders the expected number of nodes', () => { + expect(getAllNodes()).toHaveLength(processedNodes.length); + }); + }); + + describe('labels', () => { + it('renders the expected number of labels as foreignObjects', () => { + expect(getAllLabels()).toHaveLength(processedNodes.length); + }); + + it('renders the title as text', () => { + expect(getAllLabels().at(0).text()).toBe(parsedData.nodes[0].name); + }); + }); + }); + + describe('interactions', () => { + const strokeOpacity = (opacity) => `stroke-opacity: ${opacity};`; + const baseOpacity = () => wrapper.vm.$options.viewOptions.baseOpacity; + + describe('links', () => { + const liveLink = () => getAllLinks().at(4); + const otherLink = () => getAllLinks().at(1); + + describe('on hover', () => { + it('sets the link opacity to baseOpacity and background links to 0.2', () => { + liveLink().trigger('mouseover'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + }); + + it('reverts the styles on mouseout', () => { + liveLink().trigger('mouseover'); + liveLink().trigger('mouseout'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + }); + }); + + describe('on click', () => { + describe('toggles link liveness', () => { + it('turns link on', () => { + liveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + }); + + it('turns link off on second click', () => { + liveLink().trigger('click'); + liveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + }); + }); + + it('the link remains live even after mouseout', () => { + liveLink().trigger('click'); + liveLink().trigger('mouseout'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + }); + + it('preserves state when multiple links are toggled on and off', () => { + const anotherLiveLink = () => getAllLinks().at(2); + + liveLink().trigger('click'); + anotherLiveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + + anotherLiveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + + liveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + }); + }); + }); + + describe('nodes', () => { + const liveNode = () => getAllNodes().at(10); + const anotherLiveNode = () => getAllNodes().at(5); + const nodesNotHighlighted = () => getAllNodes().filter((n) => !n.classes(IS_HIGHLIGHTED)); + const linksNotHighlighted = () => getAllLinks().filter((n) => !n.classes(IS_HIGHLIGHTED)); + const nodesHighlighted = () => getAllNodes().filter((n) => n.classes(IS_HIGHLIGHTED)); + const linksHighlighted = () => getAllLinks().filter((n) => n.classes(IS_HIGHLIGHTED)); + + describe('on click', () => { + it('highlights the clicked node and predecessors', () => { + liveNode().trigger('click'); + + expect(nodesNotHighlighted().length < getAllNodes().length).toBe(true); + expect(linksNotHighlighted().length < getAllLinks().length).toBe(true); + + linksHighlighted().wrappers.forEach((link) => { + expect(link.attributes('style')).toBe(strokeOpacity(highlightIn)); + }); + + nodesHighlighted().wrappers.forEach((node) => { + expect(node.attributes('stroke')).not.toBe('#f2f2f2'); + }); + + linksNotHighlighted().wrappers.forEach((link) => { + expect(link.attributes('style')).toBe(strokeOpacity(highlightOut)); + }); + + nodesNotHighlighted().wrappers.forEach((node) => { + expect(node.attributes('stroke')).toBe('#f2f2f2'); + }); + }); + + it('toggles path off on second click', () => { + liveNode().trigger('click'); + liveNode().trigger('click'); + + expect(nodesNotHighlighted().length).toBe(getAllNodes().length); + expect(linksNotHighlighted().length).toBe(getAllLinks().length); + }); + + it('preserves state when multiple nodes are toggled on and off', () => { + anotherLiveNode().trigger('click'); + liveNode().trigger('click'); + anotherLiveNode().trigger('click'); + expect(nodesNotHighlighted().length < getAllNodes().length).toBe(true); + expect(linksNotHighlighted().length < getAllLinks().length).toBe(true); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/dag/dag_spec.js b/spec/frontend/ci/pipeline_details/dag/dag_spec.js new file mode 100644 index 00000000000..de9490be607 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/dag_spec.js @@ -0,0 +1,168 @@ +import { GlAlert, GlEmptyState } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/ci/pipeline_details/dag/constants'; +import Dag from '~/ci/pipeline_details/dag/dag.vue'; +import DagAnnotations from '~/ci/pipeline_details/dag/components/dag_annotations.vue'; +import DagGraph from '~/ci/pipeline_details/dag/components/dag_graph.vue'; + +import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/ci/pipeline_details/constants'; +import { + mockParsedGraphQLNodes, + tooSmallGraph, + unparseableGraph, + graphWithoutDependencies, + singleNote, + multiNote, +} from './mock_data'; + +describe('Pipeline DAG graph wrapper', () => { + let wrapper; + const getAlert = () => wrapper.findComponent(GlAlert); + const getAllAlerts = () => wrapper.findAllComponents(GlAlert); + const getGraph = () => wrapper.findComponent(DagGraph); + const getNotes = () => wrapper.findComponent(DagAnnotations); + const getErrorText = (type) => wrapper.vm.$options.errorTexts[type]; + const getEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = ({ + graphData = mockParsedGraphQLNodes, + provideOverride = {}, + method = shallowMount, + } = {}) => { + wrapper = method(Dag, { + provide: { + pipelineProjectPath: 'root/abc-dag', + pipelineIid: '1', + emptySvgPath: '/my-svg', + dagDocPath: '/my-doc', + ...provideOverride, + }, + data() { + return { + graphData, + showFailureAlert: false, + }; + }, + }); + }; + + describe('when a query argument is undefined', () => { + beforeEach(() => { + createComponent({ + provideOverride: { pipelineProjectPath: undefined }, + graphData: null, + }); + }); + + it('does not render the graph', () => { + expect(getGraph().exists()).toBe(false); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('when all query variables are defined', () => { + describe('but the parse fails', () => { + beforeEach(() => { + createComponent({ + graphData: unparseableGraph, + }); + }); + + it('shows the PARSE_FAILURE alert and not the graph', () => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(PARSE_FAILURE)); + expect(getGraph().exists()).toBe(false); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('parse succeeds', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('shows the graph', () => { + expect(getGraph().exists()).toBe(true); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('parse succeeds, but the resulting graph is too small', () => { + beforeEach(() => { + createComponent({ + graphData: tooSmallGraph, + }); + }); + + it('shows the UNSUPPORTED_DATA alert and not the graph', () => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(UNSUPPORTED_DATA)); + expect(getGraph().exists()).toBe(false); + }); + + it('does not show the empty dag graph state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('the returned data is empty', () => { + beforeEach(() => { + createComponent({ + method: mount, + graphData: graphWithoutDependencies, + }); + }); + + it('does not render an error alert or the graph', () => { + expect(getAllAlerts().length).toBe(0); + expect(getGraph().exists()).toBe(false); + }); + + it('shows the empty dag graph state', () => { + expect(getEmptyState().exists()).toBe(true); + }); + }); + }); + + describe('annotations', () => { + beforeEach(() => { + createComponent(); + }); + + it('toggles on link mouseover and mouseout', async () => { + const currentNote = singleNote['dag-link103']; + + expect(getNotes().exists()).toBe(false); + + getGraph().vm.$emit('update-annotation', { type: ADD_NOTE, data: currentNote }); + await nextTick(); + expect(getNotes().exists()).toBe(true); + + getGraph().vm.$emit('update-annotation', { type: REMOVE_NOTE, data: currentNote }); + await nextTick(); + expect(getNotes().exists()).toBe(false); + }); + + it('toggles on node and link click', async () => { + expect(getNotes().exists()).toBe(false); + + getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: multiNote }); + await nextTick(); + expect(getNotes().exists()).toBe(true); + + getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: {} }); + await nextTick(); + expect(getNotes().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/dag/mock_data.js b/spec/frontend/ci/pipeline_details/dag/mock_data.js new file mode 100644 index 00000000000..f27e7cf3d6b --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/mock_data.js @@ -0,0 +1,674 @@ +export const tooSmallGraph = [ + { + category: 'test', + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + category: 'test', + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], + }, + { + category: 'fixtures', + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + { + category: 'un-needed', + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, +]; + +export const graphWithoutDependencies = [ + { + category: 'test', + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + category: 'test', + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec' }], + }, + { + category: 'fixtures', + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + { + category: 'un-needed', + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, +]; + +export const unparseableGraph = [ + { + name: 'test', + groups: [ + { + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2', needs: ['frontend fixtures'] }, { name: 'jest 2/2' }], + }, + { + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], + }, + ], + }, + { + name: 'un-needed', + groups: [ + { + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, + ], + }, +]; + +/* + This represents data that has been parsed by the wrapper +*/ +export const parsedData = { + nodes: [ + { + name: 'build_a', + size: 1, + jobs: [ + { + name: 'build_a', + }, + ], + category: 'build', + }, + { + name: 'build_b', + size: 1, + jobs: [ + { + name: 'build_b', + }, + ], + category: 'build', + }, + { + name: 'test_a', + size: 1, + jobs: [ + { + name: 'test_a', + needs: ['build_a'], + }, + ], + category: 'test', + }, + { + name: 'test_b', + size: 1, + jobs: [ + { + name: 'test_b', + }, + ], + category: 'test', + }, + { + name: 'test_c', + size: 1, + jobs: [ + { + name: 'test_c', + }, + ], + category: 'test', + }, + { + name: 'test_d', + size: 1, + jobs: [ + { + name: 'test_d', + }, + ], + category: 'test', + }, + { + name: 'post_test_a', + size: 1, + jobs: [ + { + name: 'post_test_a', + }, + ], + category: 'post-test', + }, + { + name: 'post_test_b', + size: 1, + jobs: [ + { + name: 'post_test_b', + }, + ], + category: 'post-test', + }, + { + name: 'post_test_c', + size: 1, + jobs: [ + { + name: 'post_test_c', + needs: ['test_a', 'test_b'], + }, + ], + category: 'post-test', + }, + { + name: 'staging_a', + size: 1, + jobs: [ + { + name: 'staging_a', + needs: ['post_test_a'], + }, + ], + category: 'staging', + }, + { + name: 'staging_b', + size: 1, + jobs: [ + { + name: 'staging_b', + needs: ['post_test_b'], + }, + ], + category: 'staging', + }, + { + name: 'staging_c', + size: 1, + jobs: [ + { + name: 'staging_c', + }, + ], + category: 'staging', + }, + { + name: 'staging_d', + size: 1, + jobs: [ + { + name: 'staging_d', + }, + ], + category: 'staging', + }, + { + name: 'staging_e', + size: 1, + jobs: [ + { + name: 'staging_e', + }, + ], + category: 'staging', + }, + { + name: 'canary_a', + size: 1, + jobs: [ + { + name: 'canary_a', + needs: ['staging_a', 'staging_b'], + }, + ], + category: 'canary', + }, + { + name: 'canary_b', + size: 1, + jobs: [ + { + name: 'canary_b', + }, + ], + category: 'canary', + }, + { + name: 'canary_c', + size: 1, + jobs: [ + { + name: 'canary_c', + needs: ['staging_b'], + }, + ], + category: 'canary', + }, + { + name: 'production_a', + size: 1, + jobs: [ + { + name: 'production_a', + needs: ['canary_a'], + }, + ], + category: 'production', + }, + { + name: 'production_b', + size: 1, + jobs: [ + { + name: 'production_b', + }, + ], + category: 'production', + }, + { + name: 'production_c', + size: 1, + jobs: [ + { + name: 'production_c', + }, + ], + category: 'production', + }, + { + name: 'production_d', + size: 1, + jobs: [ + { + name: 'production_d', + needs: ['canary_c'], + }, + ], + category: 'production', + }, + ], + links: [ + { + source: 'build_a', + target: 'test_a', + value: 10, + }, + { + source: 'test_a', + target: 'post_test_c', + value: 10, + }, + { + source: 'test_b', + target: 'post_test_c', + value: 10, + }, + { + source: 'post_test_a', + target: 'staging_a', + value: 10, + }, + { + source: 'post_test_b', + target: 'staging_b', + value: 10, + }, + { + source: 'staging_a', + target: 'canary_a', + value: 10, + }, + { + source: 'staging_b', + target: 'canary_a', + value: 10, + }, + { + source: 'staging_b', + target: 'canary_c', + value: 10, + }, + { + source: 'canary_a', + target: 'production_a', + value: 10, + }, + { + source: 'canary_c', + target: 'production_d', + value: 10, + }, + ], +}; + +export const singleNote = { + 'dag-link103': { + uid: 'dag-link103', + source: { + name: 'canary_a', + color: '#b31756', + }, + target: { + name: 'production_a', + color: '#b24800', + }, + }, +}; + +export const multiNote = { + ...singleNote, + 'dag-link104': { + uid: 'dag-link104', + source: { + name: 'build_a', + color: '#e17223', + }, + target: { + name: 'test_c', + color: '#006887', + }, + }, + 'dag-link105': { + uid: 'dag-link105', + source: { + name: 'test_c', + color: '#006887', + }, + target: { + name: 'post_test_c', + color: '#3547de', + }, + }, +}; + +export const missingJob = 'missing_job'; + +/* + It is important that the base include parallel jobs + as well as non-parallel jobs with spaces in the name to prevent + us relying on spaces as an indicator. +*/ + +export const mockParsedGraphQLNodes = [ + { + category: 'build', + name: 'build_a', + size: 1, + jobs: [ + { + name: 'build_a', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'build', + name: 'build_b', + size: 1, + jobs: [ + { + name: 'build_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_a', + size: 1, + jobs: [ + { + name: 'test_a', + needs: ['build_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_b', + size: 1, + jobs: [ + { + name: 'test_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_c', + size: 1, + jobs: [ + { + name: 'test_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_d', + size: 1, + jobs: [ + { + name: 'test_d', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_a', + size: 1, + jobs: [ + { + name: 'post_test_a', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_b', + size: 1, + jobs: [ + { + name: 'post_test_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_c', + size: 1, + jobs: [ + { + name: 'post_test_c', + needs: ['test_b', 'test_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_a', + size: 1, + jobs: [ + { + name: 'staging_a', + needs: ['post_test_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_b', + size: 1, + jobs: [ + { + name: 'staging_b', + needs: ['post_test_b'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_c', + size: 1, + jobs: [ + { + name: 'staging_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_d', + size: 1, + jobs: [ + { + name: 'staging_d', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_e', + size: 1, + jobs: [ + { + name: 'staging_e', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_a', + size: 1, + jobs: [ + { + name: 'canary_a', + needs: ['staging_b', 'staging_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_b', + size: 1, + jobs: [ + { + name: 'canary_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_c', + size: 1, + jobs: [ + { + name: 'canary_c', + needs: ['staging_b'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_a', + size: 1, + jobs: [ + { + name: 'production_a', + needs: ['canary_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_b', + size: 1, + jobs: [ + { + name: 'production_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_c', + size: 1, + jobs: [ + { + name: 'production_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_d', + size: 1, + jobs: [ + { + name: 'production_d', + needs: ['canary_c'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_e', + size: 1, + jobs: [ + { + name: 'production_e', + needs: [missingJob], + }, + ], + __typename: 'CiGroup', + }, +]; diff --git a/spec/frontend/ci/pipeline_details/dag/utils/drawing_utils_spec.js b/spec/frontend/ci/pipeline_details/dag/utils/drawing_utils_spec.js new file mode 100644 index 00000000000..aea8e894bd4 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/utils/drawing_utils_spec.js @@ -0,0 +1,57 @@ +import { createSankey } from '~/ci/pipeline_details/dag/utils/drawing_utils'; +import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; +import { mockParsedGraphQLNodes } from '../mock_data'; + +describe('DAG visualization drawing utilities', () => { + const parsed = parseData(mockParsedGraphQLNodes); + + const layoutSettings = { + width: 200, + height: 200, + nodeWidth: 10, + nodePadding: 20, + paddingForLabels: 100, + }; + + const sankeyLayout = createSankey(layoutSettings)(parsed); + + describe('createSankey', () => { + it('returns a nodes data structure with expected d3-added properties', () => { + const exampleNode = sankeyLayout.nodes[0]; + expect(exampleNode).toHaveProperty('sourceLinks'); + expect(exampleNode).toHaveProperty('targetLinks'); + expect(exampleNode).toHaveProperty('depth'); + expect(exampleNode).toHaveProperty('layer'); + expect(exampleNode).toHaveProperty('x0'); + expect(exampleNode).toHaveProperty('x1'); + expect(exampleNode).toHaveProperty('y0'); + expect(exampleNode).toHaveProperty('y1'); + }); + + it('returns a links data structure with expected d3-added properties', () => { + const exampleLink = sankeyLayout.links[0]; + expect(exampleLink).toHaveProperty('source'); + expect(exampleLink).toHaveProperty('target'); + expect(exampleLink).toHaveProperty('width'); + expect(exampleLink).toHaveProperty('y0'); + expect(exampleLink).toHaveProperty('y1'); + }); + + describe('data structure integrity', () => { + const newObject = { name: 'bad-actor' }; + + beforeEach(() => { + sankeyLayout.nodes.unshift(newObject); + }); + + it('sankey does not propagate changes back to the original', () => { + expect(sankeyLayout.nodes[0]).toBe(newObject); + expect(parsed.nodes[0]).not.toBe(newObject); + }); + + afterEach(() => { + sankeyLayout.nodes.shift(); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/__snapshots__/links_inner_spec.js.snap b/spec/frontend/ci/pipeline_details/graph/components/__snapshots__/links_inner_spec.js.snap new file mode 100644 index 00000000000..b31c0e59a33 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/__snapshots__/links_inner_spec.js.snap @@ -0,0 +1,110 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Links Inner component with a large number of needs matches snapshot and has expected path 1`] = ` +<div + class="gl-display-flex gl-relative" + totalgroups="10" +> + <svg + class="gl-absolute gl-pointer-events-none" + height="445px" + id="reference-0" + viewBox="0,0,1019,445" + width="1019px" + > + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M202,118C52,118,52,138,102,138" + stroke-width="2" + /> + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M202,118C62,118,62,148,112,148" + stroke-width="2" + /> + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M222,138C72,138,72,158,122,158" + stroke-width="2" + /> + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M212,128C82,128,82,168,132,168" + stroke-width="2" + /> + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M232,148C92,148,92,178,142,178" + stroke-width="2" + /> + </svg> +</div> +`; + +exports[`Links Inner component with a parallel need matches snapshot and has expected path 1`] = ` +<div + class="gl-display-flex gl-relative" + totalgroups="10" +> + <svg + class="gl-absolute gl-pointer-events-none" + height="445px" + id="reference-0" + viewBox="0,0,1019,445" + width="1019px" + > + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M192,108C32,108,32,118,82,118" + stroke-width="2" + /> + </svg> +</div> +`; + +exports[`Links Inner component with one need matches snapshot and has expected path 1`] = ` +<div + class="gl-display-flex gl-relative" + totalgroups="10" +> + <svg + class="gl-absolute gl-pointer-events-none" + height="445px" + id="reference-0" + viewBox="0,0,1019,445" + width="1019px" + > + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M202,118C52,118,52,138,102,138" + stroke-width="2" + /> + </svg> +</div> +`; + +exports[`Links Inner component with same stage needs matches snapshot and has expected path 1`] = ` +<div + class="gl-display-flex gl-relative" + totalgroups="10" +> + <svg + class="gl-absolute gl-pointer-events-none" + height="445px" + id="reference-0" + viewBox="0,0,1019,445" + width="1019px" + > + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M192,108C32,108,32,118,82,118" + stroke-width="2" + /> + <path + class="gl-fill-transparent gl-stroke-gray-200 gl-transition-duration-slow gl-transition-timing-function-ease" + d="M202,118C42,118,42,128,92,128" + stroke-width="2" + /> + </svg> +</div> +`; diff --git a/spec/frontend/ci/pipeline_details/graph/components/action_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/action_component_spec.js new file mode 100644 index 00000000000..9e177156d0e --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/action_component_spec.js @@ -0,0 +1,116 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; + +describe('pipeline graph action component', () => { + let wrapper; + let mock; + const findButton = () => wrapper.findComponent(GlButton); + const findTooltipWrapper = () => wrapper.find('[data-testid="ci-action-icon-tooltip-wrapper"]'); + + const defaultProps = { + tooltipText: 'bar', + link: 'foo', + actionIcon: 'cancel', + }; + + const createComponent = ({ props } = {}) => { + wrapper = mount(ActionComponent, { + propsData: { ...defaultProps, ...props }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onPost('foo.json').reply(HTTP_STATUS_OK); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('render', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render the provided title as a bootstrap tooltip', () => { + expect(findTooltipWrapper().attributes('title')).toBe('bar'); + }); + + it('should update bootstrap tooltip when title changes', async () => { + wrapper.setProps({ tooltipText: 'changed' }); + + await nextTick(); + expect(findTooltipWrapper().attributes('title')).toBe('changed'); + }); + + it('should render an svg', () => { + expect(wrapper.find('.ci-action-icon-wrapper').exists()).toBe(true); + expect(wrapper.find('svg').exists()).toBe(true); + }); + }); + + describe('on click', () => { + beforeEach(() => { + createComponent(); + }); + + it('emits `pipelineActionRequestComplete` after a successful request', async () => { + findButton().trigger('click'); + + await waitForPromises(); + + expect(wrapper.emitted().pipelineActionRequestComplete).toHaveLength(1); + }); + + it('renders a loading icon while waiting for request', async () => { + findButton().trigger('click'); + + await nextTick(); + expect(wrapper.find('.js-action-icon-loading').exists()).toBe(true); + }); + }); + + describe('when has a confirmation modal', () => { + beforeEach(() => { + createComponent({ props: { withConfirmationModal: true, shouldTriggerClick: false } }); + }); + + describe('and a first click is initiated', () => { + beforeEach(async () => { + findButton().trigger('click'); + + await waitForPromises(); + }); + + it('emits `showActionConfirmationModal` event', () => { + expect(wrapper.emitted().showActionConfirmationModal).toHaveLength(1); + }); + + it('does not emit `pipelineActionRequestComplete` event', () => { + expect(wrapper.emitted().pipelineActionRequestComplete).toBeUndefined(); + }); + }); + + describe('and the `shouldTriggerClick` value becomes true', () => { + beforeEach(async () => { + await wrapper.setProps({ shouldTriggerClick: true }); + }); + + it('does not emit `showActionConfirmationModal` event', () => { + expect(wrapper.emitted().showActionConfirmationModal).toBeUndefined(); + }); + + it('emits `actionButtonClicked` event', () => { + expect(wrapper.emitted().actionButtonClicked).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js new file mode 100644 index 00000000000..a98e79c69fe --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js @@ -0,0 +1,182 @@ +import { shallowMount } from '@vue/test-utils'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { LAYER_VIEW, STAGE_VIEW } from '~/ci/pipeline_details/graph/constants'; +import PipelineGraph from '~/ci/pipeline_details/graph/components/graph_component.vue'; +import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; +import LinkedPipelinesColumn from '~/ci/pipeline_details/graph/components/linked_pipelines_column.vue'; +import StageColumnComponent from '~/ci/pipeline_details/graph/components/stage_column_component.vue'; +import { calculatePipelineLayersInfo } from '~/ci/pipeline_details/graph/utils'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; + +import { generateResponse, pipelineWithUpstreamDownstream } from '../mock_data'; + +describe('graph component', () => { + let wrapper; + + const findDownstreamColumn = () => wrapper.findByTestId('downstream-pipelines'); + const findLinkedColumns = () => wrapper.findAllComponents(LinkedPipelinesColumn); + const findLinksLayer = () => wrapper.findComponent(LinksLayer); + const findStageColumns = () => wrapper.findAllComponents(StageColumnComponent); + const findStageNameInJob = () => wrapper.findByTestId('stage-name-in-job'); + + const defaultProps = { + pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), + showLinks: false, + viewType: STAGE_VIEW, + configPaths: { + metricsPath: '', + graphqlResourceEtag: 'this/is/a/path', + }, + }; + + const defaultData = { + measurements: { + width: 800, + height: 800, + }, + }; + + const createComponent = ({ + data = {}, + mountFn = shallowMount, + props = {}, + stubOverride = {}, + } = {}) => { + wrapper = mountFn(PipelineGraph, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return { + ...defaultData, + ...data, + }; + }, + stubs: { + 'links-inner': true, + 'linked-pipeline': true, + 'job-item': true, + 'job-group-dropdown': true, + ...stubOverride, + }, + }); + }; + + describe('with data', () => { + beforeEach(() => { + createComponent({ mountFn: mountExtended }); + }); + + it('renders the main columns in the graph', () => { + expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); + }); + + it('renders the links layer', () => { + expect(findLinksLayer().exists()).toBe(true); + }); + + it('does not display stage name on the job in default (stage) mode', () => { + expect(findStageNameInJob().exists()).toBe(false); + }); + + describe('when column requests a refresh', () => { + beforeEach(() => { + findStageColumns().at(0).vm.$emit('refreshPipelineGraph'); + }); + + it('refreshPipelineGraph is emitted', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); + }); + + describe('when column request an update to the retry confirmation modal', () => { + beforeEach(() => { + findStageColumns().at(0).vm.$emit('setSkipRetryModal'); + }); + + it('setSkipRetryModal is emitted', () => { + expect(wrapper.emitted().setSkipRetryModal).toHaveLength(1); + }); + }); + + describe('when links are present', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + stubOverride: { 'job-item': false }, + data: { hoveredJobName: 'test_a' }, + }); + findLinksLayer().vm.$emit('highlightedJobsChange', ['test_c', 'build_c']); + }); + + it('dims unrelated jobs', () => { + const unrelatedJob = wrapper.findComponent(JobItem); + expect(findLinksLayer().emitted().highlightedJobsChange).toHaveLength(1); + expect(unrelatedJob.classes('gl-opacity-3')).toBe(true); + }); + }); + }); + + describe('when linked pipelines are not present', () => { + beforeEach(() => { + createComponent({ mountFn: mountExtended }); + }); + + it('should not render a linked pipelines column', () => { + expect(findLinkedColumns()).toHaveLength(0); + }); + }); + + describe('when linked pipelines are present', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse) }, + }); + }); + + it('should render linked pipelines columns', () => { + expect(findLinkedColumns()).toHaveLength(2); + }); + }); + + describe('in layers mode', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + stubOverride: { + 'job-item': false, + 'job-group-dropdown': false, + }, + props: { + viewType: LAYER_VIEW, + computedPipelineInfo: calculatePipelineLayersInfo(defaultProps.pipeline, 'layer', ''), + }, + }); + }); + + it('displays the stage name on the job', () => { + expect(findStageNameInJob().exists()).toBe(true); + }); + }); + + describe('downstream pipelines', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse), + }, + }); + }); + + it('filters pipelines spawned from the same trigger job', () => { + // The mock data has one downstream with `retried: true and one + // with retried false. We filter the `retried: true` out so we + // should only pass one downstream + expect(findDownstreamColumn().props().linkedPipelines).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/graph_view_selector_spec.js b/spec/frontend/ci/pipeline_details/graph/components/graph_view_selector_spec.js new file mode 100644 index 00000000000..bf98995de9c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/graph_view_selector_spec.js @@ -0,0 +1,217 @@ +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { LAYER_VIEW, STAGE_VIEW } from '~/ci/pipeline_details/graph/constants'; +import GraphViewSelector from '~/ci/pipeline_details/graph/components/graph_view_selector.vue'; + +describe('the graph view selector component', () => { + let wrapper; + + const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]'); + const findViewTypeSelector = () => wrapper.findComponent(GlButtonGroup); + const findStageViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(0); + const findLayerViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(1); + const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]'); + const findToggleLoader = () => findDependenciesToggle().findComponent(GlLoadingIcon); + const findHoverTip = () => wrapper.findComponent(GlAlert); + + const defaultProps = { + showLinks: false, + tipPreviouslyDismissed: false, + type: STAGE_VIEW, + }; + + const defaultData = { + hoverTipDismissed: false, + isToggleLoading: false, + isSwitcherLoading: false, + showLinksActive: false, + }; + + const createComponent = ({ data = {}, mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(GraphViewSelector, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return { + ...defaultData, + ...data, + }; + }, + }); + }; + + describe('when showing stage view', () => { + beforeEach(() => { + createComponent({ mountFn: mount }); + }); + + it('shows the Stage view button as selected', () => { + expect(findStageViewButton().classes('selected')).toBe(true); + }); + + it('shows the Job dependencies view button not selected', () => { + expect(findLayerViewButton().exists()).toBe(true); + expect(findLayerViewButton().classes('selected')).toBe(false); + }); + + it('does not show the Job dependencies (links) toggle', () => { + expect(findDependenciesToggle().exists()).toBe(false); + }); + }); + + describe('when showing Job dependencies view', () => { + beforeEach(() => { + createComponent({ + mountFn: mount, + props: { + type: LAYER_VIEW, + }, + }); + }); + + it('shows the Job dependencies view as selected', () => { + expect(findLayerViewButton().classes('selected')).toBe(true); + }); + + it('shows the Stage button as not selected', () => { + expect(findStageViewButton().exists()).toBe(true); + expect(findStageViewButton().classes('selected')).toBe(false); + }); + + it('shows the Job dependencies (links) toggle', () => { + expect(findDependenciesToggle().exists()).toBe(true); + }); + }); + + describe('events', () => { + beforeEach(() => { + createComponent({ + mountFn: mount, + props: { + type: LAYER_VIEW, + }, + }); + }); + + it('shows loading state and emits updateViewType when view type toggled', async () => { + expect(wrapper.emitted().updateViewType).toBeUndefined(); + expect(findSwitcherLoader().exists()).toBe(false); + + await findStageViewButton().trigger('click'); + /* + Loading happens before the event is emitted or timers are run. + Then we run the timer because the event is emitted in setInterval + which is what gives the loader a chace to show up. + */ + expect(findSwitcherLoader().exists()).toBe(true); + jest.runOnlyPendingTimers(); + + expect(wrapper.emitted().updateViewType).toHaveLength(1); + expect(wrapper.emitted().updateViewType).toEqual([[STAGE_VIEW]]); + }); + + it('shows loading state and emits updateShowLinks when show links toggle is clicked', async () => { + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + expect(findToggleLoader().exists()).toBe(false); + + await findDependenciesToggle().vm.$emit('change', true); + /* + Loading happens before the event is emitted or timers are run. + Then we run the timer because the event is emitted in setInterval + which is what gives the loader a chace to show up. + */ + expect(findToggleLoader().exists()).toBe(true); + jest.runOnlyPendingTimers(); + + expect(wrapper.emitted().updateShowLinksState).toHaveLength(1); + expect(wrapper.emitted().updateShowLinksState).toEqual([[true]]); + }); + + it('does not emit an event if the click occurs on the currently selected view button', async () => { + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + + await findLayerViewButton().trigger('click'); + + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + }); + }); + + describe('hover tip callout', () => { + describe('when links are live and it has not been previously dismissed', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + type: LAYER_VIEW, + }, + data: { + showLinksActive: true, + }, + mountFn: mount, + }); + }); + + it('is displayed', () => { + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + }); + + it('emits dismissHoverTip event when the tip is dismissed', async () => { + expect(wrapper.emitted().dismissHoverTip).toBeUndefined(); + await findHoverTip().find('button').trigger('click'); + expect(wrapper.emitted().dismissHoverTip).toHaveLength(1); + }); + + it('is displayed at first then hidden on swith to STAGE_VIEW then displayed on switch to LAYER_VIEW', async () => { + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + + await findStageViewButton().trigger('click'); + expect(findHoverTip().exists()).toBe(false); + + await findLayerViewButton().trigger('click'); + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + }); + }); + + describe('when links are live and it has been previously dismissed', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + tipPreviouslyDismissed: true, + type: LAYER_VIEW, + }, + data: { + showLinksActive: true, + }, + }); + }); + + it('is not displayed', () => { + expect(findHoverTip().exists()).toBe(false); + }); + }); + + describe('when links are not live', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + type: LAYER_VIEW, + }, + data: { + showLinksActive: false, + }, + }); + }); + + it('is not displayed', () => { + expect(findHoverTip().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_group_dropdown_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_group_dropdown_spec.js new file mode 100644 index 00000000000..d5a1cfffe68 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/job_group_dropdown_spec.js @@ -0,0 +1,84 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import JobGroupDropdown from '~/ci/pipeline_details/graph/components/job_group_dropdown.vue'; + +describe('job group dropdown component', () => { + const group = { + jobs: [ + { + id: 4256, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + has_details: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, + }, + { + id: 4299, + name: 'test', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4299', + has_details: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4299/retry', + method: 'post', + }, + }, + }, + ], + name: 'rspec:linux', + size: 2, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + has_details: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, + }; + + let wrapper; + const findButton = () => wrapper.find('button'); + + const createComponent = ({ mountFn = shallowMount }) => { + wrapper = mountFn(JobGroupDropdown, { propsData: { group } }); + }; + + beforeEach(() => { + createComponent({ mountFn: mount }); + }); + + it('renders button with group name and size', () => { + expect(findButton().text()).toContain(group.name); + expect(findButton().text()).toContain(group.size.toString()); + }); + + it('renders dropdown with jobs', () => { + expect(wrapper.findAll('.scrollable-menu>ul>li').length).toBe(group.jobs.length); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js new file mode 100644 index 00000000000..107f0df5c02 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js @@ -0,0 +1,492 @@ +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import { GlBadge, GlModal, GlToast } from '@gitlab/ui'; +import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; +import axios from '~/lib/utils/axios_utils'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; + +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + delayedJob, + mockJob, + mockJobWithoutDetails, + mockJobWithUnauthorizedAction, + mockFailedJob, + triggerJob, + triggerJobWithRetryAction, +} from '../mock_data'; + +describe('pipeline graph job item', () => { + useLocalStorageSpy(); + Vue.use(GlToast); + + let wrapper; + let mockAxios; + + const findJobWithoutLink = () => wrapper.findByTestId('job-without-link'); + const findJobWithLink = () => wrapper.findByTestId('job-with-link'); + const findActionVueComponent = () => wrapper.findComponent(ActionComponent); + const findActionComponent = () => wrapper.findByTestId('ci-action-component'); + const findBadge = () => wrapper.findComponent(GlBadge); + const findJobLink = () => wrapper.findByTestId('job-with-link'); + const findModal = () => wrapper.findComponent(GlModal); + + const clickOnModalPrimaryBtn = () => findModal().vm.$emit('primary'); + const clickOnModalCancelBtn = () => findModal().vm.$emit('hide'); + const clickOnModalCloseBtn = () => findModal().vm.$emit('close'); + + const myCustomClass1 = 'my-class-1'; + const myCustomClass2 = 'my-class-2'; + + const defaultProps = { + job: mockJob, + }; + + const createWrapper = ({ props, data, mountFn = mountExtended, mocks = {} } = {}) => { + wrapper = mountFn(JobItem, { + data() { + return { + ...data, + }; + }, + propsData: { + ...defaultProps, + ...props, + }, + mocks: { + ...mocks, + }, + }); + }; + + const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + describe('name with link', () => { + it('should render the job name and status with a link', async () => { + createWrapper(); + + await nextTick(); + const link = findJobLink(); + + expect(link.attributes('href')).toBe(mockJob.status.detailsPath); + + expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`); + + expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + + expect(wrapper.text()).toBe(mockJob.name); + }); + }); + + describe('name without link', () => { + beforeEach(() => { + createWrapper({ + props: { + job: mockJobWithoutDetails, + cssClassJobName: 'css-class-job-name', + jobHovered: 'test', + }, + }); + }); + + it('should render status and name', () => { + expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + expect(findJobLink().exists()).toBe(false); + + expect(wrapper.text()).toBe(mockJobWithoutDetails.name); + }); + + it('should apply hover class and provided class name', () => { + expect(findJobWithoutLink().classes()).toContain('css-class-job-name'); + }); + }); + + describe('action icon', () => { + it('should render the action icon', () => { + createWrapper(); + + const actionComponent = findActionComponent(); + + expect(actionComponent.exists()).toBe(true); + expect(actionComponent.props('actionIcon')).toBe('retry'); + expect(actionComponent.attributes('disabled')).toBeUndefined(); + }); + + it('should render disabled action icon when user cannot run the action', () => { + createWrapper({ + props: { + job: mockJobWithUnauthorizedAction, + }, + }); + + const actionComponent = findActionComponent(); + + expect(actionComponent.exists()).toBe(true); + expect(actionComponent.props('actionIcon')).toBe('stop'); + expect(actionComponent.attributes('disabled')).toBeDefined(); + }); + + it('action icon tooltip text when job has passed but can be ran again', () => { + createWrapper({ props: { job: mockJob } }); + + expect(findActionComponent().props('tooltipText')).toBe('Run again'); + }); + + it('action icon tooltip text when job has failed and can be retried', () => { + createWrapper({ props: { job: mockFailedJob } }); + + expect(findActionComponent().props('tooltipText')).toBe('Retry'); + }); + }); + + describe('job style', () => { + beforeEach(() => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: 'css-class-job-name', + }, + }); + }); + + it('should render provided class name', () => { + expect(findJobLink().classes()).toContain('css-class-job-name'); + }); + + it('does not show a badge on the job item', () => { + expect(findBadge().exists()).toBe(false); + }); + + it('does not apply the trigger job class', () => { + expect(findJobWithLink().classes()).not.toContain('gl-rounded-lg'); + }); + }); + + describe('status label', () => { + it('should not render status label when it is not provided', () => { + createWrapper({ + props: { + job: { + id: 4258, + name: 'test', + status: { + icon: 'status_success', + }, + }, + }, + }); + + expect(findJobWithoutLink().attributes('title')).toBe('test'); + }); + + it('should not render status label when it is provided', () => { + createWrapper({ + props: { + job: { + id: 4259, + name: 'test', + status: { + icon: 'status_success', + label: 'success', + tooltip: 'success', + }, + }, + }, + }); + + expect(findJobWithoutLink().attributes('title')).toBe('test - success'); + }); + }); + + describe('for delayed job', () => { + it('displays remaining time in tooltip', () => { + createWrapper({ + props: { + job: delayedJob, + }, + }); + + expect(findJobWithLink().attributes('title')).toBe( + `delayed job - delayed manual action (00:00:00)`, + ); + }); + }); + + describe('trigger job', () => { + describe('card', () => { + beforeEach(() => { + createWrapper({ + props: { + job: triggerJob, + }, + }); + }); + + it('shows a badge on the job item', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe('Trigger job'); + }); + + it('applies a rounded corner style instead of the usual pill shape', () => { + expect(findJobWithoutLink().classes()).toContain('gl-rounded-lg'); + }); + }); + + describe('when retrying', () => { + const mockToastShow = jest.fn(); + + beforeEach(async () => { + createWrapper({ + mountFn: shallowMountExtended, + props: { + skipRetryModal: true, + job: triggerJobWithRetryAction, + }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, + }); + + await findActionVueComponent().vm.$emit('pipelineActionRequestComplete'); + await nextTick(); + }); + + it('shows a toast message that the downstream is being created', () => { + expect(mockToastShow).toHaveBeenCalledTimes(1); + }); + }); + + describe('highlighting', () => { + it.each` + job | jobName | expanded | link + ${mockJob} | ${mockJob.name} | ${true} | ${true} + ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false} + `( + `trigger job should stay highlighted when downstream is expanded`, + ({ job, jobName, expanded, link }) => { + createWrapper({ + props: { + job, + pipelineExpanded: { jobName, expanded }, + }, + }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).toContain(triggerActiveClass); + }, + ); + + it.each` + job | jobName | expanded | link + ${mockJob} | ${mockJob.name} | ${false} | ${true} + ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false} + `( + `trigger job should not be highlighted when downstream is not expanded`, + ({ job, jobName, expanded, link }) => { + createWrapper({ + props: { + job, + pipelineExpanded: { jobName, expanded }, + }, + }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).not.toContain(triggerActiveClass); + }, + ); + }); + }); + + describe('job classes', () => { + it('job class is shown', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: 'my-class', + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain('my-class'); + + expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); + }); + + it('job class is shown, along with hover', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: 'my-class', + sourceJobHovered: mockJob.name, + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain('my-class'); + expect(jobLinkEl.classes()).toContain(triggerActiveClass); + }); + + it('multiple job classes are shown', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: [myCustomClass1, myCustomClass2], + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain(myCustomClass1); + expect(jobLinkEl.classes()).toContain(myCustomClass2); + + expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); + }); + + it('multiple job classes are shown conditionally', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: { [myCustomClass1]: true, [myCustomClass2]: true }, + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain(myCustomClass1); + expect(jobLinkEl.classes()).toContain(myCustomClass2); + + expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); + }); + + it('multiple job classes are shown, along with a hover', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: [myCustomClass1, myCustomClass2], + sourceJobHovered: mockJob.name, + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain(myCustomClass1); + expect(jobLinkEl.classes()).toContain(myCustomClass2); + expect(jobLinkEl.classes()).toContain(triggerActiveClass); + }); + }); + + describe('confirmation modal', () => { + describe('when clicking on the action component', () => { + it.each` + skipRetryModal | exists | visibilityText + ${false} | ${true} | ${'shows'} + ${true} | ${false} | ${'hides'} + `( + '$visibilityText the modal when `skipRetryModal` is $skipRetryModal', + async ({ exists, skipRetryModal }) => { + createWrapper({ + props: { + skipRetryModal, + job: triggerJobWithRetryAction, + }, + }); + await findActionComponent().trigger('click'); + + expect(findModal().exists()).toBe(exists); + }, + ); + }); + + describe('when showing the modal', () => { + it.each` + buttonName | shouldTriggerActionClick | actionBtn + ${'primary'} | ${true} | ${clickOnModalPrimaryBtn} + ${'cancel'} | ${false} | ${clickOnModalCancelBtn} + ${'close'} | ${false} | ${clickOnModalCloseBtn} + `( + 'clicking on $buttonName will pass down shouldTriggerActionClick as $shouldTriggerActionClick to the action component', + async ({ shouldTriggerActionClick, actionBtn }) => { + createWrapper({ + props: { + skipRetryModal: false, + job: triggerJobWithRetryAction, + }, + }); + await findActionComponent().trigger('click'); + + await actionBtn(); + + expect(findActionComponent().props().shouldTriggerClick).toBe(shouldTriggerActionClick); + }, + ); + }); + + describe('when not checking the "do not show this again" checkbox', () => { + it.each` + actionName | actionBtn + ${'closing'} | ${clickOnModalCloseBtn} + ${'cancelling'} | ${clickOnModalCancelBtn} + ${'confirming'} | ${clickOnModalPrimaryBtn} + `( + 'does not emit any event and will not modify localstorage on $actionName', + async ({ actionBtn }) => { + createWrapper({ + props: { + skipRetryModal: false, + job: triggerJobWithRetryAction, + }, + }); + await findActionComponent().trigger('click'); + await actionBtn(); + + expect(wrapper.emitted().setSkipRetryModal).toBeUndefined(); + expect(localStorage.setItem).not.toHaveBeenCalled(); + }, + ); + }); + + describe('when checking the "do not show this again" checkbox', () => { + it.each` + actionName | actionBtn + ${'closing'} | ${clickOnModalCloseBtn} + ${'cancelling'} | ${clickOnModalCancelBtn} + ${'confirming'} | ${clickOnModalPrimaryBtn} + `( + 'emits "setSkipRetryModal" and set local storage key on $actionName the modal', + async ({ actionBtn }) => { + // We are passing the checkbox as a slot to the GlModal. + // The way GlModal is mounted, we can neither click on the box + // or emit an event directly. We therefore set the data property + // as it would be if the box was checked. + createWrapper({ + data: { + currentSkipModalValue: true, + }, + props: { + skipRetryModal: false, + job: triggerJobWithRetryAction, + }, + }); + await findActionComponent().trigger('click'); + await actionBtn(); + + expect(wrapper.emitted().setSkipRetryModal).toHaveLength(1); + expect(localStorage.setItem).toHaveBeenCalledWith('skip_retry_modal', 'true'); + }, + ); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js new file mode 100644 index 00000000000..ca201aee648 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils'; +import jobNameComponent from '~/ci/common/private/job_name_component.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +describe('job name component', () => { + let wrapper; + + const propsData = { + name: 'foo', + status: { + icon: 'status_success', + group: 'success', + }, + }; + + beforeEach(() => { + wrapper = mount(jobNameComponent, { + propsData, + }); + }); + + it('should render the provided name', () => { + expect(wrapper.text()).toBe(propsData.name); + }); + + it('should render an icon with the provided status', () => { + expect(wrapper.findComponent(CiIcon).exists()).toBe(true); + expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js new file mode 100644 index 00000000000..5541b0db54a --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js @@ -0,0 +1,464 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; +import { createWrapper } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/ci/pipeline_details/graph/constants'; +import LinkedPipelineComponent from '~/ci/pipeline_details/graph/components/linked_pipeline.vue'; +import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; +import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import mockPipeline from './linked_pipelines_mock_data'; + +describe('Linked pipeline', () => { + let wrapper; + let requestHandlers; + + const downstreamProps = { + pipeline: { + ...mockPipeline, + multiproject: false, + }, + columnTitle: 'Downstream', + type: DOWNSTREAM, + expanded: false, + isLoading: false, + }; + + const upstreamProps = { + ...downstreamProps, + columnTitle: 'Upstream', + type: UPSTREAM, + }; + + const findButton = () => wrapper.findComponent(GlButton); + const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline'); + const findCardTooltip = () => wrapper.findComponent(GlTooltip); + const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title'); + const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button'); + const findLinkedPipeline = () => wrapper.findComponent({ ref: 'linkedPipeline' }); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findPipelineLabel = () => wrapper.findByTestId('downstream-pipeline-label'); + const findPipelineLink = () => wrapper.findByTestId('pipelineLink'); + const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline'); + + const defaultHandlers = { + cancelPipeline: jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [] } } }), + retryPipeline: jest.fn().mockResolvedValue({ data: { pipelineRetry: { errors: [] } } }), + }; + + const createMockApolloProvider = (handlers) => { + Vue.use(VueApollo); + + requestHandlers = handlers; + return createMockApollo([ + [CancelPipelineMutation, requestHandlers.cancelPipeline], + [RetryPipelineMutation, requestHandlers.retryPipeline], + ]); + }; + + const createComponent = ({ propsData, handlers = defaultHandlers }) => { + const mockApollo = createMockApolloProvider(handlers); + + wrapper = mountExtended(LinkedPipelineComponent, { + propsData, + apolloProvider: mockApollo, + }); + }; + + describe('rendered output', () => { + const props = { + pipeline: mockPipeline, + columnTitle: 'Downstream', + type: DOWNSTREAM, + expanded: false, + isLoading: false, + }; + + beforeEach(() => { + createComponent({ propsData: props }); + }); + + it('should render the project name', () => { + expect(wrapper.text()).toContain(props.pipeline.project.name); + }); + + it('should render an svg within the status container', () => { + const pipelineStatusElement = wrapper.findComponent(CiIcon); + + expect(pipelineStatusElement.find('svg').exists()).toBe(true); + }); + + it('should render the pipeline status icon svg', () => { + expect(wrapper.find('.ci-status-icon-success svg').exists()).toBe(true); + }); + + it('should have a ci-status child component', () => { + expect(wrapper.findComponent(CiIcon).exists()).toBe(true); + }); + + it('should render the pipeline id', () => { + expect(wrapper.text()).toContain(`#${props.pipeline.id}`); + }); + + it('adds the card tooltip text to the DOM', () => { + expect(findCardTooltip().exists()).toBe(true); + + expect(findCardTooltip().text()).toContain(mockPipeline.project.name); + expect(findCardTooltip().text()).toContain(mockPipeline.status.label); + expect(findCardTooltip().text()).toContain(mockPipeline.sourceJob.name); + expect(findCardTooltip().text()).toContain(mockPipeline.id.toString()); + }); + + it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { + expect(findPipelineLabel().text()).toBe('Multi-project'); + }); + }); + + describe('upstream pipelines', () => { + beforeEach(() => { + createComponent({ propsData: upstreamProps }); + }); + + it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { + expect(findPipelineLabel().exists()).toBe(true); + }); + + it('upstream pipeline should contain the correct link', () => { + expect(findPipelineLink().attributes('href')).toBe(upstreamProps.pipeline.path); + }); + + it('applies the reverse-row css class to the card', () => { + expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row-reverse'); + expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row'); + }); + }); + + describe('downstream pipelines', () => { + describe('styling', () => { + beforeEach(() => { + createComponent({ propsData: downstreamProps }); + }); + + it('parent/child label container should exist', () => { + expect(findPipelineLabel().exists()).toBe(true); + }); + + it('should display child label when pipeline project id is the same as triggered pipeline project id', () => { + expect(findPipelineLabel().exists()).toBe(true); + }); + + it('should have the name of the trigger job on the card when it is a child pipeline', () => { + expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name); + }); + + it('downstream pipeline should contain the correct link', () => { + expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path); + }); + + it('applies the flex-row css class to the card', () => { + expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row'); + expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row-reverse'); + }); + }); + + describe('action button', () => { + describe('with permissions', () => { + describe('on an upstream', () => { + describe('when retryable', () => { + beforeEach(() => { + const retryablePipeline = { + ...upstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + createComponent({ propsData: retryablePipeline }); + }); + + it('does not show the retry or cancel button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); + }); + }); + }); + + describe('on a downstream', () => { + const retryablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + describe('when retryable', () => { + beforeEach(() => { + createComponent({ propsData: retryablePipeline }); + }); + + it('shows only the retry button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(true); + }); + + it.each` + findElement | name + ${findRetryButton} | ${'retry button'} + ${findExpandButton} | ${'expand button'} + `('hides the card tooltip when $name is hovered', async ({ findElement }) => { + expect(findCardTooltip().exists()).toBe(true); + + await findElement().trigger('mouseover'); + + expect(findCardTooltip().exists()).toBe(false); + }); + + describe('and the retry button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + await findRetryButton().trigger('click'); + }); + + it('calls the retry mutation', () => { + expect(requestHandlers.retryPipeline).toHaveBeenCalledTimes(1); + expect(requestHandlers.retryPipeline).toHaveBeenCalledWith({ + id: 'gid://gitlab/Ci::Pipeline/195', + }); + }); + + it('emits the refreshPipelineGraph event', async () => { + await waitForPromises(); + expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1); + }); + }); + + describe('on failure', () => { + beforeEach(async () => { + createComponent({ + propsData: retryablePipeline, + handlers: { + retryPipeline: jest.fn().mockRejectedValue({ errors: [] }), + cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }), + }, + }); + + await findRetryButton().trigger('click'); + }); + + it('emits an error event', async () => { + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]); + }); + }); + }); + }); + + describe('when cancelable', () => { + const cancelablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true }, + }; + + beforeEach(() => { + createComponent({ propsData: cancelablePipeline }); + }); + + it('shows only the cancel button', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findRetryButton().exists()).toBe(false); + }); + + it.each` + findElement | name + ${findCancelButton} | ${'cancel button'} + ${findExpandButton} | ${'expand button'} + `('hides the card tooltip when $name is hovered', async ({ findElement }) => { + expect(findCardTooltip().exists()).toBe(true); + + await findElement().trigger('mouseover'); + + expect(findCardTooltip().exists()).toBe(false); + }); + + describe('and the cancel button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + await findCancelButton().trigger('click'); + }); + + it('calls the cancel mutation', () => { + expect(requestHandlers.cancelPipeline).toHaveBeenCalledTimes(1); + expect(requestHandlers.cancelPipeline).toHaveBeenCalledWith({ + id: 'gid://gitlab/Ci::Pipeline/195', + }); + }); + it('emits the refreshPipelineGraph event', async () => { + await waitForPromises(); + expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1); + }); + }); + + describe('on failure', () => { + beforeEach(async () => { + createComponent({ + propsData: cancelablePipeline, + handlers: { + retryPipeline: jest.fn().mockRejectedValue({ errors: [] }), + cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }), + }, + }); + + await findCancelButton().trigger('click'); + }); + + it('emits an error event', async () => { + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]); + }); + }); + }); + }); + + describe('when both cancellable and retryable', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true, retryable: true }, + }; + + createComponent({ propsData: pipelineWithTwoActions }); + }); + + it('only shows the cancel button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(true); + }); + }); + }); + }); + + describe('without permissions', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { + ...mockPipeline, + cancelable: true, + retryable: true, + userPermissions: { updatePipeline: false }, + }, + }; + + createComponent({ propsData: pipelineWithTwoActions }); + }); + + it('does not show any action button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + }); + }); + }); + + describe('expand button', () => { + it.each` + pipelineType | chevronPosition | buttonBorderClasses | expanded + ${downstreamProps} | ${'chevron-lg-right'} | ${'gl-border-l-0!'} | ${false} + ${downstreamProps} | ${'chevron-lg-left'} | ${'gl-border-l-0!'} | ${true} + ${upstreamProps} | ${'chevron-lg-left'} | ${'gl-border-r-0!'} | ${false} + ${upstreamProps} | ${'chevron-lg-right'} | ${'gl-border-r-0!'} | ${true} + `( + '$pipelineType.columnTitle pipeline button icon should be $chevronPosition with $buttonBorderClasses if expanded state is $expanded', + ({ pipelineType, chevronPosition, buttonBorderClasses, expanded }) => { + createComponent({ propsData: { ...pipelineType, expanded } }); + expect(findExpandButton().props('icon')).toBe(chevronPosition); + expect(findExpandButton().classes()).toContain(buttonBorderClasses); + }, + ); + + describe('shadow border', () => { + beforeEach(() => { + createComponent({ propsData: downstreamProps }); + }); + + it.each` + activateEventName | deactivateEventName + ${'mouseover'} | ${'mouseout'} + ${'focus'} | ${'blur'} + `( + 'applies the class on $activateEventName and removes it on $deactivateEventName', + async ({ activateEventName, deactivateEventName }) => { + const shadowClass = 'gl-shadow-none!'; + + expect(findExpandButton().classes()).toContain(shadowClass); + + await findExpandButton().vm.$emit(activateEventName); + expect(findExpandButton().classes()).not.toContain(shadowClass); + + await findExpandButton().vm.$emit(deactivateEventName); + expect(findExpandButton().classes()).toContain(shadowClass); + }, + ); + }); + }); + + describe('when isLoading is true', () => { + const props = { + pipeline: mockPipeline, + columnTitle: 'Downstream', + type: DOWNSTREAM, + expanded: false, + isLoading: true, + }; + + beforeEach(() => { + createComponent({ propsData: props }); + }); + + it('loading icon is visible', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('on click/hover', () => { + const props = { + pipeline: mockPipeline, + columnTitle: 'Downstream', + type: DOWNSTREAM, + expanded: false, + isLoading: false, + }; + + beforeEach(() => { + createComponent({ propsData: props }); + }); + + it('emits `pipelineClicked` event', () => { + findButton().trigger('click'); + + expect(wrapper.emitted('pipelineClicked')).toHaveLength(1); + }); + + it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, async () => { + const root = createWrapper(wrapper.vm.$root); + await findButton().vm.$emit('click'); + + expect(root.emitted(BV_HIDE_TOOLTIP)).toHaveLength(1); + }); + + it('should emit downstreamHovered with job name on mouseover', () => { + findLinkedPipeline().trigger('mouseover'); + expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['test_c']]); + }); + + it('should emit downstreamHovered with empty string on mouseleave', () => { + findLinkedPipeline().trigger('mouseleave'); + expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['']]); + }); + + it('should emit pipelineExpanded with job name and expanded state on click', () => { + findExpandButton().trigger('click'); + expect(wrapper.emitted('pipelineExpandToggle')).toStrictEqual([['test_c', true]]); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_column_spec.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_column_spec.js new file mode 100644 index 00000000000..30f05baceab --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_column_spec.js @@ -0,0 +1,214 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import { + DOWNSTREAM, + UPSTREAM, + LAYER_VIEW, + STAGE_VIEW, +} from '~/ci/pipeline_details/graph/constants'; +import PipelineGraph from '~/ci/pipeline_details/graph/components/graph_component.vue'; +import LinkedPipeline from '~/ci/pipeline_details/graph/components/linked_pipeline.vue'; +import LinkedPipelinesColumn from '~/ci/pipeline_details/graph/components/linked_pipelines_column.vue'; +import * as parsingUtils from '~/ci/pipeline_details/utils/parsing_utils'; +import { LOAD_FAILURE } from '~/ci/pipeline_details/constants'; + +import { pipelineWithUpstreamDownstream, wrappedPipelineReturn } from '../mock_data'; + +const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse); + +describe('Linked Pipelines Column', () => { + const defaultProps = { + columnTitle: 'Downstream', + linkedPipelines: processedPipeline.downstream, + showLinks: false, + type: DOWNSTREAM, + viewType: STAGE_VIEW, + configPaths: { + metricsPath: '', + graphqlResourceEtag: 'this/is/a/path', + }, + }; + + let wrapper; + const findLinkedColumnTitle = () => wrapper.find('[data-testid="linked-column-title"]'); + const findLinkedPipelineElements = () => wrapper.findAllComponents(LinkedPipeline); + const findPipelineGraph = () => wrapper.findComponent(PipelineGraph); + const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); + + Vue.use(VueApollo); + + const createComponent = ({ apolloProvider, mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(LinkedPipelinesColumn, { + apolloProvider, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const createComponentWithApollo = ({ + mountFn = shallowMount, + getPipelineDetailsHandler = jest.fn().mockResolvedValue(wrappedPipelineReturn), + props = {}, + } = {}) => { + const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; + + const apolloProvider = createMockApollo(requestHandlers); + createComponent({ apolloProvider, mountFn, props }); + }; + + describe('it renders correctly', () => { + beforeEach(() => { + createComponentWithApollo(); + }); + + it('renders the pipeline title', () => { + expect(findLinkedColumnTitle().text()).toBe(defaultProps.columnTitle); + }); + + it('renders the correct number of linked pipelines', () => { + expect(findLinkedPipelineElements()).toHaveLength(defaultProps.linkedPipelines.length); + }); + }); + + describe('click action', () => { + const clickExpandButton = async () => { + await findExpandButton().trigger('click'); + await waitForPromises(); + }; + + describe('layer type rendering', () => { + let layersFn; + + beforeEach(() => { + layersFn = jest.spyOn(parsingUtils, 'listByLayers'); + createComponentWithApollo({ mountFn: mount }); + }); + + it('calls listByLayers only once no matter how many times view is switched', async () => { + expect(layersFn).not.toHaveBeenCalled(); + await clickExpandButton(); + await wrapper.setProps({ viewType: LAYER_VIEW }); + await nextTick(); + expect(layersFn).toHaveBeenCalledTimes(1); + await wrapper.setProps({ viewType: STAGE_VIEW }); + await wrapper.setProps({ viewType: LAYER_VIEW }); + await wrapper.setProps({ viewType: STAGE_VIEW }); + expect(layersFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('when graph does not use needs', () => { + beforeEach(() => { + const nonNeedsResponse = { ...wrappedPipelineReturn }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + createComponentWithApollo({ + props: { + viewType: LAYER_VIEW, + }, + getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), + mountFn: mount, + }); + }); + + it('shows the stage view, even when the main graph view type is layers', async () => { + await clickExpandButton(); + expect(findPipelineGraph().props('viewType')).toBe(STAGE_VIEW); + }); + }); + + describe('downstream', () => { + describe('when successful', () => { + beforeEach(() => { + createComponentWithApollo({ mountFn: mount }); + }); + + it('toggles the pipeline visibility', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(true); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + + describe('on error', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + }); + + it('emits the error', async () => { + await clickExpandButton(); + expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]); + }); + + it('does not show the pipeline', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + }); + + describe('upstream', () => { + const upstreamProps = { + columnTitle: 'Upstream', + /* + Because the IDs need to match to work, rather + than make new mock data, we are representing + the upstream pipeline with the downstream data. + */ + linkedPipelines: processedPipeline.downstream, + type: UPSTREAM, + }; + + describe('when successful', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + props: upstreamProps, + }); + }); + + it('toggles the pipeline visibility', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(true); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + + describe('on error', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + props: upstreamProps, + }); + }); + + it('emits the error', async () => { + await clickExpandButton(); + expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]); + }); + + it('does not show the pipeline', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_mock_data.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_mock_data.js new file mode 100644 index 00000000000..f7f5738e46d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_mock_data.js @@ -0,0 +1,27 @@ +export default { + __typename: 'Pipeline', + id: 195, + iid: '5', + retryable: false, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + path: '/root/elemenohpee/-/pipelines/195', + status: { + __typename: 'DetailedStatus', + group: 'success', + label: 'passed', + icon: 'status_success', + }, + sourceJob: { + __typename: 'CiJob', + name: 'test_c', + }, + project: { + __typename: 'Project', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + }, + multiproject: true, +}; diff --git a/spec/frontend/ci/pipeline_details/graph/components/links_inner_spec.js b/spec/frontend/ci/pipeline_details/graph/components/links_inner_spec.js new file mode 100644 index 00000000000..655b2ac74ac --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/links_inner_spec.js @@ -0,0 +1,223 @@ +import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; +import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; +import { createJobsHash } from '~/ci/pipeline_details/utils'; +import { + jobRect, + largePipelineData, + parallelNeedData, + pipelineData, + pipelineDataWithNoNeeds, + rootRect, + sameStageNeeds, +} from 'jest/ci/pipeline_editor/components/graph/mock_data'; + +describe('Links Inner component', () => { + const containerId = 'pipeline-graph-container'; + const defaultProps = { + containerId, + containerMeasurements: { width: 1019, height: 445 }, + pipelineId: 1, + pipelineData: [], + totalGroups: 10, + }; + + let wrapper; + + const createComponent = (props) => { + const currentPipelineData = props?.pipelineData || defaultProps.pipelineData; + wrapper = shallowMount(LinksInner, { + propsData: { + ...defaultProps, + ...props, + linksData: parseData(currentPipelineData.flatMap(({ groups }) => groups)).links, + }, + }); + }; + + const findLinkSvg = () => wrapper.find('#link-svg'); + const findAllLinksPath = () => findLinkSvg().findAll('path'); + + // We create fixture so that each job has an empty div that represent + // the JobPill in the DOM. Each `JobPill` would have different coordinates, + // so we increment their coordinates on each iteration to simulate different positions. + const setHTMLFixtureLocal = ({ stages }) => { + const jobs = createJobsHash(stages); + const arrayOfJobs = Object.keys(jobs); + + const linksHtmlElements = arrayOfJobs.map((job) => { + return `<div id=${job}-${defaultProps.pipelineId} />`; + }); + + setHTMLFixture(`<div id="${containerId}">${linksHtmlElements.join(' ')}</div>`); + + // We are mocking the clientRect data of each job and the container ID. + jest + .spyOn(document.getElementById(containerId), 'getBoundingClientRect') + .mockImplementation(() => rootRect); + + arrayOfJobs.forEach((job, index) => { + jest + .spyOn( + document.getElementById(`${job}-${defaultProps.pipelineId}`), + 'getBoundingClientRect', + ) + .mockImplementation(() => { + const newValue = 10 * index; + const { left, right, top, bottom, x, y } = jobRect; + return { + ...jobRect, + left: left + newValue, + right: right + newValue, + top: top + newValue, + bottom: bottom + newValue, + x: x + newValue, + y: y + newValue, + }; + }); + }); + }; + + afterEach(() => { + resetHTMLFixture(); + }); + + describe('basic SVG creation', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an SVG of the right size', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findLinkSvg().attributes('width')).toBe( + `${defaultProps.containerMeasurements.width}px`, + ); + expect(findLinkSvg().attributes('height')).toBe( + `${defaultProps.containerMeasurements.height}px`, + ); + }); + }); + + describe('no pipeline data', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the component', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findAllLinksPath()).toHaveLength(0); + }); + }); + + describe('pipeline data with no needs', () => { + beforeEach(() => { + createComponent({ pipelineData: pipelineDataWithNoNeeds.stages }); + }); + + it('renders no links', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findAllLinksPath()).toHaveLength(0); + }); + }); + + describe('with one need', () => { + beforeEach(() => { + setHTMLFixtureLocal(pipelineData); + createComponent({ pipelineData: pipelineData.stages }); + }); + + it('renders one link', () => { + expect(findAllLinksPath()).toHaveLength(1); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with a parallel need', () => { + beforeEach(() => { + setHTMLFixtureLocal(parallelNeedData); + createComponent({ pipelineData: parallelNeedData.stages }); + }); + + it('renders only one link for all the same parallel jobs', () => { + expect(findAllLinksPath()).toHaveLength(1); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with same stage needs', () => { + beforeEach(() => { + setHTMLFixtureLocal(sameStageNeeds); + createComponent({ pipelineData: sameStageNeeds.stages }); + }); + + it('renders the correct number of links', () => { + expect(findAllLinksPath()).toHaveLength(2); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with a large number of needs', () => { + beforeEach(() => { + setHTMLFixtureLocal(largePipelineData); + createComponent({ pipelineData: largePipelineData.stages }); + }); + + it('renders the correct number of links', () => { + expect(findAllLinksPath()).toHaveLength(5); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('interactions', () => { + beforeEach(() => { + setHTMLFixtureLocal(largePipelineData); + createComponent({ pipelineData: largePipelineData.stages }); + }); + + it('highlight needs on hover', async () => { + const firstLink = findAllLinksPath().at(0); + + const defaultColorClass = 'gl-stroke-gray-200'; + const hoverColorClass = 'gl-stroke-blue-400'; + + expect(firstLink.classes(defaultColorClass)).toBe(true); + expect(firstLink.classes(hoverColorClass)).toBe(false); + + // Because there is a watcher, we need to set the props after the component + // has mounted. + await wrapper.setProps({ highlightedJob: 'test_1' }); + + expect(firstLink.classes(defaultColorClass)).toBe(false); + expect(firstLink.classes(hoverColorClass)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js new file mode 100644 index 00000000000..cc79205ec41 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js @@ -0,0 +1,228 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; +import StageColumnComponent from '~/ci/pipeline_details/graph/components/stage_column_component.vue'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; + +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, jobs: [mockJob], id: idx, name: `fish-${idx}` }; + }); + +const defaultProps = { + name: 'Fish', + groups: mockGroups, + pipelineId: 159, + userPermissions: { + updatePipeline: true, + }, +}; + +describe('stage column component', () => { + let wrapper; + + 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.findComponent(JobItem); + const findActionComponent = () => wrapper.findComponent(ActionComponent); + + const createComponent = ({ method = shallowMount, props = {} } = {}) => { + wrapper = method(StageColumnComponent, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('should render provided title', () => { + expect(findStageColumnTitle().text()).toBe(defaultProps.name); + }); + + it('should render the provided groups', () => { + expect(findAllStageColumnGroups().length).toBe(mockGroups.length); + }); + + it('should emit updateMeasurements event on mount', () => { + expect(wrapper.emitted().updateMeasurements).toHaveLength(1); + }); + }); + + 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('job', () => { + describe('text handling', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { + groups: [ + { + ...mockJob, + name: '<img src=x onerror=alert(document.domain)>', + jobs: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + }, + ], + }, + ], + name: 'test <img src=x onerror=alert(document.domain)>', + }, + }); + }); + + it('escapes name', () => { + expect(findStageColumnTitle().html()).toContain( + '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('interactions', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('emits jobHovered event on mouseenter and mouseleave', async () => { + await findStageColumnGroup().trigger('mouseenter'); + expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name]]); + await findStageColumnGroup().trigger('mouseleave'); + expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name], ['']]); + }); + }); + }); + + describe('with action', () => { + const defaults = { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + jobs: [mockJob], + }, + ], + title: 'test', + hasTriggeredBy: false, + action: { + icon: 'play', + title: 'Play all', + path: 'action', + }, + }; + + it('renders action button if permissions are permitted', () => { + createComponent({ + method: mount, + props: { + ...defaults, + }, + }); + + expect(findActionComponent().exists()).toBe(true); + }); + + it('does not render action button if permissions are not permitted', () => { + createComponent({ + method: mount, + props: { + ...defaults, + userPermissions: { + updatePipeline: false, + }, + }, + }); + + expect(findActionComponent().exists()).toBe(false); + }); + }); + + describe('without action', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + jobs: [mockJob], + }, + ], + title: 'test', + hasTriggeredBy: false, + }, + }); + }); + + it('does not render action button', () => { + expect(findActionComponent().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/graph_component_wrapper_spec.js b/spec/frontend/ci/pipeline_details/graph/graph_component_wrapper_spec.js new file mode 100644 index 00000000000..372ed2a4e1c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/graph_component_wrapper_spec.js @@ -0,0 +1,603 @@ +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubPerformanceWebAPI } from 'helpers/performance'; +import waitForPromises from 'helpers/wait_for_promises'; +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; +import * as perfUtils from '~/performance/utils'; +import { + ACTION_FAILURE, + LAYER_VIEW, + STAGE_VIEW, + VIEW_TYPE_KEY, +} from '~/ci/pipeline_details/graph/constants'; +import PipelineGraph from '~/ci/pipeline_details/graph/components/graph_component.vue'; +import PipelineGraphWrapper from '~/ci/pipeline_details/graph/graph_component_wrapper.vue'; +import GraphViewSelector from '~/ci/pipeline_details/graph/components/graph_view_selector.vue'; +import * as Api from '~/ci/pipeline_details/graph/api_utils'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; +import * as parsingUtils from '~/ci/pipeline_details/utils/parsing_utils'; +import getPipelineHeaderData from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql'; +import * as sentryUtils from '~/ci/utils'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { mockRunningPipelineHeaderData } from '../mock_data'; +import { + mapCallouts, + mockCalloutsResponse, + mockPipelineResponseWithTooManyJobs, +} from './mock_data'; + +const defaultProvide = { + graphqlResourceEtag: 'frog/amphibirama/etag/', + metricsPath: '', + pipelineProjectPath: 'frog/amphibirama', + pipelineIid: '22', +}; + +describe('Pipeline graph wrapper', () => { + Vue.use(VueApollo); + useLocalStorageSpy(); + + let wrapper; + let requestHandlers; + let pipelineDetailsHandler; + + const findAlert = () => wrapper.findByTestId('error-alert'); + const findJobCountWarning = () => wrapper.findByTestId('job-count-warning'); + const findDependenciesToggle = () => wrapper.findByTestId('show-links-toggle'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLinksLayer = () => wrapper.findComponent(LinksLayer); + const findGraph = () => wrapper.findComponent(PipelineGraph); + const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title'); + const findViewSelector = () => wrapper.findComponent(GraphViewSelector); + const findViewSelectorToggle = () => findViewSelector().findComponent(GlToggle); + const findViewSelectorTrip = () => findViewSelector().findComponent(GlAlert); + const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + + const createComponent = ({ + apolloProvider, + data = {}, + provide = {}, + mountFn = shallowMountExtended, + } = {}) => { + wrapper = mountFn(PipelineGraphWrapper, { + provide: { + ...defaultProvide, + ...provide, + }, + apolloProvider, + data() { + return { + ...data, + }; + }, + }); + }; + + const createComponentWithApollo = ({ + calloutsList = [], + data = {}, + mountFn = shallowMountExtended, + provide = {}, + } = {}) => { + const callouts = mapCallouts(calloutsList); + + requestHandlers = { + getUserCalloutsHandler: jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)), + getPipelineHeaderDataHandler: jest.fn().mockResolvedValue(mockRunningPipelineHeaderData), + getPipelineDetailsHandler: pipelineDetailsHandler, + }; + + const handlers = [ + [getPipelineHeaderData, requestHandlers.getPipelineHeaderDataHandler], + [getPipelineDetails, requestHandlers.getPipelineDetailsHandler], + [getUserCallouts, requestHandlers.getUserCalloutsHandler], + ]; + + const apolloProvider = createMockApollo(handlers); + createComponent({ apolloProvider, data, provide, mountFn }); + }; + + beforeEach(() => { + pipelineDetailsHandler = jest.fn(); + pipelineDetailsHandler.mockResolvedValue(mockPipelineResponse); + }); + + describe('when data is loading', () => { + beforeEach(() => { + createComponentWithApollo(); + }); + + it('displays the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not display the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('does not display the graph', () => { + expect(findGraph().exists()).toBe(false); + }); + + it('skips querying headerPipeline', () => { + expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(true); + }); + }); + + describe('when data has loaded', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + }); + + it('does not display the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('does not display the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('displays the graph', () => { + expect(findGraph().exists()).toBe(true); + }); + + it('passes the etag resource and metrics path to the graph', () => { + expect(findGraph().props('configPaths')).toMatchObject({ + graphqlResourceEtag: defaultProvide.graphqlResourceEtag, + metricsPath: defaultProvide.metricsPath, + }); + }); + }); + + describe('when a stage has 100 jobs or more', () => { + beforeEach(async () => { + pipelineDetailsHandler.mockResolvedValue(mockPipelineResponseWithTooManyJobs); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('show a warning alert', () => { + expect(findJobCountWarning().exists()).toBe(true); + expect(findJobCountWarning().props().title).toBe( + 'Only the first 100 jobs per stage are displayed', + ); + }); + }); + + describe('when there is an error', () => { + beforeEach(async () => { + pipelineDetailsHandler.mockRejectedValue(new Error('GraphQL error')); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('does not display the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays the alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('does not display the graph', () => { + expect(findGraph().exists()).toBe(false); + }); + }); + + describe('when there is no pipeline iid available', () => { + beforeEach(async () => { + createComponentWithApollo({ + provide: { + pipelineIid: '', + }, + }); + await waitForPromises(); + }); + + it('does not display the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays the no iid alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe( + 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', + ); + }); + + it('does not display the graph', () => { + expect(findGraph().exists()).toBe(false); + }); + }); + + describe('events', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + }); + describe('when receiving `setSkipRetryModal` event', () => { + it('passes down `skipRetryModal` value as true', async () => { + expect(findGraph().props('skipRetryModal')).toBe(false); + + await findGraph().vm.$emit('setSkipRetryModal'); + + expect(findGraph().props('skipRetryModal')).toBe(true); + }); + }); + }); + + describe('when there is an error with an action in the graph', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + await findGraph().vm.$emit('error', { type: ACTION_FAILURE }); + }); + + it('does not display the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays the action error alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('An error occurred while performing this action.'); + }); + + it('displays the graph', () => { + expect(findGraph().exists()).toBe(true); + }); + }); + + describe('when refresh action is emitted', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + findGraph().vm.$emit('refreshPipelineGraph'); + }); + + it('calls refetch', () => { + expect(requestHandlers.getPipelineHeaderDataHandler).toHaveBeenCalledWith({ + fullPath: 'frog/amphibirama', + iid: '22', + }); + expect(requestHandlers.getPipelineDetailsHandler).toHaveBeenCalledTimes(2); + expect(requestHandlers.getUserCalloutsHandler).toHaveBeenCalledWith({}); + }); + }); + + describe('when query times out', () => { + const advanceApolloTimers = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + beforeEach(async () => { + const errorData = { + data: { + project: { + pipelines: null, + }, + }, + errors: [{ message: 'timeout' }], + }; + + pipelineDetailsHandler + .mockResolvedValueOnce(errorData) + .mockResolvedValueOnce(mockPipelineResponse) + .mockResolvedValueOnce(errorData); + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('shows correct errors and does not overwrite populated data when data is empty', async () => { + /* fails at first, shows error, no data yet */ + expect(findAlert().exists()).toBe(true); + expect(findGraph().exists()).toBe(false); + + /* succeeds, clears error, shows graph */ + await advanceApolloTimers(); + expect(findAlert().exists()).toBe(false); + expect(findGraph().exists()).toBe(true); + + /* fails again, alert returns but data persists */ + await advanceApolloTimers(); + expect(findAlert().exists()).toBe(true); + expect(findGraph().exists()).toBe(true); + }); + }); + + describe('view dropdown', () => { + describe('default', () => { + let layersFn; + beforeEach(async () => { + layersFn = jest.spyOn(parsingUtils, 'listByLayers'); + createComponentWithApollo({ + mountFn: mountExtended, + }); + + await waitForPromises(); + }); + + it('appears when pipeline uses needs', () => { + expect(findViewSelector().exists()).toBe(true); + }); + + it('switches between views', async () => { + expect(findStageColumnTitle().text()).toBe('deploy'); + + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + + expect(findStageColumnTitle().text()).toBe(''); + }); + + it('saves the view type to local storage', async () => { + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + expect(localStorage.setItem.mock.calls).toEqual([[VIEW_TYPE_KEY, LAYER_VIEW]]); + }); + + it('calls listByLayers only once no matter how many times view is switched', async () => { + expect(layersFn).not.toHaveBeenCalled(); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + expect(layersFn).toHaveBeenCalledTimes(1); + await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW); + expect(layersFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('when layers view is selected', () => { + beforeEach(async () => { + createComponentWithApollo({ + data: { + currentViewType: LAYER_VIEW, + }, + mountFn: mountExtended, + }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('sets showLinks to true', async () => { + /* This spec uses .props for performance reasons. */ + expect(findLinksLayer().exists()).toBe(true); + expect(findLinksLayer().props('showLinks')).toBe(false); + expect(findViewSelector().props('type')).toBe(LAYER_VIEW); + await findDependenciesToggle().vm.$emit('change', true); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + expect(wrapper.findComponent(LinksLayer).props('showLinks')).toBe(true); + }); + }); + + describe('when layers view is selected, and links are active', () => { + beforeEach(async () => { + createComponentWithApollo({ + data: { + currentViewType: LAYER_VIEW, + showLinks: true, + }, + mountFn: mountExtended, + }); + + await waitForPromises(); + }); + + it('shows the hover tip in the view selector', async () => { + await findViewSelectorToggle().vm.$emit('change', true); + expect(findViewSelectorTrip().exists()).toBe(true); + }); + }); + + describe('when hover tip would otherwise show, but it has been previously dismissed', () => { + beforeEach(async () => { + createComponentWithApollo({ + data: { + currentViewType: LAYER_VIEW, + showLinks: true, + }, + mountFn: mountExtended, + calloutsList: ['pipeline_needs_hover_tip'.toUpperCase()], + }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('does not show the hover tip', async () => { + await findViewSelectorToggle().vm.$emit('change', true); + expect(findViewSelectorTrip().exists()).toBe(false); + }); + }); + + describe('when feature flag is on and local storage is set', () => { + beforeEach(async () => { + localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); + + createComponentWithApollo({ + mountFn: mountExtended, + }); + + await waitForPromises(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('sets the asString prop on the LocalStorageSync component', () => { + expect(getLocalStorageSync().props('asString')).toBe(true); + }); + + it('reads the view type from localStorage when available', () => { + const viewSelectorNeedsSegment = wrapper + .findComponent(GlButtonGroup) + .findAllComponents(GlButton) + .at(1); + expect(viewSelectorNeedsSegment.classes()).toContain('selected'); + }); + }); + + describe('when feature flag is on and local storage is set, but the graph does not use needs', () => { + beforeEach(async () => { + const nonNeedsResponse = { ...mockPipelineResponse }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); + + pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); + createComponentWithApollo({ + mountFn: mountExtended, + }); + + await waitForPromises(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('still passes stage type to graph', () => { + expect(findGraph().props('viewType')).toBe(STAGE_VIEW); + }); + }); + + describe('when feature flag is on but pipeline does not use needs', () => { + beforeEach(async () => { + const nonNeedsResponse = { ...mockPipelineResponse }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); + createComponentWithApollo({ + mountFn: mountExtended, + }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('does not appear when pipeline does not use needs', () => { + expect(findViewSelector().exists()).toBe(false); + }); + }); + }); + + describe('performance metrics', () => { + const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json'; + let markAndMeasure; + let reportToSentry; + let reportPerformance; + let mock; + + beforeEach(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); + markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure'); + reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry'); + reportPerformance = jest.spyOn(Api, 'reportPerformance'); + }); + + describe('with no metrics path', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with metrics path', () => { + const duration = 500; + const numLinks = 3; + const totalGroups = 7; + const metricsData = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / totalGroups, + }, + ], + }; + + describe('when no duration is obtained', () => { + beforeEach(async () => { + stubPerformanceWebAPI(); + + createComponentWithApollo({ + provide: { + metricsPath, + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + data: { + currentViewType: LAYER_VIEW, + }, + }); + + await waitForPromises(); + }); + + it('attempts to collect metrics', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + + describe('with duration and no error', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onPost(metricsPath).reply(HTTP_STATUS_OK, {}); + + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return [{ duration }]; + }); + + createComponentWithApollo({ + provide: { + metricsPath, + }, + data: { + currentViewType: LAYER_VIEW, + }, + }); + await waitForPromises(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('calls reportPerformance with expected arguments', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/mock_data.js b/spec/frontend/ci/pipeline_details/graph/mock_data.js new file mode 100644 index 00000000000..a880a9cf4b0 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/mock_data.js @@ -0,0 +1,383 @@ +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import { unwrapPipelineData } from '~/ci/pipeline_details/graph/utils'; +import { BUILD_KIND, BRIDGE_KIND, RETRY_ACTION_TITLE } from '~/ci/pipeline_details/graph/constants'; + +// We mock this instead of using fixtures for performance reason. +const mockPipelineResponseCopy = JSON.parse(JSON.stringify(mockPipelineResponse)); +const groups = new Array(100).fill({ + ...mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes[0], +}); +mockPipelineResponseCopy.data.project.pipeline.stages.nodes[0].groups.nodes = groups; +export const mockPipelineResponseWithTooManyJobs = mockPipelineResponseCopy; + +export const downstream = { + nodes: [ + { + id: 175, + iid: '31', + path: '/root/elemenohpee/-/pipelines/175', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + status: { + id: '70', + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: { + name: 'test_c', + id: '71', + retried: false, + __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', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + status: { + id: '72', + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: '73', + name: 'test_d', + retried: true, + __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', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + status: { + id: '74', + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: null, + project: { + id: 'gid://gitlab/Project/23', + name: 'abcd-dag', + fullPath: 'root/abcd-dag', + __typename: 'Project', + }, + __typename: 'Pipeline', + multiproject: true, +}; + +export const wrappedPipelineReturn = { + data: { + project: { + __typename: 'Project', + id: '75', + pipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/175', + iid: '38', + complete: true, + usesNeeds: true, + userPermissions: { + __typename: 'PipelinePermissions', + updatePipeline: true, + }, + downstream: { + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + __typename: 'PipelineConnection', + nodes: [], + }, + upstream: { + id: 'gid://gitlab/Ci::Pipeline/174', + iid: '37', + path: '/root/elemenohpee/-/pipelines/174', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + __typename: 'Pipeline', + status: { + __typename: 'DetailedStatus', + id: '77', + group: 'success', + label: 'passed', + icon: 'status_success', + }, + sourceJob: { + name: 'test_c', + id: '78', + retried: false, + __typename: 'CiJob', + }, + project: { + id: 'gid://gitlab/Project/25', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + __typename: 'Project', + }, + }, + stages: { + __typename: 'CiStageConnection', + nodes: [ + { + name: 'build', + __typename: 'CiStage', + id: '79', + status: { + action: null, + id: '80', + __typename: 'DetailedStatus', + }, + groups: { + __typename: 'CiGroupConnection', + nodes: [ + { + __typename: 'CiGroup', + id: '81', + status: { + __typename: 'DetailedStatus', + id: '82', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + name: 'build_n', + size: 1, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + id: '83', + kind: BUILD_KIND, + name: 'build_n', + scheduledAt: null, + needs: { + __typename: 'CiBuildNeedConnection', + nodes: [], + }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, + status: { + __typename: 'DetailedStatus', + id: '84', + icon: 'status_success', + tooltip: 'passed', + label: 'passed', + hasDetails: true, + detailsPath: '/root/elemenohpee/-/jobs/1662', + group: 'success', + action: { + __typename: 'StatusAction', + id: '85', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/elemenohpee/-/jobs/1662/retry', + title: 'Retry', + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, +}; + +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'); +}; + +export const mapCallouts = (callouts) => + callouts.map((callout) => { + return { featureName: callout, __typename: 'UserCallout' }; + }); + +export const mockCalloutsResponse = (mappedCallouts) => ({ + data: { + currentUser: { + id: 45, + __typename: 'User', + callouts: { + id: 5, + __typename: 'UserCalloutConnection', + nodes: mappedCallouts, + }, + }, + }, +}); + +export const delayedJob = { + __typename: 'CiJob', + kind: BUILD_KIND, + name: 'delayed job', + scheduledAt: '2015-07-03T10:01:00.000Z', + needs: [], + status: { + __typename: 'DetailedStatus', + icon: 'status_scheduled', + tooltip: 'delayed manual action (%{remainingTime})', + hasDetails: true, + detailsPath: '/root/kinder-pipe/-/jobs/5339', + group: 'scheduled', + action: { + __typename: 'StatusAction', + icon: 'time-out', + title: 'Unschedule', + path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule', + buttonTitle: 'Unschedule job', + }, + }, +}; + +export const mockJob = { + id: 4256, + name: 'test', + kind: BUILD_KIND, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + detailsPath: '/root/ci-mock/builds/4256', + hasDetails: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, +}; + +export const mockJobWithoutDetails = { + id: 4257, + name: 'job_without_details', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + detailsPath: '/root/ci-mock/builds/4257', + hasDetails: false, + }, +}; + +export const mockJobWithUnauthorizedAction = { + id: 4258, + name: 'stop-environment', + status: { + icon: 'status_manual', + label: 'manual stop action (not allowed)', + tooltip: 'manual action', + group: 'manual', + detailsPath: '/root/ci-mock/builds/4258', + hasDetails: true, + action: null, + }, +}; + +export const triggerJob = { + id: 4259, + name: 'trigger', + kind: BRIDGE_KIND, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + action: null, + }, +}; + +export const triggerJobWithRetryAction = { + ...triggerJob, + status: { + ...triggerJob.status, + action: { + icon: 'retry', + title: RETRY_ACTION_TITLE, + path: '/root/ci-mock/builds/4259/retry', + method: 'post', + }, + }, +}; + +export const mockFailedJob = { + id: 3999, + name: 'failed job', + kind: BUILD_KIND, + status: { + id: 'failed-3999-3999', + icon: 'status_failed', + tooltip: 'failed - (stuck or timeout failure)', + hasDetails: true, + detailsPath: '/root/ci-project/-/jobs/3999', + group: 'failed', + label: 'failed', + action: { + id: 'Ci::BuildPresenter-failed-3999', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/ci-project/-/jobs/3999/retry', + title: 'Retry', + }, + }, +}; diff --git a/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js new file mode 100644 index 00000000000..6e13658a773 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js @@ -0,0 +1,452 @@ +import { GlAlert, GlBadge, GlLoadingIcon, GlModal, GlSprintf } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import PipelineDetailsHeader from '~/ci/pipeline_details/header/pipeline_details_header.vue'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import cancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; +import deletePipelineMutation from '~/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql'; +import retryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; +import getPipelineDetailsQuery from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql'; +import { + pipelineHeaderSuccess, + pipelineHeaderRunning, + pipelineHeaderRunningWithDuration, + pipelineHeaderFailed, + pipelineRetryMutationResponseSuccess, + pipelineCancelMutationResponseSuccess, + pipelineDeleteMutationResponseSuccess, + pipelineRetryMutationResponseFailed, + pipelineCancelMutationResponseFailed, + pipelineDeleteMutationResponseFailed, +} from '../mock_data'; + +Vue.use(VueApollo); + +describe('Pipeline details header', () => { + let wrapper; + let glModalDirective; + + const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess); + const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning); + const runningHandlerWithDuration = jest.fn().mockResolvedValue(pipelineHeaderRunningWithDuration); + const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed); + + const retryMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineRetryMutationResponseSuccess); + const cancelMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineCancelMutationResponseSuccess); + const deleteMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineDeleteMutationResponseSuccess); + const retryMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineRetryMutationResponseFailed); + const cancelMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineCancelMutationResponseFailed); + const deleteMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineDeleteMutationResponseFailed); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findStatus = () => wrapper.findComponent(CiBadgeLink); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAllBadges = () => wrapper.findAllComponents(GlBadge); + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findCreatedTimeAgo = () => wrapper.findByTestId('pipeline-created-time-ago'); + const findFinishedTimeAgo = () => wrapper.findByTestId('pipeline-finished-time-ago'); + const findPipelineName = () => wrapper.findByTestId('pipeline-name'); + const findCommitTitle = () => wrapper.findByTestId('pipeline-commit-title'); + const findTotalJobs = () => wrapper.findByTestId('total-jobs'); + const findComputeMinutes = () => wrapper.findByTestId('compute-minutes'); + const findCommitLink = () => wrapper.findByTestId('commit-link'); + const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text(); + const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text(); + const findRetryButton = () => wrapper.findByTestId('retry-pipeline'); + const findCancelButton = () => wrapper.findByTestId('cancel-pipeline'); + const findDeleteButton = () => wrapper.findByTestId('delete-pipeline'); + const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link'); + const findPipelineDuration = () => wrapper.findByTestId('pipeline-duration-text'); + + const defaultHandlers = [[getPipelineDetailsQuery, successHandler]]; + + const defaultProvideOptions = { + pipelineIid: 1, + paths: { + pipelinesPath: '/namespace/my-project/-/pipelines', + fullProject: '/namespace/my-project', + triggeredByPath: '', + }, + }; + + const defaultProps = { + name: 'Ruby 3.0 master branch pipeline', + totalJobs: '50', + computeMinutes: '0.65', + yamlErrors: 'errors', + failureReason: 'pipeline failed', + badges: { + schedule: true, + child: false, + latest: true, + mergeTrainPipeline: false, + invalid: false, + failed: false, + autoDevops: false, + detached: false, + stuck: false, + }, + refText: + 'Related merge request <a class="mr-iid" href="/root/ci-project/-/merge_requests/1">!1</a> to merge <a class="ref-name" href="/root/ci-project/-/commits/test">test</a>', + }; + + const createMockApolloProvider = (handlers) => { + return createMockApollo(handlers); + }; + + const createComponent = (handlers = defaultHandlers, props = defaultProps) => { + glModalDirective = jest.fn(); + + wrapper = shallowMountExtended(PipelineDetailsHeader, { + provide: { + ...defaultProvideOptions, + }, + propsData: { + ...props, + }, + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + stubs: { GlSprintf }, + apolloProvider: createMockApolloProvider(handlers), + }); + }; + + describe('loading state', () => { + it('shows a loading state while graphQL is fetching initial data', () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('defaults', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('does not display loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays pipeline status', () => { + expect(findStatus().exists()).toBe(true); + }); + + it('displays pipeline name', () => { + expect(findPipelineName().text()).toBe(defaultProps.name); + }); + + it('displays total jobs', () => { + expect(findTotalJobs().text()).toBe('50 Jobs'); + }); + + it('has link to commit', () => { + const { + data: { + project: { pipeline }, + }, + } = pipelineHeaderSuccess; + + expect(findCommitLink().attributes('href')).toBe(pipeline.commit.webPath); + }); + + it('displays correct badges', () => { + expect(findAllBadges()).toHaveLength(2); + expect(wrapper.findByText('latest').exists()).toBe(true); + expect(wrapper.findByText('Scheduled').exists()).toBe(true); + }); + + it('displays ref text', () => { + expect(findPipelineRefText()).toBe('Related merge request !1 to merge test'); + }); + + it('displays pipeline user link with required user popover attributes', () => { + const { + data: { + project: { + pipeline: { user }, + }, + }, + } = pipelineHeaderSuccess; + + const userId = getIdFromGraphQLId(user.id).toString(); + + expect(findPipelineUserLink().classes()).toContain('js-user-link'); + expect(findPipelineUserLink().attributes('data-user-id')).toBe(userId); + expect(findPipelineUserLink().attributes('data-username')).toBe(user.username); + expect(findPipelineUserLink().attributes('href')).toBe(user.webUrl); + }); + }); + + describe('without pipeline name', () => { + it('displays commit title', async () => { + createComponent(defaultHandlers, { ...defaultProps, name: '' }); + + await waitForPromises(); + + const expectedTitle = pipelineHeaderSuccess.data.project.pipeline.commit.title; + + expect(findPipelineName().exists()).toBe(false); + expect(findCommitTitle().text()).toBe(expectedTitle); + }); + }); + + describe('finished pipeline', () => { + it('displays compute minutes when not zero', async () => { + createComponent(); + + await waitForPromises(); + + expect(findComputeMinutes().text()).toBe('0.65'); + }); + + it('does not display compute minutes when zero', async () => { + createComponent(defaultHandlers, { ...defaultProps, computeMinutes: '0.0' }); + + await waitForPromises(); + + expect(findComputeMinutes().exists()).toBe(false); + }); + + it('does not display created time ago', async () => { + createComponent(); + + await waitForPromises(); + + expect(findCreatedTimeAgo().exists()).toBe(false); + }); + + it('displays finished time ago', async () => { + createComponent(); + + await waitForPromises(); + + expect(findFinishedTimeAgo().exists()).toBe(true); + }); + + it('displays pipeline duartion text', async () => { + createComponent(); + + await waitForPromises(); + + expect(findPipelineDuration().text()).toBe( + '120 minutes 10 seconds, queued for 3,600 seconds', + ); + }); + }); + + describe('running pipeline', () => { + beforeEach(async () => { + createComponent([[getPipelineDetailsQuery, runningHandler]]); + + await waitForPromises(); + }); + + it('does not display compute minutes', () => { + expect(findComputeMinutes().exists()).toBe(false); + }); + + it('does not display finished time ago', () => { + expect(findFinishedTimeAgo().exists()).toBe(false); + }); + + it('does not display pipeline duration text', () => { + expect(findPipelineDuration().exists()).toBe(false); + }); + + it('displays pipeline running text', () => { + expect(findPipelineRunningText()).toBe('In progress, queued for 3,600 seconds'); + }); + + it('displays created time ago', () => { + expect(findCreatedTimeAgo().exists()).toBe(true); + }); + }); + + describe('running pipeline with duration', () => { + beforeEach(async () => { + createComponent([[getPipelineDetailsQuery, runningHandlerWithDuration]]); + + await waitForPromises(); + }); + + it('does not display pipeline duration text', () => { + expect(findPipelineDuration().exists()).toBe(false); + }); + }); + + describe('actions', () => { + describe('retry action', () => { + beforeEach(async () => { + createComponent([ + [getPipelineDetailsQuery, failedHandler], + [retryPipelineMutation, retryMutationHandlerSuccess], + ]); + + await waitForPromises(); + }); + + it('should call retryPipeline Mutation with pipeline id', () => { + findRetryButton().vm.$emit('click'); + + expect(retryMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderFailed.data.project.pipeline.id, + }); + expect(findAlert().exists()).toBe(false); + }); + + it('should render retry action tooltip', () => { + expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); + }); + }); + + describe('retry action failed', () => { + beforeEach(async () => { + createComponent([ + [getPipelineDetailsQuery, failedHandler], + [retryPipelineMutation, retryMutationHandlerFailed], + ]); + + await waitForPromises(); + }); + + it('should display error message on failure', async () => { + findRetryButton().vm.$emit('click'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + + it('retry button loading state should reset on error', async () => { + findRetryButton().vm.$emit('click'); + + await nextTick(); + + expect(findRetryButton().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findRetryButton().props('loading')).toBe(false); + }); + }); + + describe('cancel action', () => { + it('should call cancelPipeline Mutation with pipeline id', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findCancelButton().vm.$emit('click'); + + expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderRunning.data.project.pipeline.id, + }); + expect(findAlert().exists()).toBe(false); + }); + + it('should render cancel action tooltip', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerSuccess], + ]); + + await waitForPromises(); + + expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); + }); + + it('should display error message on failure', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerFailed], + ]); + + await waitForPromises(); + + findCancelButton().vm.$emit('click'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('delete action', () => { + it('displays delete modal when clicking on delete and does not call the delete action', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findDeleteButton().vm.$emit('click'); + + const modalId = 'pipeline-delete-modal'; + + expect(findDeleteModal().props('modalId')).toBe(modalId); + expect(glModalDirective).toHaveBeenCalledWith(modalId); + expect(deleteMutationHandlerSuccess).not.toHaveBeenCalled(); + expect(findAlert().exists()).toBe(false); + }); + + it('should call deletePipeline Mutation with pipeline id when modal is submitted', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findDeleteModal().vm.$emit('primary'); + + expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderSuccess.data.project.pipeline.id, + }); + }); + + it('should display error message on failure', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerFailed], + ]); + + await waitForPromises(); + + findDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js b/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js new file mode 100644 index 00000000000..7110a35ad4e --- /dev/null +++ b/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js @@ -0,0 +1,141 @@ +import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { createAlert } from '~/alert'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import FailedJobsTable from '~/ci/pipeline_details/jobs/components/failed_jobs_table.vue'; +import RetryFailedJobMutation from '~/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { + successRetryMutationResponse, + failedRetryMutationResponse, + mockFailedJobsData, + mockFailedJobsDataNoPermission, +} from '../../mock_data'; + +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility'); + +Vue.use(VueApollo); + +describe('Failed Jobs Table', () => { + let wrapper; + + const successRetryMutationHandler = jest.fn().mockResolvedValue(successRetryMutationResponse); + const failedRetryMutationHandler = jest.fn().mockResolvedValue(failedRetryMutationResponse); + + const findJobsTable = () => wrapper.findComponent(GlTableLite); + const findRetryButton = () => wrapper.findComponent(GlButton); + const findJobLink = () => wrapper.findComponent(GlLink); + const findJobLog = () => wrapper.findByTestId('job-log'); + const findSummary = (index) => wrapper.findAllByTestId('job-trace-summary').at(index); + const findFirstFailureMessage = () => wrapper.findAllByTestId('job-failure-message').at(0); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[RetryFailedJobMutation, resolver]]; + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver, failedJobsData = mockFailedJobsData) => { + wrapper = mountExtended(FailedJobsTable, { + propsData: { + failedJobs: failedJobsData, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + it('displays the failed jobs table', () => { + createComponent(); + + expect(findJobsTable().exists()).toBe(true); + }); + + it('displays failed job summary', () => { + createComponent(); + + expect(findSummary(0).text()).toBe('Html Summary'); + }); + + it('displays no job log when no trace', () => { + createComponent(); + + expect(findSummary(1).text()).toBe('No job log'); + }); + + it('displays failure reason', () => { + createComponent(); + + expect(findFirstFailureMessage().text()).toBe('Job failed'); + }); + + it('calls the retry failed job mutation and tracks the click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(successRetryMutationHandler); + + findRetryButton().trigger('click'); + + expect(successRetryMutationHandler).toHaveBeenCalledWith({ + id: mockFailedJobsData[0].id, + }); + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry', { + label: TRACKING_CATEGORIES.failed, + }); + + unmockTracking(); + }); + + it('redirects to the new job after the mutation', async () => { + const { + data: { + jobRetry: { job }, + }, + } = successRetryMutationResponse; + + createComponent(successRetryMutationHandler); + + findRetryButton().trigger('click'); + + await waitForPromises(); + + expect(redirectTo).toHaveBeenCalledWith(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated + }); + + it('shows error message if the retry failed job mutation fails', async () => { + createComponent(failedRetryMutationHandler); + + findRetryButton().trigger('click'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem retrying the failed job.', + }); + }); + + it('hides the job log and retry button if a user does not have permission', () => { + createComponent([[]], mockFailedJobsDataNoPermission); + + expect(findJobLog().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); + }); + + it('displays the job log and retry button if a user has permission', () => { + createComponent(); + + expect(findJobLog().exists()).toBe(true); + expect(findRetryButton().exists()).toBe(true); + }); + + it('job name links to the correct job', () => { + createComponent(); + + expect(findJobLink().attributes('href')).toBe(mockFailedJobsData[0].detailedStatus.detailsPath); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/jobs/failed_jobs_app_spec.js b/spec/frontend/ci/pipeline_details/jobs/failed_jobs_app_spec.js new file mode 100644 index 00000000000..17b43aa422b --- /dev/null +++ b/spec/frontend/ci/pipeline_details/jobs/failed_jobs_app_spec.js @@ -0,0 +1,80 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import FailedJobsApp from '~/ci/pipeline_details/jobs/failed_jobs_app.vue'; +import FailedJobsTable from '~/ci/pipeline_details/jobs/components/failed_jobs_table.vue'; +import GetFailedJobsQuery from '~/ci/pipeline_details/jobs/graphql/queries/get_failed_jobs.query.graphql'; +import { mockFailedJobsQueryResponse } from 'jest/ci/pipeline_details/mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); + +describe('Failed Jobs App', () => { + let wrapper; + let resolverSpy; + + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findJobsTable = () => wrapper.findComponent(FailedJobsTable); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[GetFailedJobsQuery, resolver]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver) => { + wrapper = shallowMount(FailedJobsApp, { + provide: { + fullPath: 'root/ci-project', + pipelineIid: 1, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + beforeEach(() => { + resolverSpy = jest.fn().mockResolvedValue(mockFailedJobsQueryResponse); + }); + + describe('loading spinner', () => { + it('displays loading spinner when fetching failed jobs', () => { + createComponent(resolverSpy); + + expect(findLoadingSpinner().exists()).toBe(true); + }); + + it('hides loading spinner after the failed jobs have been fetched', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findLoadingSpinner().exists()).toBe(false); + }); + }); + + it('displays the failed jobs table', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findJobsTable().exists()).toBe(true); + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('handles query fetch error correctly', async () => { + resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + createComponent(resolverSpy); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem fetching the failed jobs.', + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/jobs/jobs_app_spec.js b/spec/frontend/ci/pipeline_details/jobs/jobs_app_spec.js new file mode 100644 index 00000000000..4a3a901502e --- /dev/null +++ b/spec/frontend/ci/pipeline_details/jobs/jobs_app_spec.js @@ -0,0 +1,127 @@ +import { GlIntersectionObserver, GlSkeletonLoader, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import JobsApp from '~/ci/pipeline_details/jobs/jobs_app.vue'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import getPipelineJobsQuery from '~/ci/pipeline_details/jobs/graphql/queries/get_pipeline_jobs.query.graphql'; +import { mockPipelineJobsQueryResponse } from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); + +describe('Jobs app', () => { + let wrapper; + let resolverSpy; + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findJobsTable = () => wrapper.findComponent(JobsTable); + + const triggerInfiniteScroll = () => + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[getPipelineJobsQuery, resolver]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver) => { + wrapper = shallowMount(JobsApp, { + provide: { + projectPath: 'root/ci-project', + pipelineIid: 1, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + beforeEach(() => { + resolverSpy = jest.fn().mockResolvedValue(mockPipelineJobsQueryResponse); + }); + + describe('loading spinner', () => { + const setup = async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + triggerInfiniteScroll(); + }; + + it('displays loading spinner when fetching more jobs', async () => { + await setup(); + + expect(findLoadingSpinner().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('hides loading spinner after jobs have been fetched', async () => { + await setup(); + await waitForPromises(); + + expect(findLoadingSpinner().exists()).toBe(false); + expect(findSkeletonLoader().exists()).toBe(false); + }); + }); + + it('displays the skeleton loader', () => { + createComponent(resolverSpy); + + expect(findSkeletonLoader().exists()).toBe(true); + expect(findJobsTable().exists()).toBe(false); + }); + + it('displays the jobs table', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findJobsTable().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('handles job fetch error correctly', async () => { + resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + createComponent(resolverSpy); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while fetching the pipelines jobs.', + }); + }); + + it('handles infinite scrolling by calling fetchMore', async () => { + createComponent(resolverSpy); + await waitForPromises(); + + triggerInfiniteScroll(); + await waitForPromises(); + + expect(resolverSpy).toHaveBeenCalledWith({ + after: 'eyJpZCI6Ijg0NyJ9', + fullPath: 'root/ci-project', + iid: 1, + }); + }); + + it('does not display skeleton loader again after fetchMore', async () => { + createComponent(resolverSpy); + + expect(findSkeletonLoader().exists()).toBe(true); + await waitForPromises(); + + triggerInfiniteScroll(); + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/linked_pipelines_mock.json b/spec/frontend/ci/pipeline_details/linked_pipelines_mock.json new file mode 100644 index 00000000000..a68283032d2 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/linked_pipelines_mock.json @@ -0,0 +1,3569 @@ +{ + "id": 23211253, + "user": { + "id": 3585, + "name": "Achilleas Pipinellis", + "username": "axil", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png", + "web_url": "https://gitlab.com/axil", + "status_tooltip_html": "<span class=\"user-status-emoji has-tooltip\" title=\"I like pizza\" data-html=\"true\" data-placement=\"top\"><gl-emoji title=\"slice of pizza\" data-name=\"pizza\" data-unicode-version=\"6.0\">🍕</gl-emoji></span>", + "path": "/axil" + }, + "active": false, + "coverage": null, + "source": "push", + "created_at": "2018-06-05T11:31:30.452Z", + "updated_at": "2018-10-31T16:35:31.305Z", + "path": "/gitlab-org/gitlab-runner/pipelines/23211253", + "flags": { + "latest": false, + "stuck": false, + "auto_devops": false, + "merge_request": false, + "yaml_errors": false, + "retryable": false, + "cancelable": false, + "failure_reason": false + }, + "details": { + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "duration": 53, + "finished_at": "2018-10-31T16:35:31.299Z", + "stages": [ + { + "name": "prebuild", + "title": "prebuild: passed", + "groups": [ + { + "name": "review-docs-deploy", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 72469032, + "name": "review-docs-deploy", + "started": "2018-10-31T16:34:58.778Z", + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry", + "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "playable": true, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.495Z", + "updated_at": "2018-10-31T16:35:31.251Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild" + }, + { + "name": "test", + "title": "test: passed", + "groups": [ + { + "name": "docs check links", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 72469033, + "name": "docs check links", + "started": "2018-06-05T11:31:33.240Z", + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.627Z", + "updated_at": "2018-06-05T11:31:54.363Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test" + }, + { + "name": "cleanup", + "title": "cleanup: skipped", + "groups": [ + { + "name": "review-docs-cleanup", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual stop action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "stop", + "title": "Stop", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "method": "post", + "button_title": "Stop this environment" + } + }, + "jobs": [ + { + "id": 72469034, + "name": "review-docs-cleanup", + "started": null, + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "playable": true, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.760Z", + "updated_at": "2018-06-05T11:31:56.037Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual stop action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "stop", + "title": "Stop", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "method": "post", + "button_title": "Stop this environment" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "review-docs-cleanup", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "playable": true, + "scheduled": false + }, + { + "name": "review-docs-deploy", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "playable": true, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "ref": { + "name": "docs/add-development-guide-to-readme", + "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme", + "tag": false, + "branch": true, + "merge_request": false + }, + "commit": { + "id": "8083eb0a920572214d0dccedd7981f05d535ad46", + "short_id": "8083eb0a", + "title": "Add link to development guide in readme", + "created_at": "2018-06-05T11:30:48.000Z", + "parent_ids": [ + "1d7cf79b5a1a2121b9474ac20d61c1b8f621289d" + ], + "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n", + "author_name": "Achilleas Pipinellis", + "author_email": "axil@gitlab.com", + "authored_date": "2018-06-05T11:30:48.000Z", + "committer_name": "Achilleas Pipinellis", + "committer_email": "axil@gitlab.com", + "committed_date": "2018-06-05T11:30:48.000Z", + "author": { + "id": 3585, + "name": "Achilleas Pipinellis", + "username": "axil", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png", + "web_url": "https://gitlab.com/axil", + "status_tooltip_html": null, + "path": "/axil" + }, + "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80&d=identicon", + "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46", + "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46" + }, + "project": { + "id": 1794617 + }, + "triggered_by": { + "id": 12, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 11421321982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 1149822131854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 11498285523424, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1149846949786, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 11498282342357, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "project": { + "id": 1794617, + "name": "Test", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered_by": { + "id": 349932310342451, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 11421321982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 1149822131854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 11498285523424, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1149846949786, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 11498282342357, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + } + }, + "triggered": [ + + ] + }, + "triggered": [ + { + "id": 34993051, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982855, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 114984694, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982857, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered": [ + { + } + ] + }, + { + "id": 34993052, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 1224982855, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1123984694, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 1143232982857, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114921313182858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered": [ + { + "id": 26, + "user": null, + "active": false, + "coverage": null, + "source": "push", + "created_at": "2019-01-06T17:48:37.599Z", + "updated_at": "2019-01-06T17:48:38.371Z", + "path": "/h5bp/html5-boilerplate/pipelines/26", + "flags": { + "latest": true, + "stuck": false, + "auto_devops": false, + "merge_request": false, + "yaml_errors": false, + "retryable": true, + "cancelable": false, + "failure_reason": false + }, + "details": { + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "duration": null, + "finished_at": "2019-01-06T17:48:38.370Z", + "stages": [ + { + "name": "build", + "title": "build: passed", + "groups": [ + { + "name": "build:linux", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/526", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 526, + "name": "build:linux", + "started": "2019-01-06T08:48:20.236Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/526", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.806Z", + "updated_at": "2019-01-06T17:48:37.806Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/526", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "build:osx", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/527", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 527, + "name": "build:osx", + "started": "2019-01-06T07:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/527", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.846Z", + "updated_at": "2019-01-06T17:48:37.846Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/527", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#build", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#build", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build" + }, + { + "name": "test", + "title": "test: passed with warnings", + "groups": [ + { + "name": "jenkins", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": null, + "group": "success", + "tooltip": null, + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 546, + "name": "jenkins", + "started": "2019-01-06T11:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/546", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.359Z", + "updated_at": "2019-01-06T17:48:38.359Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": null, + "group": "success", + "tooltip": null, + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + } + } + ] + }, + { + "name": "rspec:linux", + "size": 3, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 528, + "name": "rspec:linux 0 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/528", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.885Z", + "updated_at": "2019-01-06T17:48:37.885Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/528", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 529, + "name": "rspec:linux 1 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/529", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/529/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.907Z", + "updated_at": "2019-01-06T17:48:37.907Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/529", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/529/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 530, + "name": "rspec:linux 2 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/530", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/530/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.927Z", + "updated_at": "2019-01-06T17:48:37.927Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/530", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/530/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "rspec:osx", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/535", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 535, + "name": "rspec:osx", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/535", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.018Z", + "updated_at": "2019-01-06T17:48:38.018Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/535", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "rspec:windows", + "size": 3, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 531, + "name": "rspec:windows 0 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/531", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/531/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.944Z", + "updated_at": "2019-01-06T17:48:37.944Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/531", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/531/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 532, + "name": "rspec:windows 1 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/532", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/532/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.962Z", + "updated_at": "2019-01-06T17:48:37.962Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/532", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/532/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 534, + "name": "rspec:windows 2 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/534", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/534/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.999Z", + "updated_at": "2019-01-06T17:48:37.999Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/534", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/534/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "spinach:linux", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/536", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 536, + "name": "spinach:linux", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/536", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.050Z", + "updated_at": "2019-01-06T17:48:38.050Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/536", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "spinach:osx", + "size": 1, + "status": { + "icon": "status_warning", + "text": "failed", + "label": "failed (allowed to fail)", + "group": "failed-with-warnings", + "tooltip": "failed - (unknown failure) (allowed to fail)", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/537", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 537, + "name": "spinach:osx", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/537", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.069Z", + "updated_at": "2019-01-06T17:48:38.069Z", + "status": { + "icon": "status_warning", + "text": "failed", + "label": "failed (allowed to fail)", + "group": "failed-with-warnings", + "tooltip": "failed - (unknown failure) (allowed to fail)", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/537", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "callout_message": "There is an unknown failure, please try again", + "recoverable": true + } + ] + } + ], + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#test", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#test", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test" + }, + { + "name": "security", + "title": "security: passed", + "groups": [ + { + "name": "container_scanning", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/541", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 541, + "name": "container_scanning", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/541", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.186Z", + "updated_at": "2019-01-06T17:48:38.186Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/541", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "dast", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/538", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 538, + "name": "dast", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/538", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.087Z", + "updated_at": "2019-01-06T17:48:38.087Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/538", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "dependency_scanning", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/540", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 540, + "name": "dependency_scanning", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/540", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.153Z", + "updated_at": "2019-01-06T17:48:38.153Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/540", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "sast", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/539", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 539, + "name": "sast", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/539", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.121Z", + "updated_at": "2019-01-06T17:48:38.121Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/539", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#security", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#security", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security" + }, + { + "name": "deploy", + "title": "deploy: passed", + "groups": [ + { + "name": "production", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/544", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 544, + "name": "production", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/544", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.313Z", + "updated_at": "2019-01-06T17:48:38.313Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/544", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "staging", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/542", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 542, + "name": "staging", + "started": "2019-01-06T11:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/542", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.219Z", + "updated_at": "2019-01-06T17:48:38.219Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/542", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "stop staging", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/543", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 543, + "name": "stop staging", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/543", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.283Z", + "updated_at": "2019-01-06T17:48:38.283Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/543", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#deploy", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#deploy", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy" + }, + { + "name": "notify", + "title": "notify: passed", + "groups": [ + { + "name": "slack", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/545", + "illustration": { + "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 545, + "name": "slack", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/545", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/545/retry", + "play_path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "playable": true, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.341Z", + "updated_at": "2019-01-06T17:48:38.341Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/545", + "illustration": { + "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#notify", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#notify", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify" + } + ], + "artifacts": [ + { + "name": "build:linux", + "expired": null, + "expire_at": null, + "path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/download", + "browse_path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse" + }, + { + "name": "build:osx", + "expired": null, + "expire_at": null, + "path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/download", + "browse_path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse" + } + ], + "manual_actions": [ + { + "name": "stop staging", + "path": "/h5bp/html5-boilerplate/-/jobs/543/play", + "playable": false, + "scheduled": false + }, + { + "name": "production", + "path": "/h5bp/html5-boilerplate/-/jobs/544/play", + "playable": false, + "scheduled": false + }, + { + "name": "slack", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "playable": true, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "ref": { + "name": "master", + "path": "/h5bp/html5-boilerplate/commits/master", + "tag": false, + "branch": true, + "merge_request": false + }, + "commit": { + "id": "bad98c453eab56d20057f3929989251d45cd1a8b", + "short_id": "bad98c45", + "title": "remove instances of shrink-to-fit=no (#2103)", + "created_at": "2018-12-17T20:52:18.000Z", + "parent_ids": [ + "49130f6cfe9ff1f749015d735649a2bc6f66cf3a" + ], + "message": "remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.", + "author_name": "Scott O'Hara", + "author_email": "scottaohara@users.noreply.github.com", + "authored_date": "2018-12-17T20:52:18.000Z", + "committer_name": "Rob Larsen", + "committer_email": "rob@drunkenfist.com", + "committed_date": "2018-12-17T20:52:18.000Z", + "author": null, + "author_gravatar_url": "https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80&d=identicon", + "commit_url": "http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b", + "commit_path": "/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b" + }, + "retry_path": "/h5bp/html5-boilerplate/pipelines/26/retry", + "triggered_by": { + "id": 4, + "user": null, + "active": false, + "coverage": null, + "source": "push", + "path": "/gitlab-org/gitlab-test/pipelines/4", + "details": { + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-test/pipelines/4", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + } + }, + "project": { + "id": 1, + "name": "Gitlab Test", + "full_path": "/gitlab-org/gitlab-test", + "full_name": "Gitlab Org / Gitlab Test" + } + }, + "triggered": [ + + ], + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + } + } + ] + } + ] +} diff --git a/spec/frontend/ci/pipeline_details/mock_data.js b/spec/frontend/ci/pipeline_details/mock_data.js new file mode 100644 index 00000000000..e32d0a0df47 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/mock_data.js @@ -0,0 +1,1277 @@ +import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json'; +import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json'; +import pipelineHeaderRunningWithDuration from 'test_fixtures/graphql/pipelines/pipeline_header_running_with_duration.json'; +import pipelineHeaderFailed from 'test_fixtures/graphql/pipelines/pipeline_header_failed.json'; + +const PIPELINE_RUNNING = 'RUNNING'; +const PIPELINE_CANCELED = 'CANCELED'; +const PIPELINE_FAILED = 'FAILED'; + +const threeWeeksAgo = new Date(); +threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + +export { + pipelineHeaderSuccess, + pipelineHeaderRunning, + pipelineHeaderRunningWithDuration, + pipelineHeaderFailed, +}; + +export const pipelineRetryMutationResponseSuccess = { + data: { pipelineRetry: { errors: [] } }, +}; + +export const pipelineRetryMutationResponseFailed = { + data: { pipelineRetry: { errors: ['error'] } }, +}; + +export const pipelineCancelMutationResponseSuccess = { + data: { pipelineCancel: { errors: [] } }, +}; + +export const pipelineCancelMutationResponseFailed = { + data: { pipelineCancel: { errors: ['error'] } }, +}; + +export const pipelineDeleteMutationResponseSuccess = { + data: { pipelineDestroy: { errors: [] } }, +}; + +export const pipelineDeleteMutationResponseFailed = { + data: { pipelineDestroy: { errors: ['error'] } }, +}; + +export const mockPipelineHeader = { + detailedStatus: {}, + id: 123, + userPermissions: { + destroyPipeline: true, + updatePipeline: true, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + id: 'user-1', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, +}; + +export const mockFailedPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_FAILED, + retryable: true, + cancelable: false, + detailedStatus: { + id: 'status-1', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + detailsPath: 'path', + }, +}; + +export const mockFailedPipelineNoPermissions = { + id: 123, + userPermissions: { + destroyPipeline: false, + updatePipeline: false, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + id: 'user-1', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, + status: PIPELINE_RUNNING, + retryable: true, + cancelable: false, + detailedStatus: { + id: 'status-1', + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + +export const mockRunningPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_RUNNING, + retryable: false, + cancelable: true, + detailedStatus: { + id: 'status-1', + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + +export const mockRunningPipelineNoPermissions = { + id: 123, + userPermissions: { + destroyPipeline: false, + updatePipeline: false, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + id: 'user-1', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, + status: PIPELINE_RUNNING, + retryable: false, + cancelable: true, + detailedStatus: { + id: 'status-1', + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + +export const mockCancelledPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_CANCELED, + retryable: true, + cancelable: false, + detailedStatus: { + id: 'status-1', + group: 'cancelled', + icon: 'status_cancelled', + label: 'cancelled', + text: 'cancelled', + detailsPath: 'path', + }, +}; + +export const mockSuccessfulPipelineHeader = { + ...mockPipelineHeader, + status: 'SUCCESS', + retryable: false, + cancelable: false, + detailedStatus: { + id: 'status-1', + group: 'success', + icon: 'status_success', + label: 'success', + text: 'success', + detailsPath: 'path', + }, +}; + +export const mockRunningPipelineHeaderData = { + data: { + project: { + id: '1', + pipeline: { + ...mockRunningPipelineHeader, + iid: '28', + user: { + id: 'user-1', + name: 'Foo', + username: 'foobar', + webPath: '/foo', + webUrl: '/foo', + email: 'foo@bar.com', + avatarUrl: 'link', + status: null, + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; + +export const users = [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/root', + }, + { + id: 10, + name: 'Angel Spinka', + username: 'shalonda', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/709df1b65ad06764ee2b0edf1b49fc27?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/shalonda', + }, + { + id: 11, + name: 'Art Davis', + username: 'deja.green', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/bb56834c061522760e7a6dd7d431a306?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/deja.green', + }, + { + id: 32, + name: 'Arnold Mante', + username: 'reported_user_10', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/ab558033a82466d7905179e837d7723a?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_10', + }, + { + id: 38, + name: 'Cher Wintheiser', + username: 'reported_user_16', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/2640356e8b5bc4314133090994ed162b?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_16', + }, + { + id: 39, + name: 'Bethel Wolf', + username: 'reported_user_17', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/4b948694fadba4b01e4acfc06b065e8e?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_17', + }, +]; + +export const branches = [ + { + name: 'branch-1', + commit: { + id: '21fb056cc47dcf706670e6de635b1b326490ebdc', + short_id: '21fb056c', + created_at: '2020-05-07T10:58:28.000-04:00', + parent_ids: null, + title: 'Add new file', + message: 'Add new file', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-05-07T10:58:28.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-05-07T10:58:28.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/21fb056cc47dcf706670e6de635b1b326490ebdc', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-1', + }, + { + name: 'branch-10', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: null, + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-10', + }, + { + name: 'branch-11', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: null, + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-11', + }, +]; + +export const tags = [ + { + name: 'tag-3', + message: '', + target: '66673b07efef254dab7d537f0433a40e61cf84fe', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: ['def28bf679235071140180495f25b657e2203587'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + release: null, + protected: false, + }, + { + name: 'tag-2', + message: '', + target: '66673b07efef254dab7d537f0433a40e61cf84fe', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: ['def28bf679235071140180495f25b657e2203587'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + release: null, + protected: false, + }, + { + name: 'tag-1', + message: '', + target: '66673b07efef254dab7d537f0433a40e61cf84fe', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: ['def28bf679235071140180495f25b657e2203587'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + release: null, + protected: false, + }, + { + name: 'main-tag', + message: '', + target: '66673b07efef254dab7d537f0433a40e61cf84fe', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: ['def28bf679235071140180495f25b657e2203587'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + release: null, + protected: false, + }, +]; + +export const mockSearch = [ + { type: 'username', value: { data: 'root', operator: '=' } }, + { type: 'ref', value: { data: 'main', operator: '=' } }, + { type: 'status', value: { data: 'pending', operator: '=' } }, +]; + +export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; + +export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag']; + +export const mockPipelineJobsQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + __typename: 'Project', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/224', + __typename: 'Pipeline', + jobs: { + __typename: 'CiJobConnection', + pageInfo: { + endCursor: 'eyJpZCI6Ijg0NyJ9', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjYyMCJ9', + __typename: 'PageInfo', + }, + nodes: [ + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/620/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + id: 'success-620-620', + detailsPath: '/root/ci-project/-/jobs/620', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed (retried)', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/620', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '5acce24b', + commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', + stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, + name: 'coverage_job', + duration: 4, + finishedAt: '2021-12-06T14:13:49Z', + coverage: 82.71, + retryable: false, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, + __typename: 'CiJob', + }, + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/619/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + id: 'success-619-619', + detailsPath: '/root/ci-project/-/jobs/619', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed (retried)', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/619', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '5acce24b', + commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', + stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, + name: 'test_job_two', + duration: 4, + finishedAt: '2021-12-06T14:13:44Z', + coverage: null, + retryable: false, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, + __typename: 'CiJob', + }, + ], + }, + }, + }, + }, +}; + +export const mockPipeline = (projectPath) => { + return { + pipeline: { + id: 1, + user: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: '', + web_url: 'http://0.0.0.0:3000/root', + show_status: false, + path: '/root', + }, + active: false, + source: 'merge_request_event', + created_at: '2021-10-19T21:17:38.698Z', + updated_at: '2021-10-21T18:00:42.758Z', + path: 'foo', + flags: {}, + merge_request: { + iid: 1, + path: `/${projectPath}/1`, + title: 'commit', + source_branch: 'test-commit-name', + source_branch_path: `/${projectPath}`, + target_branch: 'main', + target_branch_path: `/${projectPath}/-/commit/main`, + }, + ref: { + name: 'refs/merge-requests/1/head', + path: `/${projectPath}/-/commits/refs/merge-requests/1/head`, + tag: false, + branch: false, + merge_request: true, + }, + commit: { + id: 'fd6df5b3229e213c97d308844a6f3e7fd71e8f8c', + short_id: 'fd6df5b3', + created_at: '2021-10-19T21:17:12.000+00:00', + parent_ids: ['7147906b84306e83cb3fec6582a25390b75713c6'], + title: 'Commit', + message: 'Commit', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2021-10-19T21:17:12.000+00:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2021-10-19T21:17:12.000+00:00', + trailers: {}, + web_url: '', + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: '', + web_url: '', + show_status: false, + path: '/root', + }, + author_gravatar_url: '', + commit_url: `/${projectPath}/fd6df5b3229e213c97d308844a6f3e7fd71e8f8c`, + commit_path: `/${projectPath}/commit/fd6df5b3229e213c97d308844a6f3e7fd71e8f8c`, + }, + project: { + full_path: `/${projectPath}`, + }, + triggered_by: null, + triggered: [], + }, + pipelineScheduleUrl: 'foo', + pipelineKey: 'id', + viewType: 'root', + }; +}; + +export const mockPipelineTag = () => { + return { + pipeline: { + id: 311, + iid: 37, + user: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + active: false, + source: 'push', + name: 'Build pipeline', + created_at: '2022-02-02T15:39:04.012Z', + updated_at: '2022-02-02T15:40:59.573Z', + path: '/root/mr-widgets/-/pipelines/311', + flags: { + stuck: false, + auto_devops: false, + merge_request: false, + yaml_errors: false, + retryable: true, + cancelable: false, + failure_reason: false, + detached_merge_request_pipeline: false, + merge_request_pipeline: false, + merge_train_pipeline: false, + latest: true, + }, + details: { + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + stages: [ + { + name: 'accessibility', + title: 'accessibility: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311#accessibility', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/311#accessibility', + dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=accessibility', + }, + { + name: 'validate', + title: 'validate: passed with warnings', + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311#validate', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/311#validate', + dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=validate', + }, + { + name: 'test', + title: 'test: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311#test', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/311#test', + dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=test', + }, + { + name: 'build', + title: 'build: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311#build', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/311#build', + dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=build', + }, + ], + duration: 93, + finished_at: '2022-02-02T15:40:59.384Z', + event_type_name: 'Pipeline', + manual_actions: [], + scheduled_actions: [], + }, + ref: { + name: 'test', + path: '/root/mr-widgets/-/commits/test', + tag: true, + branch: false, + merge_request: false, + }, + commit: { + id: '9b92b4f730d1611bd9a086ca221ae206d5da1e59', + short_id: '9b92b4f7', + created_at: '2022-01-13T13:59:03.000+00:00', + parent_ids: ['0ba763634114e207dc72c65c8e9459556b1204fb'], + title: 'Update hello_world.js', + message: 'Update hello_world.js', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2022-01-13T13:59:03.000+00:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2022-01-13T13:59:03.000+00:00', + trailers: {}, + web_url: + 'http://gdk.test:3000/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', + author: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commit_url: + 'http://gdk.test:3000/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', + commit_path: '/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', + }, + retry_path: '/root/mr-widgets/-/pipelines/311/retry', + delete_path: '/root/mr-widgets/-/pipelines/311', + failed_builds: [ + { + id: 1696, + name: 'fmt', + started: '2022-02-02T15:39:45.192Z', + complete: true, + archived: false, + build_path: '/root/mr-widgets/-/jobs/1696', + retry_path: '/root/mr-widgets/-/jobs/1696/retry', + playable: false, + scheduled: false, + created_at: '2022-02-02T15:39:04.136Z', + updated_at: '2022-02-02T15:39:57.969Z', + status: { + icon: 'status_warning', + text: 'failed', + label: 'failed (allowed to fail)', + group: 'failed-with-warnings', + tooltip: 'failed - (script failure) (allowed to fail)', + has_details: true, + details_path: '/root/mr-widgets/-/jobs/1696', + illustration: { + image: + '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/mr-widgets/-/jobs/1696/retry', + method: 'post', + button_title: 'Retry this job', + }, + }, + recoverable: false, + }, + ], + project: { + id: 23, + name: 'mr-widgets', + full_path: '/root/mr-widgets', + full_name: 'Administrator / mr-widgets', + }, + triggered_by: null, + triggered: [], + }, + pipelineScheduleUrl: 'foo', + pipelineKey: 'id', + viewType: 'root', + }; +}; + +export const mockPipelineBranch = () => { + return { + pipeline: { + id: 268, + iid: 34, + user: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + active: false, + source: 'push', + name: 'Build pipeline', + created_at: '2022-01-14T17:40:27.866Z', + updated_at: '2022-01-14T18:02:35.850Z', + path: '/root/mr-widgets/-/pipelines/268', + flags: { + stuck: false, + auto_devops: false, + merge_request: false, + yaml_errors: false, + retryable: true, + cancelable: false, + failure_reason: false, + detached_merge_request_pipeline: false, + merge_request_pipeline: false, + merge_train_pipeline: false, + latest: true, + }, + details: { + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/268', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + stages: [ + { + name: 'validate', + title: 'validate: passed with warnings', + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/268#validate', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/268#validate', + dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=validate', + }, + { + name: 'test', + title: 'test: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/268#test', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/268#test', + dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=test', + }, + { + name: 'build', + title: 'build: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/268#build', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/268#build', + dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=build', + }, + ], + duration: 75, + finished_at: '2022-01-14T18:02:35.842Z', + event_type_name: 'Pipeline', + manual_actions: [], + scheduled_actions: [], + }, + ref: { + name: 'update-ci', + path: '/root/mr-widgets/-/commits/update-ci', + tag: false, + branch: true, + merge_request: false, + }, + commit: { + id: '96aef9ecec5752c09371c1ade5fc77860aafc863', + short_id: '96aef9ec', + created_at: '2022-01-14T17:40:26.000+00:00', + parent_ids: ['06860257572d4cf84b73806250b78169050aed83'], + title: 'Update main.tf', + message: 'Update main.tf', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2022-01-14T17:40:26.000+00:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2022-01-14T17:40:26.000+00:00', + trailers: {}, + web_url: + 'http://gdk.test:3000/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', + author: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commit_url: + 'http://gdk.test:3000/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', + commit_path: '/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', + }, + retry_path: '/root/mr-widgets/-/pipelines/268/retry', + delete_path: '/root/mr-widgets/-/pipelines/268', + failed_builds: [ + { + id: 1260, + name: 'fmt', + started: '2022-01-14T17:40:36.435Z', + complete: true, + archived: false, + build_path: '/root/mr-widgets/-/jobs/1260', + retry_path: '/root/mr-widgets/-/jobs/1260/retry', + playable: false, + scheduled: false, + created_at: '2022-01-14T17:40:27.879Z', + updated_at: '2022-01-14T17:40:42.129Z', + status: { + icon: 'status_warning', + text: 'failed', + label: 'failed (allowed to fail)', + group: 'failed-with-warnings', + tooltip: 'failed - (script failure) (allowed to fail)', + has_details: true, + details_path: '/root/mr-widgets/-/jobs/1260', + illustration: { + image: + '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/mr-widgets/-/jobs/1260/retry', + method: 'post', + button_title: 'Retry this job', + }, + }, + recoverable: false, + }, + ], + project: { + id: 23, + name: 'mr-widgets', + full_path: '/root/mr-widgets', + full_name: 'Administrator / mr-widgets', + }, + triggered_by: null, + triggered: [], + }, + pipelineScheduleUrl: 'foo', + pipelineKey: 'id', + viewType: 'root', + }; +}; + +export const mockFailedJobsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/20', + pipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/300', + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1848-1848', + detailsPath: '/root/ci-project/-/jobs/1848', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure)', + action: { + __typename: 'StatusAction', + id: 'Ci::Build-failed-1848', + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/ci-project/-/jobs/1848/retry', + title: 'Retry', + }, + }, + id: 'gid://gitlab/Ci::Build/1848', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: true, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + trace: { + htmlSummary: '<span>Html Summary</span>', + }, + failureMessage: 'Failed', + }, + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1710-1710', + detailsPath: '/root/ci-project/-/jobs/1710', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure) (retried)', + action: null, + }, + id: 'gid://gitlab/Ci::Build/1710', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: false, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + trace: null, + failureMessage: 'Failed', + }, + ], + }, + }, + }, + }, +}; + +export const mockFailedJobsData = [ + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1848-1848', + detailsPath: '/root/ci-project/-/jobs/1848', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure)', + action: { + __typename: 'StatusAction', + id: 'Ci::Build-failed-1848', + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/ci-project/-/jobs/1848/retry', + title: 'Retry', + }, + }, + id: 'gid://gitlab/Ci::Build/1848', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: true, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + trace: { + htmlSummary: '<span>Html Summary</span>', + }, + failureMessage: 'Job failed', + _showDetails: true, + }, + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1710-1710', + detailsPath: '/root/ci-project/-/jobs/1710', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure) (retried)', + action: null, + }, + id: 'gid://gitlab/Ci::Build/1710', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: false, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + trace: null, + failureMessage: 'Job failed', + _showDetails: true, + }, +]; + +export const mockFailedJobsDataNoPermission = [ + { + ...mockFailedJobsData[0], + userPermissions: { __typename: 'JobPermissions', readBuild: false, updateBuild: false }, + }, +]; + +export const successRetryMutationResponse = { + data: { + jobRetry: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1985"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1985', + id: 'pending-1985-1985', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const failedRetryMutationResponse = { + data: { + jobRetry: { + job: {}, + errors: ['New Error'], + __typename: 'JobRetryPayload', + }, + }, +}; diff --git a/spec/frontend/ci/pipeline_details/pipeline_tabs_spec.js b/spec/frontend/ci/pipeline_details/pipeline_tabs_spec.js new file mode 100644 index 00000000000..8d67cdef05c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipeline_tabs_spec.js @@ -0,0 +1,63 @@ +import { createAppOptions } from '~/ci/pipeline_details/pipeline_tabs'; + +jest.mock('~/lib/utils/url_utility', () => ({ + removeParams: () => 'gitlab.com', + joinPaths: () => {}, + setUrlFragment: () => {}, +})); + +jest.mock('~/ci/pipeline_details/utils', () => ({ + getPipelineDefaultTab: () => '', +})); + +describe('~/ci/pipeline_details/pipeline_tabs.js', () => { + describe('createAppOptions', () => { + const SELECTOR = 'SELECTOR'; + + let el; + + const createElement = () => { + el = document.createElement('div'); + el.id = SELECTOR; + el.dataset.canGenerateCodequalityReports = 'true'; + el.dataset.codequalityReportDownloadPath = 'codequalityReportDownloadPath'; + el.dataset.downloadablePathForReportType = 'downloadablePathForReportType'; + el.dataset.exposeSecurityDashboard = 'true'; + el.dataset.exposeLicenseScanningData = 'true'; + el.dataset.failedJobsCount = 1; + el.dataset.graphqlResourceEtag = 'graphqlResourceEtag'; + el.dataset.pipelineIid = '123'; + el.dataset.pipelineProjectPath = 'pipelineProjectPath'; + + document.body.appendChild(el); + }; + + afterEach(() => { + el = null; + }); + + it("extracts the properties from the element's dataset", () => { + createElement(); + const options = createAppOptions(`#${SELECTOR}`, null); + + expect(options).toMatchObject({ + el, + provide: { + canGenerateCodequalityReports: true, + codequalityReportDownloadPath: 'codequalityReportDownloadPath', + downloadablePathForReportType: 'downloadablePathForReportType', + exposeSecurityDashboard: true, + exposeLicenseScanningData: true, + failedJobsCount: '1', + graphqlResourceEtag: 'graphqlResourceEtag', + pipelineIid: '123', + pipelineProjectPath: 'pipelineProjectPath', + }, + }); + }); + + it('returns `null` if el does not exist', () => { + expect(createAppOptions('foo', null)).toBe(null); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_store_spec.js b/spec/frontend/ci/pipeline_details/pipelines_store_spec.js new file mode 100644 index 00000000000..43e605f4306 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_store_spec.js @@ -0,0 +1,80 @@ +import PipelineStore from '~/ci/pipeline_details/stores/pipelines_store'; + +describe('Pipelines Store', () => { + let store; + + beforeEach(() => { + store = new PipelineStore(); + }); + + it('should be initialized with an empty state', () => { + expect(store.state.pipelines).toEqual([]); + expect(store.state.count).toEqual({}); + expect(store.state.pageInfo).toEqual({}); + }); + + describe('storePipelines', () => { + it('should use the default parameter if none is provided', () => { + store.storePipelines(); + + expect(store.state.pipelines).toEqual([]); + }); + + it('should store the provided array', () => { + const array = [ + { id: 1, status: 'running' }, + { id: 2, status: 'success' }, + ]; + store.storePipelines(array); + + expect(store.state.pipelines).toEqual(array); + }); + }); + + describe('storeCount', () => { + it('should use the default parameter if none is provided', () => { + store.storeCount(); + + expect(store.state.count).toEqual({}); + }); + + it('should store the provided count', () => { + const count = { all: 20, finished: 10 }; + store.storeCount(count); + + expect(store.state.count).toEqual(count); + }); + }); + + describe('storePagination', () => { + it('should use the default parameter if none is provided', () => { + store.storePagination(); + + expect(store.state.pageInfo).toEqual({}); + }); + + it('should store pagination information normalized and parsed', () => { + const pagination = { + 'X-nExt-pAge': '2', + 'X-page': '1', + 'X-Per-Page': '1', + 'X-Prev-Page': '2', + 'X-TOTAL': '37', + 'X-Total-Pages': '2', + }; + + const expectedResult = { + perPage: 1, + page: 1, + total: 37, + totalPages: 2, + nextPage: 2, + previousPage: 2, + }; + + store.storePagination(pagination); + + expect(store.state.pageInfo).toEqual(expectedResult); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js b/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js new file mode 100644 index 00000000000..0f1835b7ec8 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js @@ -0,0 +1,114 @@ +import { GlTab } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import PipelineTabs from '~/ci/pipeline_details/tabs/pipeline_tabs.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; + +describe('The Pipeline Tabs', () => { + let wrapper; + let trackingSpy; + + const $router = { push: jest.fn() }; + + const findDagTab = () => wrapper.findByTestId('dag-tab'); + const findFailedJobsTab = () => wrapper.findByTestId('failed-jobs-tab'); + const findJobsTab = () => wrapper.findByTestId('jobs-tab'); + const findPipelineTab = () => wrapper.findByTestId('pipeline-tab'); + const findTestsTab = () => wrapper.findByTestId('tests-tab'); + + const findFailedJobsBadge = () => wrapper.findByTestId('failed-builds-counter'); + const findJobsBadge = () => wrapper.findByTestId('builds-counter'); + const findTestsBadge = () => wrapper.findByTestId('tests-counter'); + + const defaultProvide = { + defaultTabValue: '', + failedJobsCount: 1, + totalJobCount: 10, + testsCount: 123, + }; + + const createComponent = (provide = {}) => { + wrapper = shallowMountExtended(PipelineTabs, { + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + GlTab, + RouterView: true, + }, + mocks: { + $router, + }, + }); + }; + + describe('Tabs', () => { + it.each` + tabName | tabComponent + ${'Pipeline'} | ${findPipelineTab} + ${'Dag'} | ${findDagTab} + ${'Jobs'} | ${findJobsTab} + ${'Failed Jobs'} | ${findFailedJobsTab} + ${'Tests'} | ${findTestsTab} + `('shows $tabName tab', ({ tabComponent }) => { + createComponent(); + + expect(tabComponent().exists()).toBe(true); + }); + + describe('with no failed jobs', () => { + beforeEach(() => { + createComponent({ failedJobsCount: 0 }); + }); + + it('hides the failed jobs tab', () => { + expect(findFailedJobsTab().exists()).toBe(false); + }); + }); + }); + + describe('Tabs badges', () => { + it.each` + tabName | badgeComponent | badgeText + ${'Jobs'} | ${findJobsBadge} | ${String(defaultProvide.totalJobCount)} + ${'Failed Jobs'} | ${findFailedJobsBadge} | ${String(defaultProvide.failedJobsCount)} + ${'Tests'} | ${findTestsBadge} | ${String(defaultProvide.testsCount)} + `('shows badge for $tabName with the correct text', ({ badgeComponent, badgeText }) => { + createComponent(); + + expect(badgeComponent().exists()).toBe(true); + expect(badgeComponent().text()).toBe(badgeText); + }); + }); + + describe('Tab tracking', () => { + beforeEach(() => { + createComponent(); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks failed jobs tab click', () => { + findFailedJobsTab().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { + label: TRACKING_CATEGORIES.failed, + }); + }); + + it('tracks tests tab click', () => { + findTestsTab().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { + label: TRACKING_CATEGORIES.tests, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/empty_state_spec.js b/spec/frontend/ci/pipeline_details/test_reports/empty_state_spec.js new file mode 100644 index 00000000000..ed1d6bc7d37 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/empty_state_spec.js @@ -0,0 +1,45 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EmptyState, { i18n } from '~/ci/pipeline_details/test_reports/empty_state.vue'; + +describe('Test report empty state', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = ({ hasTestReport = true } = {}) => { + wrapper = shallowMount(EmptyState, { + provide: { + emptyStateImagePath: '/image/path', + hasTestReport, + }, + stubs: { + GlEmptyState, + }, + }); + }; + + describe('when pipeline has a test report', () => { + it('should render empty test report message', () => { + createComponent(); + + expect(findEmptyState().props()).toMatchObject({ + primaryButtonText: i18n.noTestsButton, + description: i18n.noTestsDescription, + title: i18n.noTestsTitle, + }); + }); + }); + + describe('when pipeline does not have a test report', () => { + it('should render no test report message', () => { + createComponent({ hasTestReport: false }); + + expect(findEmptyState().props()).toMatchObject({ + primaryButtonText: i18n.noReportsButton, + description: i18n.noReportsDescription, + title: i18n.noReportsTitle, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/mock_data.js b/spec/frontend/ci/pipeline_details/test_reports/mock_data.js new file mode 100644 index 00000000000..7c9f9287c86 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/mock_data.js @@ -0,0 +1,31 @@ +import { TestStatus } from '~/ci/pipeline_details/constants'; + +export default [ + { + classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', + execution_time: 0, + name: 'Test#skipped text', + stack_trace: null, + status: TestStatus.SKIPPED, + system_output: null, + }, + { + classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', + execution_time: 0, + name: 'Test#error text', + stack_trace: null, + status: TestStatus.ERROR, + system_output: null, + }, + { + classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', + execution_time: 0, + name: 'Test#unknown text', + stack_trace: null, + status: TestStatus.UNKNOWN, + system_output: null, + }, +]; diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/actions_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/actions_spec.js new file mode 100644 index 00000000000..6636a7f1ed6 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/actions_spec.js @@ -0,0 +1,149 @@ +import MockAdapter from 'axios-mock-adapter'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import { TEST_HOST } from 'helpers/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import * as actions from '~/ci/pipeline_details/stores/test_reports/actions'; +import * as types from '~/ci/pipeline_details/stores/test_reports/mutation_types'; + +jest.mock('~/alert'); + +describe('Actions TestReports Store', () => { + let mock; + let state; + + const summary = { total_count: 1 }; + + const suiteEndpoint = `${TEST_HOST}/tests/suite.json`; + const summaryEndpoint = `${TEST_HOST}/test_reports/summary.json`; + const defaultState = { + suiteEndpoint, + summaryEndpoint, + testReports: {}, + selectedSuite: null, + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = { ...defaultState }; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('fetch report summary', () => { + beforeEach(() => { + mock.onGet(summaryEndpoint).replyOnce(HTTP_STATUS_OK, summary, {}); + }); + + it('sets testReports and shows tests', () => { + return testAction( + actions.fetchSummary, + null, + state, + [{ type: types.SET_SUMMARY, payload: summary }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + ); + }); + + it('should create alert on API error', async () => { + await testAction( + actions.fetchSummary, + null, + { summaryEndpoint: null }, + [], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + ); + expect(createAlert).toHaveBeenCalled(); + }); + }); + + describe('fetch test suite', () => { + beforeEach(() => { + const buildIds = [1]; + testReports.test_suites[0].build_ids = buildIds; + mock + .onGet(suiteEndpoint, { params: { build_ids: buildIds } }) + .replyOnce(HTTP_STATUS_OK, testReports.test_suites[0], {}); + }); + + it('sets test suite and shows tests', () => { + const suite = testReports.test_suites[0]; + const index = 0; + + return testAction( + actions.fetchTestSuite, + index, + { ...state, testReports }, + [{ type: types.SET_SUITE, payload: { suite, index } }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + ); + }); + + it('should call SET_SUITE_ERROR on error', () => { + const index = 0; + + return testAction( + actions.fetchTestSuite, + index, + { ...state, testReports, suiteEndpoint: null }, + [{ type: types.SET_SUITE_ERROR, payload: expect.any(Error) }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + ); + }); + + describe('when we already have the suite data', () => { + it('should not fetch suite', () => { + const index = 0; + testReports.test_suites[0].hasFullSuite = true; + + return testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], []); + }); + }); + }); + + describe('set selected suite index', () => { + it('sets selectedSuiteIndex', () => { + const selectedSuiteIndex = 0; + + return testAction( + actions.setSelectedSuiteIndex, + selectedSuiteIndex, + { ...state, hasFullReport: true }, + [{ type: types.SET_SELECTED_SUITE_INDEX, payload: selectedSuiteIndex }], + [], + ); + }); + }); + + describe('remove selected suite index', () => { + it('sets selectedSuiteIndex to null', () => { + return testAction( + actions.removeSelectedSuiteIndex, + {}, + state, + [{ type: types.SET_SELECTED_SUITE_INDEX, payload: null }], + [], + ); + }); + }); + + describe('toggles loading', () => { + it('sets isLoading to true', () => { + return testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], []); + }); + + it('toggles isLoading to false', () => { + return testAction( + actions.toggleLoading, + {}, + { ...state, isLoading: true }, + [{ type: types.TOGGLE_LOADING }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/getters_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/getters_spec.js new file mode 100644 index 00000000000..e52e9a07ae0 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/getters_spec.js @@ -0,0 +1,171 @@ +import testReports from 'test_fixtures/pipelines/test_report.json'; +import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; +import { + iconForTestStatus, + formatFilePath, + formattedTime, +} from '~/ci/pipeline_details/stores/test_reports/utils'; + +describe('Getters TestReports Store', () => { + let state; + + const defaultState = { + blobPath: '/test/blob/path', + testReports, + selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage: 2, + }, + }; + + const emptyState = { + blobPath: '', + testReports: {}, + selectedSuite: null, + pageInfo: { + page: 1, + perPage: 2, + }, + }; + + beforeEach(() => { + state = { + testReports, + }; + }); + + const setupState = (testState = defaultState) => { + state = testState; + }; + + describe('getTestSuites', () => { + it('should return the test suites', () => { + setupState(); + + const suites = getters.getTestSuites(state); + const expected = testReports.test_suites.map((x) => ({ + ...x, + formattedTime: formattedTime(x.total_time), + })); + + expect(suites).toEqual(expected); + }); + + it('should return an empty array when testReports is empty', () => { + setupState(emptyState); + + expect(getters.getTestSuites(state)).toEqual([]); + }); + }); + + describe('getSelectedSuite', () => { + it('should return the selected suite', () => { + setupState(); + + const selectedSuite = getters.getSelectedSuite(state); + const expected = testReports.test_suites[state.selectedSuiteIndex]; + + expect(selectedSuite).toEqual(expected); + }); + }); + + describe('getSuiteTests', () => { + 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, + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); + + expect(cases).toEqual(expected); + }); + + it('should return an empty array when testReports is empty', () => { + setupState(emptyState); + + expect(getters.getSuiteTests(state)).toEqual([]); + }); + + describe('when a test case classname property is null', () => { + it('should return an empty string value for the classname property', () => { + const testCases = testReports.test_suites[0].test_cases; + setupState({ + ...defaultState, + testReports: { + ...testReports, + test_suites: [ + { + test_cases: testCases.map((testCase) => ({ + ...testCase, + classname: null, + })), + }, + ], + }, + }); + + const expected = testCases + .map((x) => ({ + ...x, + classname: '', + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); + + expect(getters.getSuiteTests(state)).toEqual(expected); + }); + }); + + describe('when a test case name property is null', () => { + it('should return an empty string value for the name property', () => { + const testCases = testReports.test_suites[0].test_cases; + setupState({ + ...defaultState, + testReports: { + ...testReports, + test_suites: [ + { + test_cases: testCases.map((testCase) => ({ + ...testCase, + name: null, + })), + }, + ], + }, + }); + + const expected = testCases + .map((x) => ({ + ...x, + name: '', + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); + + expect(getters.getSuiteTests(state)).toEqual(expected); + }); + }); + }); + + 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/ci/pipeline_details/test_reports/stores/mutations_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/mutations_spec.js new file mode 100644 index 00000000000..d58515dcc6d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/mutations_spec.js @@ -0,0 +1,114 @@ +import testReports from 'test_fixtures/pipelines/test_report.json'; +import * as types from '~/ci/pipeline_details/stores/test_reports/mutation_types'; +import mutations from '~/ci/pipeline_details/stores/test_reports/mutations'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); + +describe('Mutations TestReports Store', () => { + let mockState; + + const defaultState = { + endpoint: '', + 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; + const suite = { name: 'test_suite' }; + const index = 0; + const expectedState = { ...mockState }; + expectedState.testReports.test_suites[index] = { suite, hasFullSuite: true }; + mutations[types.SET_SUITE](mockState, { suite, index }); + + expect(mockState.testReports.test_suites[index]).toEqual( + expectedState.testReports.test_suites[index], + ); + }); + }); + + describe('set suite error', () => { + it('should set the error message in state if provided', () => { + const message = 'Test report artifacts not found'; + + mutations[types.SET_SUITE_ERROR](mockState, { + response: { data: { errors: message } }, + }); + + expect(mockState.errorMessage).toBe(message); + }); + + it('should show an alert otherwise', () => { + mutations[types.SET_SUITE_ERROR](mockState, {}); + + expect(createAlert).toHaveBeenCalled(); + }); + }); + + describe('set selected suite index', () => { + it('should set selectedSuiteIndex', () => { + const selectedSuiteIndex = 0; + mutations[types.SET_SELECTED_SUITE_INDEX](mockState, selectedSuiteIndex); + + expect(mockState.selectedSuiteIndex).toEqual(selectedSuiteIndex); + }); + }); + + describe('set summary', () => { + it('should set summary', () => { + const summary = { + total: { time: 0, count: 10, success: 1, failed: 2, skipped: 3, error: 4 }, + }; + const expectedSummary = { + ...summary, + total_time: 0, + total_count: 10, + success_count: 1, + failed_count: 2, + skipped_count: 3, + error_count: 4, + }; + mutations[types.SET_SUMMARY](mockState, summary); + + expect(mockState.testReports).toEqual(expectedSummary); + }); + }); + + describe('toggle loading', () => { + it('should set to true', () => { + const expectedState = { ...mockState, isLoading: true }; + mutations[types.TOGGLE_LOADING](mockState); + + expect(mockState.isLoading).toEqual(expectedState.isLoading); + }); + + it('should toggle back to false', () => { + const expectedState = { ...mockState, isLoading: false }; + mockState.isLoading = true; + + mutations[types.TOGGLE_LOADING](mockState); + + expect(mockState.isLoading).toEqual(expectedState.isLoading); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js new file mode 100644 index 00000000000..c0ffc2b34fb --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js @@ -0,0 +1,40 @@ +import { formatFilePath, formattedTime } from '~/ci/pipeline_details/stores/test_reports/utils'; + +describe('Test reports utils', () => { + describe('formatFilePath', () => { + it.each` + file | expected + ${'./test.js'} | ${'test.js'} + ${'/test.js'} | ${'test.js'} + ${'.//////////////test.js'} | ${'test.js'} + ${'test.js'} | ${'test.js'} + ${'mock/path./test.js'} | ${'mock/path./test.js'} + ${'./mock/path./test.js'} | ${'mock/path./test.js'} + `('should format $file to be $expected', ({ file, expected }) => { + expect(formatFilePath(file)).toBe(expected); + }); + }); + + describe('formattedTime', () => { + describe('when time is smaller than a second', () => { + it('should return time in milliseconds fixed to 2 decimals', () => { + const result = formattedTime(0.4815162342); + expect(result).toBe('481.52ms'); + }); + }); + + describe('when time is equal to a second', () => { + it('should return time in seconds fixed to 2 decimals', () => { + const result = formattedTime(1); + expect(result).toBe('1.00s'); + }); + }); + + describe('when time is greater than a second', () => { + it('should return time in seconds fixed to 2 decimals', () => { + const result = formattedTime(4.815162342); + expect(result).toBe('4.82s'); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_case_details_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_case_details_spec.js new file mode 100644 index 00000000000..0f651b9d456 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_case_details_spec.js @@ -0,0 +1,149 @@ +import { GlModal, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TestCaseDetails from '~/ci/pipeline_details/test_reports/test_case_details.vue'; +import CodeBlock from '~/vue_shared/components/code_block.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; + +describe('Test case details', () => { + let wrapper; + const defaultTestCase = { + classname: 'spec.test_spec', + name: 'Test#something cool', + file: '~/index.js', + filePath: '/src/javascripts/index.js', + formattedTime: '10.04ms', + recent_failures: { + count: 2, + base_branch: 'main', + }, + system_output: 'Line 42 is broken', + }; + + const findCopyFileBtn = () => wrapper.findComponent(ModalCopyButton); + const findModal = () => wrapper.findComponent(GlModal); + const findName = () => wrapper.findByTestId('test-case-name'); + const findFile = () => wrapper.findByTestId('test-case-file'); + const findFileLink = () => wrapper.findComponent(GlLink); + const findDuration = () => wrapper.findByTestId('test-case-duration'); + const findRecentFailures = () => wrapper.findByTestId('test-case-recent-failures'); + const findAttachmentUrl = () => wrapper.findByTestId('test-case-attachment-url'); + const findSystemOutput = () => wrapper.findByTestId('test-case-trace'); + + const createComponent = (testCase = {}) => { + wrapper = extendedWrapper( + shallowMount(TestCaseDetails, { + propsData: { + modalId: 'my-modal', + testCase: { + ...defaultTestCase, + ...testCase, + }, + }, + stubs: { CodeBlock, GlModal }, + }), + ); + }; + + describe('required details', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the test case classname as modal title', () => { + expect(findModal().props('title')).toBe(defaultTestCase.classname); + }); + + it('renders the test case name', () => { + expect(findName().text()).toBe(defaultTestCase.name); + }); + + it('renders the test case file', () => { + expect(findFile().text()).toBe(defaultTestCase.file); + expect(findFileLink().attributes('href')).toBe(defaultTestCase.filePath); + }); + + it('renders copy button for test case file', () => { + expect(findCopyFileBtn().attributes('text')).toBe(defaultTestCase.file); + }); + + it('renders the test case duration', () => { + expect(findDuration().text()).toBe(defaultTestCase.formattedTime); + }); + }); + + describe('when test case has execution time instead of formatted time', () => { + beforeEach(() => { + createComponent({ ...defaultTestCase, formattedTime: null, execution_time: 17 }); + }); + + it('renders the test case duration', () => { + expect(findDuration().text()).toBe('17 s'); + }); + }); + + describe('when test case has recent failures', () => { + describe('has only 1 recent failure', () => { + it('renders the recent failure', () => { + createComponent({ recent_failures: { ...defaultTestCase.recent_failures, count: 1 } }); + + expect(findRecentFailures().text()).toContain( + `Failed 1 time in ${defaultTestCase.recent_failures.base_branch} in the last 14 days`, + ); + }); + }); + + describe('has more than 1 recent failure', () => { + it('renders the recent failures', () => { + createComponent(); + + expect(findRecentFailures().text()).toContain( + `Failed ${defaultTestCase.recent_failures.count} times in ${defaultTestCase.recent_failures.base_branch} in the last 14 days`, + ); + }); + }); + }); + + describe('when test case does not have recent failures', () => { + it('does not render the recent failures', () => { + createComponent({ recent_failures: null }); + + expect(findRecentFailures().exists()).toBe(false); + }); + }); + + describe('when test case has attachment URL', () => { + it('renders the attachment URL as a link', () => { + const expectedUrl = '/my/path.jpg'; + createComponent({ attachment_url: expectedUrl }); + const attachmentUrl = findAttachmentUrl(); + + expect(attachmentUrl.exists()).toBe(true); + expect(attachmentUrl.attributes('href')).toBe(expectedUrl); + }); + }); + + describe('when test case does not have attachment URL', () => { + it('does not render the attachment URL', () => { + createComponent({ attachment_url: null }); + + expect(findAttachmentUrl().exists()).toBe(false); + }); + }); + + describe('when test case has system output', () => { + it('renders the test case system output', () => { + createComponent(); + + expect(findSystemOutput().text()).toContain(defaultTestCase.system_output); + }); + }); + + describe('when test case does not have system output', () => { + it('does not render the test case system output', () => { + createComponent({ system_output: null }); + + expect(findSystemOutput().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js new file mode 100644 index 00000000000..8ff060026da --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js @@ -0,0 +1,125 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import EmptyState from '~/ci/pipeline_details/test_reports/empty_state.vue'; +import TestReports from '~/ci/pipeline_details/test_reports/test_reports.vue'; +import TestSummary from '~/ci/pipeline_details/test_reports/test_summary.vue'; +import TestSummaryTable from '~/ci/pipeline_details/test_reports/test_summary_table.vue'; +import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; + +Vue.use(Vuex); + +describe('Test reports app', () => { + let wrapper; + let store; + + const loadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const testsDetail = () => wrapper.findByTestId('tests-detail'); + const emptyState = () => wrapper.findComponent(EmptyState); + const testSummary = () => wrapper.findComponent(TestSummary); + const testSummaryTable = () => wrapper.findComponent(TestSummaryTable); + + const actionSpies = { + fetchTestSuite: jest.fn(), + fetchSummary: jest.fn(), + setSelectedSuiteIndex: jest.fn(), + removeSelectedSuiteIndex: jest.fn(), + }; + + const createComponent = ({ state = {} } = {}) => { + store = new Vuex.Store({ + modules: { + testReports: { + namespaced: true, + state: { + isLoading: false, + selectedSuiteIndex: null, + testReports, + ...state, + }, + actions: actionSpies, + getters, + }, + }, + }); + + jest.spyOn(store, 'registerModule').mockReturnValue(null); + + wrapper = extendedWrapper( + shallowMount(TestReports, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, + store, + }), + ); + }; + + describe('when component is created', () => { + it('should call fetchSummary when pipeline has test report', () => { + createComponent(); + + expect(actionSpies.fetchSummary).toHaveBeenCalled(); + }); + }); + + describe('when loading', () => { + beforeEach(() => createComponent({ state: { isLoading: true } })); + + it('shows the loading spinner', () => { + expect(emptyState().exists()).toBe(false); + expect(testsDetail().exists()).toBe(false); + expect(loadingSpinner().exists()).toBe(true); + }); + }); + + describe('when the api returns no data', () => { + it('displays empty state component', () => { + createComponent({ state: { testReports: {} } }); + + expect(emptyState().exists()).toBe(true); + }); + }); + + describe('when the api returns data', () => { + beforeEach(() => createComponent()); + + it('sets testReports and shows tests', () => { + expect(wrapper.vm.testReports).toEqual(expect.any(Object)); + expect(wrapper.vm.showTests).toBe(true); + }); + + it('shows tests details', () => { + expect(testsDetail().exists()).toBe(true); + }); + }); + + describe('when a suite is clicked', () => { + beforeEach(() => { + createComponent({ state: { hasFullReport: true } }); + testSummaryTable().vm.$emit('row-click', 0); + }); + + it('should call setSelectedSuiteIndex and fetchTestSuite', () => { + expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); + expect(actionSpies.fetchTestSuite).toHaveBeenCalled(); + }); + }); + + describe('when clicking back to summary', () => { + beforeEach(() => { + createComponent({ state: { selectedSuiteIndex: 0 } }); + testSummary().vm.$emit('on-back-click'); + }); + + it('should call removeSelectedSuiteIndex', () => { + expect(actionSpies.removeSelectedSuiteIndex).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js new file mode 100644 index 00000000000..5bdea6bbcbf --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js @@ -0,0 +1,169 @@ +import { GlButton, GlFriendlyWrap, GlLink, GlPagination, GlEmptyState } from '@gitlab/ui'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SuiteTable, { i18n } from '~/ci/pipeline_details/test_reports/test_suite_table.vue'; +import { TestStatus } from '~/ci/pipeline_details/constants'; +import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; +import { formatFilePath } from '~/ci/pipeline_details/stores/test_reports/utils'; +import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from '~/ci/pipeline_details/stores/test_reports/constants'; +import skippedTestCases from './mock_data'; + +Vue.use(Vuex); + +describe('Test reports suite table', () => { + let wrapper; + let store; + + const { + test_suites: [testSuite], + } = testReports; + + testSuite.test_cases = [...testSuite.test_cases, ...skippedTestCases]; + const testCases = testSuite.test_cases; + const blobPath = '/test/blob/path'; + + const noCasesMessage = () => wrapper.findByTestId('no-test-cases'); + const artifactsExpiredMessage = () => wrapper.findByTestId('artifacts-expired'); + const artifactsExpiredEmptyState = () => wrapper.findComponent(GlEmptyState); + const allCaseRows = () => wrapper.findAllByTestId('test-case-row'); + const findCaseRowAtIndex = (index) => wrapper.findAllByTestId('test-case-row').at(index); + const findLinkForRow = (row) => row.findComponent(GlLink); + const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); + + const createComponent = ({ suite = testSuite, perPage = 20, errorMessage } = {}) => { + store = new Vuex.Store({ + modules: { + testReports: { + namespaced: true, + state: { + blobPath, + testReports: { + test_suites: [suite], + }, + selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage, + }, + errorMessage, + }, + getters, + }, + }, + }); + + wrapper = shallowMountExtended(SuiteTable, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, + store, + stubs: { GlFriendlyWrap }, + }); + }; + + it('should render a message when there are no test cases', () => { + createComponent({ suite: [] }); + + expect(noCasesMessage().exists()).toBe(true); + expect(artifactsExpiredMessage().exists()).toBe(false); + }); + + it('should render an empty state when artifacts have expired', () => { + createComponent({ suite: [], errorMessage: ARTIFACTS_EXPIRED_ERROR_MESSAGE }); + const emptyState = artifactsExpiredEmptyState(); + + expect(noCasesMessage().exists()).toBe(false); + expect(artifactsExpiredMessage().exists()).toBe(true); + + expect(emptyState.exists()).toBe(true); + expect(emptyState.props('title')).toBe(i18n.expiredArtifactsTitle); + }); + + describe('when a test suite is supplied', () => { + beforeEach(() => createComponent()); + + it('renders the correct number of rows', () => { + expect(allCaseRows()).toHaveLength(testCases.length); + }); + + it.each([ + TestStatus.ERROR, + TestStatus.FAILED, + TestStatus.SKIPPED, + TestStatus.SUCCESS, + 'unknown', + ])('renders the correct icon for test case with %s status', (status) => { + const test = testCases.findIndex((x) => x.status === status); + const row = findCaseRowAtIndex(test); + + expect(findIconForRow(row, status).exists()).toBe(true); + }); + + it('renders the file name for the test with a copy button', () => { + const { file } = testCases[0]; + const relativeFile = formatFilePath(file); + const filePath = `${blobPath}/${relativeFile}`; + const row = findCaseRowAtIndex(0); + const fileLink = findLinkForRow(row); + const button = row.findComponent(GlButton); + + expect(fileLink.attributes('href')).toBe(filePath); + expect(row.text()).toContain(file); + expect(button.exists()).toBe(true); + 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.findComponent(GlPagination).exists()).toBe(true); + }); + }); + + describe('when a test case classname property is null', () => { + it('still renders all test cases', () => { + createComponent({ + testSuite: { + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + classname: null, + })), + }, + }); + + expect(allCaseRows()).toHaveLength(testCases.length); + }); + }); + + describe('when a test case name property is null', () => { + it('still renders all test cases', () => { + createComponent({ + testSuite: { + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + name: null, + })), + }, + }); + + expect(allCaseRows()).toHaveLength(testCases.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_summary_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_summary_spec.js new file mode 100644 index 00000000000..f9182d52c8a --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_summary_spec.js @@ -0,0 +1,106 @@ +import { mount } from '@vue/test-utils'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import Summary from '~/ci/pipeline_details/test_reports/test_summary.vue'; +import { formattedTime } from '~/ci/pipeline_details/stores/test_reports/utils'; + +describe('Test reports summary', () => { + let wrapper; + + const { + test_suites: [testSuite], + } = testReports; + + const backButton = () => wrapper.find('.js-back-button'); + const totalTests = () => wrapper.find('.js-total-tests'); + const failedTests = () => wrapper.find('.js-failed-tests'); + const erroredTests = () => wrapper.find('.js-errored-tests'); + const successRate = () => wrapper.find('.js-success-rate'); + const duration = () => wrapper.find('.js-duration'); + + const defaultProps = { + report: testSuite, + showBack: false, + }; + + const createComponent = (props) => { + wrapper = mount(Summary, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('should not render', () => { + beforeEach(() => { + createComponent(); + }); + + it('a back button by default', () => { + expect(backButton().exists()).toBe(false); + }); + }); + + describe('should render', () => { + beforeEach(() => { + createComponent(); + }); + + it('a back button and emit on-back-click event', () => { + createComponent({ + showBack: true, + }); + + expect(backButton().exists()).toBe(true); + }); + }); + + describe('when a report is supplied', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the correct total', () => { + expect(totalTests().text()).toBe('4 tests'); + }); + + it('displays the correct failure count', () => { + expect(failedTests().text()).toBe('2 failures'); + }); + + it('displays the correct error count', () => { + expect(erroredTests().text()).toBe('0 errors'); + }); + + it('calculates and displays percentages correctly', () => { + expect(successRate().text()).toBe('50% success rate'); + }); + + it('displays the correctly formatted duration', () => { + expect(duration().text()).toBe(formattedTime(testSuite.total_time)); + }); + }); + + describe('success percentage calculation', () => { + it.each` + name | successCount | totalCount | skippedCount | result + ${'displays 0 when there are no tests'} | ${0} | ${0} | ${0} | ${'0'} + ${'displays whole number when possible'} | ${10} | ${50} | ${0} | ${'20'} + ${'excludes skipped tests from total'} | ${10} | ${50} | ${5} | ${'22.22'} + ${'rounds to 0.01'} | ${1} | ${16604} | ${0} | ${'0.01'} + ${'correctly rounds to 50'} | ${8302} | ${16604} | ${0} | ${'50'} + ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${0} | ${'99.99'} + ${'correctly displays 100'} | ${16604} | ${16604} | ${0} | ${'100'} + `('$name', ({ successCount, totalCount, skippedCount, result }) => { + createComponent({ + report: { + success_count: successCount, + skipped_count: skippedCount, + total_count: totalCount, + }, + }); + + expect(successRate().text()).toBe(`${result}% success rate`); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_summary_table_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_summary_table_spec.js new file mode 100644 index 00000000000..bb62fbcb32c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_summary_table_spec.js @@ -0,0 +1,100 @@ +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import SummaryTable from '~/ci/pipeline_details/test_reports/test_summary_table.vue'; +import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; + +Vue.use(Vuex); + +describe('Test reports summary table', () => { + let wrapper; + let store; + + const allSuitesRows = () => wrapper.findAll('.js-suite-row'); + const noSuitesToShow = () => wrapper.find('.js-no-tests-suites'); + + const defaultProps = { + testReports, + }; + + const createComponent = (reports = null) => { + store = new Vuex.Store({ + modules: { + testReports: { + namespaced: true, + state: { + testReports: reports || testReports, + }, + getters, + }, + }, + }); + + wrapper = mount(SummaryTable, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, + propsData: defaultProps, + store, + }); + }; + + describe('when test reports are supplied', () => { + beforeEach(() => createComponent()); + const findErrorIcon = () => wrapper.findComponent({ ref: 'suiteErrorIcon' }); + + it('renders the correct number of rows', () => { + expect(noSuitesToShow().exists()).toBe(false); + expect(allSuitesRows().length).toBe(testReports.test_suites.length); + }); + + describe('when there is a suite error', () => { + beforeEach(() => { + createComponent({ + test_suites: [ + { + ...testReports.test_suites[0], + suite_error: 'Suite Error', + }, + ], + }); + }); + + it('renders error icon', () => { + expect(findErrorIcon().exists()).toBe(true); + expect(findErrorIcon().attributes('title')).toEqual('Suite Error'); + }); + }); + + describe('when there is not a suite error', () => { + beforeEach(() => { + createComponent({ + test_suites: [ + { + ...testReports.test_suites[0], + suite_error: null, + }, + ], + }); + }); + + it('does not render error icon', () => { + expect(findErrorIcon().exists()).toBe(false); + }); + }); + }); + + describe('when there are no test suites', () => { + beforeEach(() => { + createComponent({ test_suites: [] }); + }); + + it('displays the no suites to show message', () => { + expect(noSuitesToShow().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/utils/index_spec.js b/spec/frontend/ci/pipeline_details/utils/index_spec.js new file mode 100644 index 00000000000..61230cb52e6 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/utils/index_spec.js @@ -0,0 +1,201 @@ +import { + createJobsHash, + generateJobNeedsDict, + getPipelineDefaultTab, +} from '~/ci/pipeline_details/utils'; +import { validPipelineTabNames, pipelineTabName } from '~/ci/pipeline_details/constants'; + +describe('utils functions', () => { + const jobName1 = 'build_1'; + const jobName2 = 'build_2'; + const jobName3 = 'test_1'; + const jobName4 = 'deploy_1'; + 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 = { + stages: [ + { + name: userDefinedStage, + groups: [], + }, + { + name: job4.stage, + groups: [ + { + name: jobName4, + jobs: [{ ...job4 }], + }, + ], + }, + { + name: job1.stage, + groups: [ + { + name: jobName1, + jobs: [{ ...job1 }], + }, + { + name: jobName2, + jobs: [{ ...job2 }], + }, + ], + }, + { + name: job3.stage, + groups: [ + { + name: jobName3, + jobs: [{ ...job3 }], + }, + ], + }, + ], + }; + + describe('createJobsHash', () => { + it('returns an empty object if there are no jobs received as argument', () => { + expect(createJobsHash([])).toEqual({}); + }); + + it('returns a hash with the jobname as key and all its data as value', () => { + const jobs = { + [jobName1]: { jobs: [job1], name: jobName1, needs: [] }, + [jobName2]: { jobs: [job2], name: jobName2, needs: [] }, + [jobName3]: { jobs: [job3], name: jobName3, needs: job3.needs }, + [jobName4]: { jobs: [job4], name: jobName4, needs: job4.needs }, + }; + + expect(createJobsHash(pipelineGraphData.stages)).toEqual(jobs); + }); + }); + + describe('generateJobNeedsDict', () => { + it('generates an empty object if it receives no jobs', () => { + expect(generateJobNeedsDict({})).toEqual({}); + }); + + it('generates a dict with empty needs if there are no dependencies', () => { + const smallGraph = { + [jobName1]: job1, + [jobName2]: job2, + }; + + expect(generateJobNeedsDict(smallGraph)).toEqual({ + [jobName1]: [], + [jobName2]: [], + }); + }); + + it('generates a dict where key is the a job and its value is an array of all its needs', () => { + const jobsWithNeeds = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: job4, + }; + + expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ + [jobName1]: [], + [jobName2]: [], + [jobName3]: [jobName1, jobName2], + [jobName4]: [jobName3, jobName1, jobName2], + }); + }); + + it('removes needs which are not in the data', () => { + const inexistantJobName = 'job5'; + const jobsWithNeeds = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: { + name: jobName4, + script: 'echo deploy', + stage: 'deploy', + needs: [inexistantJobName], + }, + }; + + expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ + [jobName1]: [], + [jobName2]: [], + [jobName3]: [jobName1, jobName2], + [jobName4]: [], + }); + }); + + it('handles parallel jobs by adding the group name as a need', () => { + const size = 3; + const jobOptimize1 = 'optimize_1'; + const jobPrepareA = 'prepare_a'; + const jobPrepareA1 = `${jobPrepareA} 1/${size}`; + const jobPrepareA2 = `${jobPrepareA} 2/${size}`; + const jobPrepareA3 = `${jobPrepareA} 3/${size}`; + + const jobsParallel = { + [jobOptimize1]: { + jobs: [job1], + name: [jobOptimize1], + needs: [jobPrepareA1, jobPrepareA2, jobPrepareA3], + }, + [jobPrepareA]: { jobs: [], name: jobPrepareA, needs: [], size }, + [jobPrepareA1]: { jobs: [], name: jobPrepareA, needs: [], size }, + [jobPrepareA2]: { jobs: [], name: jobPrepareA, needs: [], size }, + [jobPrepareA3]: { jobs: [], name: jobPrepareA, needs: [], size }, + }; + + expect(generateJobNeedsDict(jobsParallel)).toEqual({ + [jobOptimize1]: [ + jobPrepareA1, + // This is the important part, the `jobPrepareA` group name has been + // added to our list of needs. + jobPrepareA, + jobPrepareA2, + jobPrepareA3, + ], + [jobPrepareA]: [], + [jobPrepareA1]: [], + [jobPrepareA2]: [], + [jobPrepareA3]: [], + }); + }); + }); + + describe('getPipelineDefaultTab', () => { + const baseUrl = 'http://gitlab.com/user/multi-projects-small/-/pipelines/332/'; + it('returns pipeline tab name if there is only the base url', () => { + expect(getPipelineDefaultTab(baseUrl)).toBe(pipelineTabName); + }); + + it('returns null if there was no valid last url part', () => { + expect(getPipelineDefaultTab(`${baseUrl}something`)).toBe(null); + }); + + it('returns the correct tab name if present', () => { + validPipelineTabNames.forEach((tabName) => { + expect(getPipelineDefaultTab(`${baseUrl}${tabName}`)).toBe(tabName); + }); + }); + + it('returns the right value even with query params', () => { + const [tabName] = validPipelineTabNames; + expect(getPipelineDefaultTab(`${baseUrl}${tabName}?query="something"&query2="else"`)).toBe( + tabName, + ); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/utils/parsing_utils_spec.js b/spec/frontend/ci/pipeline_details/utils/parsing_utils_spec.js new file mode 100644 index 00000000000..9390f076d3d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/utils/parsing_utils_spec.js @@ -0,0 +1,191 @@ +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import { createSankey } from '~/ci/pipeline_details/dag/utils/drawing_utils'; +import { + makeLinksFromNodes, + filterByAncestors, + generateColumnsFromLayersListBare, + keepLatestDownstreamPipelines, + listByLayers, + parseData, + removeOrphanNodes, + getMaxNodes, +} from '~/ci/pipeline_details/utils/parsing_utils'; +import { createNodeDict } from '~/ci/pipeline_details/utils'; + +import { mockDownstreamPipelinesRest } from '../../../vue_merge_request_widget/mock_data'; +import { mockDownstreamPipelinesGraphql } from '../../../commit/mock_data'; +import { mockParsedGraphQLNodes, missingJob } from '../dag/mock_data'; +import { generateResponse } from '../graph/mock_data'; + +describe('DAG visualization parsing utilities', () => { + const nodeDict = createNodeDict(mockParsedGraphQLNodes); + const unfilteredLinks = makeLinksFromNodes(mockParsedGraphQLNodes, nodeDict); + const parsed = parseData(mockParsedGraphQLNodes); + + describe('makeLinksFromNodes', () => { + it('returns the expected link structure', () => { + expect(unfilteredLinks[0]).toHaveProperty('source', 'build_a'); + expect(unfilteredLinks[0]).toHaveProperty('target', 'test_a'); + expect(unfilteredLinks[0]).toHaveProperty('value', 10); + }); + + it('does not generate a link for non-existing jobs', () => { + const sources = unfilteredLinks.map(({ source }) => source); + + expect(sources.includes(missingJob)).toBe(false); + }); + }); + + describe('filterByAncestors', () => { + const allLinks = [ + { source: 'job1', target: 'job4' }, + { source: 'job1', target: 'job2' }, + { source: 'job2', target: 'job4' }, + ]; + + const dedupedLinks = [ + { source: 'job1', target: 'job2' }, + { source: 'job2', target: 'job4' }, + ]; + + const nodeLookup = { + job1: { + name: 'job1', + }, + job2: { + name: 'job2', + needs: ['job1'], + }, + job4: { + name: 'job4', + needs: ['job1', 'job2'], + category: 'build', + }, + }; + + it('dedupes links', () => { + expect(filterByAncestors(allLinks, nodeLookup)).toMatchObject(dedupedLinks); + }); + }); + + describe('parseData parent function', () => { + it('returns an object containing a list of nodes and links', () => { + // an array of nodes exist and the values are defined + expect(parsed).toHaveProperty('nodes'); + expect(Array.isArray(parsed.nodes)).toBe(true); + expect(parsed.nodes.filter(Boolean)).not.toHaveLength(0); + + // an array of links exist and the values are defined + expect(parsed).toHaveProperty('links'); + expect(Array.isArray(parsed.links)).toBe(true); + expect(parsed.links.filter(Boolean)).not.toHaveLength(0); + }); + }); + + describe('removeOrphanNodes', () => { + it('removes sankey nodes that have no needs and are not needed', () => { + const layoutSettings = { + width: 200, + height: 200, + nodeWidth: 10, + nodePadding: 20, + paddingForLabels: 100, + }; + + const sankeyLayout = createSankey(layoutSettings)(parsed); + const cleanedNodes = removeOrphanNodes(sankeyLayout.nodes); + /* + These lengths are determined by the mock data. + If the data changes, the numbers may also change. + */ + expect(parsed.nodes).toHaveLength(mockParsedGraphQLNodes.length); + expect(cleanedNodes).toHaveLength(12); + }); + }); + + describe('getMaxNodes', () => { + it('returns the number of nodes in the most populous generation', () => { + const layerNodes = [ + { layer: 0 }, + { layer: 0 }, + { layer: 1 }, + { layer: 1 }, + { layer: 0 }, + { layer: 3 }, + { layer: 2 }, + { layer: 4 }, + { layer: 1 }, + { layer: 3 }, + { layer: 4 }, + ]; + expect(getMaxNodes(layerNodes)).toBe(3); + }); + }); + + describe('generateColumnsFromLayersList', () => { + const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); + const { pipelineLayers } = listByLayers(pipeline); + const columns = generateColumnsFromLayersListBare(pipeline, pipelineLayers); + + it('returns stage-like objects with default name, id, and status', () => { + columns.forEach((col, idx) => { + expect(col).toMatchObject({ + name: '', + status: { action: null }, + id: `layer-${idx}`, + }); + }); + }); + + it('creates groups that match the list created in listByLayers', () => { + columns.forEach((col, idx) => { + const groupNames = col.groups.map(({ name }) => name); + expect(groupNames).toEqual(pipelineLayers[idx]); + }); + }); + + it('looks up the correct group object', () => { + columns.forEach((col) => { + col.groups.forEach((group) => { + const groupStage = pipeline.stages.find((el) => el.name === group.stageName); + const groupObject = groupStage.groups.find((el) => el.name === group.name); + expect(group).toBe(groupObject); + }); + }); + }); + }); +}); + +describe('linked pipeline utilities', () => { + describe('keepLatestDownstreamPipelines', () => { + it('filters data from GraphQL', () => { + const downstream = mockDownstreamPipelinesGraphql().nodes; + const latestDownstream = keepLatestDownstreamPipelines(downstream); + + expect(downstream).toHaveLength(3); + expect(latestDownstream).toHaveLength(1); + }); + + it('filters data from REST', () => { + const downstream = mockDownstreamPipelinesRest(); + const latestDownstream = keepLatestDownstreamPipelines(downstream); + + expect(downstream).toHaveLength(2); + expect(latestDownstream).toHaveLength(1); + }); + + it('returns downstream pipelines if sourceJob.retried is null', () => { + const downstream = mockDownstreamPipelinesGraphql({ includeSourceJobRetried: false }).nodes; + const latestDownstream = keepLatestDownstreamPipelines(downstream); + + expect(latestDownstream).toHaveLength(downstream.length); + }); + + it('returns downstream pipelines if source_job.retried is null', () => { + const downstream = mockDownstreamPipelinesRest({ includeSourceJobRetried: false }); + const latestDownstream = keepLatestDownstreamPipelines(downstream); + + expect(latestDownstream).toHaveLength(downstream.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/utils/unwrapping_utils_spec.js b/spec/frontend/ci/pipeline_details/utils/unwrapping_utils_spec.js new file mode 100644 index 00000000000..99ee2eff1e4 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/utils/unwrapping_utils_spec.js @@ -0,0 +1,127 @@ +import { + unwrapGroups, + unwrapNodesWithName, + unwrapStagesWithNeeds, +} from '~/ci/pipeline_details/utils/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('unwrapGroups', () => { + it('takes stages without nodes and returns the unwrapped groups', () => { + expect(unwrapGroups(stagesAndGroups)[0].node.groups).toEqual(groupsArray); + }); + + it('keeps other stage properties intact', () => { + expect(unwrapGroups(stagesAndGroups)[0].node).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); + }); + }); +}); |