diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-08 00:07:25 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-08 00:07:25 +0300 |
commit | 4e9110c3c5b218bb8e1b183b9570426d9bbb0670 (patch) | |
tree | cd6662bef14ad8d7d6c1f4ccfdf27b8b4210d9bc /spec/frontend/ci | |
parent | 1869c23b11aeda0f8183dd324ebadf59505846f0 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend/ci')
82 files changed, 19097 insertions, 0 deletions
diff --git a/spec/frontend/ci/common/private/job_links_layer_spec.js b/spec/frontend/ci/common/private/job_links_layer_spec.js new file mode 100644 index 00000000000..c2defc8d770 --- /dev/null +++ b/spec/frontend/ci/common/private/job_links_layer_spec.js @@ -0,0 +1,85 @@ +import { shallowMount } from '@vue/test-utils'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; + +import { generateResponse } from 'jest/ci/pipeline_details/graph/mock_data'; + +describe('links layer component', () => { + let wrapper; + + const findLinksInner = () => wrapper.findComponent(LinksInner); + + const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); + const containerId = `pipeline-links-container-${pipeline.id}`; + const slotContent = "<div>Ceci n'est pas un graphique</div>"; + + const defaultProps = { + containerId, + containerMeasurements: { width: 400, height: 400 }, + pipelineId: pipeline.id, + pipelineData: pipeline.stages, + showLinks: false, + }; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(LinksLayer, { + propsData: { + ...defaultProps, + ...props, + }, + slots: { + default: slotContent, + }, + stubs: { + 'links-inner': true, + }, + }); + }; + + describe('with show links off', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('does not render inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); + + describe('with show links on', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + }, + }); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('renders the inner links component', () => { + expect(findLinksInner().exists()).toBe(true); + }); + }); + + describe('with width or height measurement at 0', () => { + beforeEach(() => { + createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } }); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('does not render the inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); +}); 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..f48340153a1 --- /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/pipeline_details/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..cb2d8ad85d5 --- /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/pipeline_details/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..f27aa02c1cc --- /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 '~/jobs/components/table/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_list/components/nav_controls_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/nav_controls_spec.js new file mode 100644 index 00000000000..cefe0c9f0a3 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/nav_controls_spec.js @@ -0,0 +1,80 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import NavControls from '~/ci/pipeline_details/pipelines_list/components/nav_controls.vue'; + +describe('Pipelines Nav Controls', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMountExtended(NavControls, { + propsData: { + ...props, + }, + }); + }; + + const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); + const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); + const findClearCacheButton = () => wrapper.findByTestId('clear-cache-button'); + + it('should render link to create a new pipeline', () => { + const mockData = { + newPipelinePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + + const runPipelineButton = findRunPipelineButton(); + expect(runPipelineButton.text()).toContain('Run pipeline'); + expect(runPipelineButton.attributes('href')).toBe(mockData.newPipelinePath); + }); + + it('should not render link to create pipeline if no path is provided', () => { + const mockData = { + helpPagePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + + expect(findRunPipelineButton().exists()).toBe(false); + }); + + it('should render link for CI lint', () => { + const mockData = { + newPipelinePath: 'foo', + helpPagePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + const ciLintButton = findCiLintButton(); + + expect(ciLintButton.text()).toContain('CI lint'); + expect(ciLintButton.attributes('href')).toBe(mockData.ciLintPath); + }); + + describe('Reset Runners Cache', () => { + beforeEach(() => { + const mockData = { + newPipelinePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + createComponent(mockData); + }); + + it('should render button for resetting runner caches', () => { + expect(findClearCacheButton().text()).toContain('Clear runner caches'); + }); + + it('should emit postAction event when reset runner cache button is clicked', () => { + findClearCacheButton().vm.$emit('click'); + + expect(wrapper.emitted('resetRunnersCache')).toEqual([['foo']]); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_labels_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_labels_spec.js new file mode 100644 index 00000000000..87c2867b5d8 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_labels_spec.js @@ -0,0 +1,164 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { trimText } from 'helpers/text_helper'; +import PipelineLabelsComponent from '~/ci/pipeline_details/pipelines_list/components/pipeline_labels.vue'; +import { mockPipeline } from '../../mock_data'; + +const projectPath = 'test/test'; + +describe('Pipeline label component', () => { + let wrapper; + + const findScheduledTag = () => wrapper.findByTestId('pipeline-url-scheduled'); + const findLatestTag = () => wrapper.findByTestId('pipeline-url-latest'); + const findYamlTag = () => wrapper.findByTestId('pipeline-url-yaml'); + const findStuckTag = () => wrapper.findByTestId('pipeline-url-stuck'); + const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops'); + const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link'); + const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached'); + const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure'); + const findForkTag = () => wrapper.findByTestId('pipeline-url-fork'); + const findTrainTag = () => wrapper.findByTestId('pipeline-url-train'); + + const defaultProps = mockPipeline(projectPath); + + const createComponent = (props) => { + wrapper = shallowMountExtended(PipelineLabelsComponent, { + propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: projectPath, + }, + }); + }; + + it('should not render tags when flags are not set', () => { + createComponent(); + + expect(findStuckTag().exists()).toBe(false); + expect(findLatestTag().exists()).toBe(false); + expect(findYamlTag().exists()).toBe(false); + expect(findAutoDevopsTag().exists()).toBe(false); + expect(findFailureTag().exists()).toBe(false); + expect(findScheduledTag().exists()).toBe(false); + expect(findForkTag().exists()).toBe(false); + expect(findTrainTag().exists()).toBe(false); + }); + + it('should render the stuck tag when flag is provided', () => { + const stuckPipeline = defaultProps.pipeline; + stuckPipeline.flags.stuck = true; + + createComponent({ + ...stuckPipeline.pipeline, + }); + + expect(findStuckTag().text()).toContain('stuck'); + }); + + it('should render latest tag when flag is provided', () => { + const latestPipeline = defaultProps.pipeline; + latestPipeline.flags.latest = true; + + createComponent({ + ...latestPipeline, + }); + + expect(findLatestTag().text()).toContain('latest'); + }); + + it('should render a yaml badge when it is invalid', () => { + const yamlPipeline = defaultProps.pipeline; + yamlPipeline.flags.yaml_errors = true; + + createComponent({ + ...yamlPipeline, + }); + + expect(findYamlTag().text()).toContain('yaml invalid'); + }); + + it('should render an autodevops badge when flag is provided', () => { + const autoDevopsPipeline = defaultProps.pipeline; + autoDevopsPipeline.flags.auto_devops = true; + + createComponent({ + ...autoDevopsPipeline, + }); + + expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps'); + + expect(findAutoDevopsTagLink().attributes()).toMatchObject({ + href: '/help/topics/autodevops/index.md', + target: '_blank', + }); + }); + + it('should render a detached badge when flag is provided', () => { + const detachedMRPipeline = defaultProps.pipeline; + detachedMRPipeline.flags.detached_merge_request_pipeline = true; + + createComponent({ + ...detachedMRPipeline, + }); + + expect(findDetachedTag().text()).toBe('merge request'); + }); + + it('should render error badge when pipeline has a failure reason set', () => { + const failedPipeline = defaultProps.pipeline; + failedPipeline.flags.failure_reason = true; + failedPipeline.failure_reason = 'some reason'; + + createComponent({ + ...failedPipeline, + }); + + expect(findFailureTag().text()).toContain('error'); + expect(findFailureTag().attributes('title')).toContain('some reason'); + }); + + it('should render scheduled badge when pipeline was triggered by a schedule', () => { + const scheduledPipeline = defaultProps.pipeline; + scheduledPipeline.source = 'schedule'; + + createComponent({ + ...scheduledPipeline, + }); + + expect(findScheduledTag().exists()).toBe(true); + expect(findScheduledTag().text()).toContain('Scheduled'); + }); + + it('should render the fork badge when the pipeline was run in a fork', () => { + const forkedPipeline = defaultProps.pipeline; + forkedPipeline.project.full_path = '/test/forked'; + + createComponent({ + ...forkedPipeline, + }); + + expect(findForkTag().exists()).toBe(true); + expect(findForkTag().text()).toBe('fork'); + }); + + it('should render the train badge when the pipeline is a merge train pipeline', () => { + const mergeTrainPipeline = defaultProps.pipeline; + mergeTrainPipeline.flags.merge_train_pipeline = true; + + createComponent({ + ...mergeTrainPipeline, + }); + + expect(findTrainTag().text()).toBe('merge train'); + }); + + it('should not render the train badge when the pipeline is not a merge train pipeline', () => { + const mergeTrainPipeline = defaultProps.pipeline; + mergeTrainPipeline.flags.merge_train_pipeline = false; + + createComponent({ + ...mergeTrainPipeline, + }); + + expect(findTrainTag().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions_spec.js new file mode 100644 index 00000000000..6827aea7b1c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions_spec.js @@ -0,0 +1,288 @@ +import { nextTick } from 'vue'; +import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import PipelineMultiActions, { + i18n, +} from '~/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions.vue'; +import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants'; + +describe('Pipeline Multi Actions Dropdown', () => { + let wrapper; + let mockAxios; + const focusInputMock = jest.fn(); + + const artifacts = [ + { + name: 'job my-artifact', + path: '/download/path', + }, + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]; + const newArtifacts = [ + { + name: 'job-3 my-new-artifact', + path: '/new/download/path', + }, + { + name: 'job-4 my-new-artifact-2', + path: '/new/download/path-two', + }, + { + name: 'job-5 my-new-artifact-3', + path: '/new/download/path-three', + }, + ]; + const artifactItemTestId = 'artifact-item'; + const artifactsEndpointPlaceholder = ':pipeline_artifacts_id'; + const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`; + const pipelineId = 108; + + const createComponent = () => { + wrapper = extendedWrapper( + shallowMount(PipelineMultiActions, { + provide: { + artifactsEndpoint, + artifactsEndpointPlaceholder, + }, + propsData: { + pipelineId, + }, + stubs: { + GlSprintf, + GlDropdown, + GlSearchBoxByType: stubComponent(GlSearchBoxByType, { + methods: { focusInput: focusInputMock }, + }), + }, + }), + ); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId); + const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); + const findAllArtifactItemsData = () => + wrapper.findAllByTestId(artifactItemTestId).wrappers.map((x) => ({ + path: x.attributes('href'), + name: x.text(), + })); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message'); + const findWarning = () => wrapper.findByTestId('artifacts-fetch-warning'); + const changePipelineId = (newId) => wrapper.setProps({ pipelineId: newId }); + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('should render the dropdown', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(true); + }); + + describe('Artifacts', () => { + const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); + + describe('while loading artifacts', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); + }); + + it('should render a loading spinner and no empty message', async () => { + createComponent(); + + findDropdown().vm.$emit('show'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findEmptyMessage().exists()).toBe(false); + }); + }); + + describe('artifacts loaded successfully', () => { + describe('artifacts exist', () => { + beforeEach(async () => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); + + createComponent(); + + findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('should fetch artifacts and show search box on dropdown click', () => { + expect(mockAxios.history.get).toHaveLength(1); + expect(findSearchBox().exists()).toBe(true); + }); + + it('should focus the search box when opened with artifacts', () => { + findDropdown().vm.$emit('shown'); + + expect(focusInputMock).toHaveBeenCalled(); + }); + + it('should render all the provided artifacts when search query is empty', () => { + findSearchBox().vm.$emit('input', ''); + + expect(findAllArtifactItems()).toHaveLength(artifacts.length); + expect(findEmptyMessage().exists()).toBe(false); + }); + + it('should render filtered artifacts when search query is not empty', async () => { + findSearchBox().vm.$emit('input', 'job-2'); + await waitForPromises(); + + expect(findAllArtifactItems()).toHaveLength(1); + expect(findEmptyMessage().exists()).toBe(false); + }); + + it('should render the correct artifact name and path', () => { + expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); + expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); + }); + + describe('when opened again with new artifacts', () => { + describe('with a successful refetch', () => { + beforeEach(async () => { + mockAxios.resetHistory(); + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: newArtifacts }); + + findDropdown().vm.$emit('show'); + await nextTick(); + }); + + it('should hide list and render a loading spinner on dropdown click', () => { + expect(findAllArtifactItems()).toHaveLength(0); + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('should not render warning or empty message while loading', () => { + expect(findEmptyMessage().exists()).toBe(false); + expect(findWarning().exists()).toBe(false); + }); + + it('should render the correct new list', async () => { + await waitForPromises(); + + expect(findAllArtifactItemsData()).toEqual(newArtifacts); + }); + }); + + describe('with a failing refetch', () => { + beforeEach(async () => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('should render warning', () => { + expect(findWarning().text()).toBe(i18n.artifactsFetchWarningMessage); + }); + + it('should render old list', () => { + expect(findAllArtifactItemsData()).toEqual(artifacts); + }); + }); + }); + + describe('pipeline id has changed', () => { + const newEndpoint = artifactsEndpoint.replace( + artifactsEndpointPlaceholder, + pipelineId + 1, + ); + + beforeEach(() => { + changePipelineId(pipelineId + 1); + }); + + describe('followed by a failing request', () => { + beforeEach(async () => { + mockAxios.onGet(newEndpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('should render error message and no warning', () => { + expect(findWarning().exists()).toBe(false); + expect(findAlert().text()).toBe(i18n.artifactsFetchErrorMessage); + }); + + it('should clear list', () => { + expect(findAllArtifactItems()).toHaveLength(0); + }); + }); + }); + }); + + describe('artifacts list is empty', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: [] }); + }); + + it('should render empty message and no search box when no artifacts are found', async () => { + createComponent(); + + findDropdown().vm.$emit('show'); + await waitForPromises(); + + expect(findEmptyMessage().exists()).toBe(true); + expect(findSearchBox().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + }); + + describe('with a failing request', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + }); + + it('should render an error message', async () => { + createComponent(); + findDropdown().vm.$emit('show'); + await waitForPromises(); + + const error = findAlert(); + expect(error.exists()).toBe(true); + expect(error.text()).toBe(i18n.artifactsFetchErrorMessage); + }); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks artifacts dropdown click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(); + + findDropdown().vm.$emit('show'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_artifacts_dropdown', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_operations_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_operations_spec.js new file mode 100644 index 00000000000..3e2005236bb --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_operations_spec.js @@ -0,0 +1,77 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelinesManualActions from '~/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions.vue'; +import PipelineMultiActions from '~/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions.vue'; +import PipelineOperations from '~/ci/pipeline_details/pipelines_list/components/pipeline_operations.vue'; +import eventHub from '~/ci/pipeline_details/event_hub'; + +describe('Pipeline operations', () => { + let wrapper; + + const defaultProps = { + pipeline: { + id: 329, + iid: 234, + details: { + has_manual_actions: true, + has_scheduled_actions: false, + }, + flags: { + retryable: true, + cancelable: true, + }, + cancel_path: '/root/ci-project/-/pipelines/329/cancel', + retry_path: '/root/ci-project/-/pipelines/329/retry', + }, + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineOperations, { + propsData: { + ...props, + }, + }); + }; + + const findManualActions = () => wrapper.findComponent(PipelinesManualActions); + const findMultiActions = () => wrapper.findComponent(PipelineMultiActions); + const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); + const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + + it('should display pipeline manual actions', () => { + createComponent(); + + expect(findManualActions().exists()).toBe(true); + }); + + it('should display pipeline multi actions', () => { + createComponent(); + + expect(findMultiActions().exists()).toBe(true); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('should emit retryPipeline event', () => { + findRetryBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'retryPipeline', + defaultProps.pipeline.retry_path, + ); + }); + + it('should emit openConfirmationModal event', () => { + findCancelBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', { + pipeline: defaultProps.pipeline, + endpoint: defaultProps.pipeline.cancel_path, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal_spec.js new file mode 100644 index 00000000000..81fed11875d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import { mockPipelineHeader } from 'jest/ci/pipeline_details/mock_data'; +import PipelineStopModal from '~/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal.vue'; + +describe('PipelineStopModal', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineStopModal, { + propsData: { + pipeline: mockPipelineHeader, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should render "stop pipeline" warning', () => { + expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_triggerer_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_triggerer_spec.js new file mode 100644 index 00000000000..4c8a43598ad --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_triggerer_spec.js @@ -0,0 +1,76 @@ +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import pipelineTriggerer from '~/ci/pipeline_details/pipelines_list/components/pipeline_triggerer.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('Pipelines Triggerer', () => { + let wrapper; + + const mockData = { + pipeline: { + user: { + name: 'foo', + avatar_url: '/avatar', + path: '/path', + }, + }, + }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(pipelineTriggerer, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findTriggerer = () => wrapper.findByText('API'); + + describe('when user was a triggerer', () => { + beforeEach(() => { + createComponent(mockData); + }); + + it('should render pipeline triggerer table cell', () => { + expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); + }); + + it('should render only user avatar', () => { + expect(findAvatarLink().exists()).toBe(true); + expect(findTriggerer().exists()).toBe(false); + }); + + it('should set correct props on avatar link component', () => { + expect(findAvatarLink().attributes()).toMatchObject({ + title: mockData.pipeline.user.name, + href: mockData.pipeline.user.path, + }); + }); + + it('should add tooltip to avatar link', () => { + const tooltip = getBinding(findAvatarLink().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + }); + + it('should set correct props on avatar component', () => { + expect(findAvatar().attributes().src).toBe(mockData.pipeline.user.avatar_url); + }); + }); + + describe('when API was a triggerer', () => { + beforeEach(() => { + createComponent({ pipeline: {} }); + }); + + it('should render label only', () => { + expect(findAvatarLink().exists()).toBe(false); + expect(findTriggerer().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_url_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_url_spec.js new file mode 100644 index 00000000000..78097edecd3 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_url_spec.js @@ -0,0 +1,184 @@ +import { merge } from 'lodash'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineUrlComponent from '~/ci/pipeline_details/pipelines_list/components/pipeline_url.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants'; +import { mockPipeline, mockPipelineBranch, mockPipelineTag } from '../../mock_data'; + +const projectPath = 'test/test'; + +describe('Pipeline Url Component', () => { + let wrapper; + let trackingSpy; + + const findTableCell = () => wrapper.findByTestId('pipeline-url-table-cell'); + const findPipelineUrlLink = () => wrapper.findByTestId('pipeline-url-link'); + const findRefName = () => wrapper.findByTestId('merge-request-ref'); + const findCommitShortSha = () => wrapper.findByTestId('commit-short-sha'); + const findCommitIcon = () => wrapper.findByTestId('commit-icon'); + const findCommitIconType = () => wrapper.findByTestId('commit-icon-type'); + const findCommitRefName = () => wrapper.findByTestId('commit-ref-name'); + + const findCommitTitleContainer = () => wrapper.findByTestId('commit-title-container'); + const findPipelineNameContainer = () => wrapper.findByTestId('pipeline-name-container'); + const findCommitTitle = (commitWrapper) => commitWrapper.find('[data-testid="commit-title"]'); + + const defaultProps = { ...mockPipeline(projectPath), refClass: 'gl-text-black' }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(PipelineUrlComponent, { + propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: projectPath, + }, + }); + }; + + it('should render pipeline url table cell', () => { + createComponent(); + + expect(findTableCell().exists()).toBe(true); + }); + + it('should render a link the provided path and id', () => { + createComponent(); + + expect(findPipelineUrlLink().attributes('href')).toBe('foo'); + + expect(findPipelineUrlLink().text()).toBe('#1'); + }); + + it('should render the pipeline name instead of commit title', () => { + createComponent(merge(mockPipeline(projectPath), { pipeline: { name: 'Build pipeline' } })); + + expect(findCommitTitleContainer().exists()).toBe(false); + expect(findPipelineNameContainer().exists()).toBe(true); + expect(findRefName().exists()).toBe(true); + expect(findCommitShortSha().exists()).toBe(true); + }); + + it('should render the commit title when pipeline has no name', () => { + createComponent(); + + const commitWrapper = findCommitTitleContainer(); + + expect(findCommitTitle(commitWrapper).exists()).toBe(true); + expect(findRefName().exists()).toBe(true); + expect(findCommitShortSha().exists()).toBe(true); + expect(findPipelineNameContainer().exists()).toBe(false); + }); + + it('should pass the refClass prop to merge request link', () => { + createComponent(); + + expect(findRefName().classes()).toContain(defaultProps.refClass); + }); + + it('should pass the refClass prop to the commit ref name link', () => { + createComponent(mockPipelineBranch()); + + expect(findCommitRefName().classes()).toContain(defaultProps.refClass); + }); + + describe('commit user avatar', () => { + it('renders when commit author exists', () => { + const pipelineBranch = mockPipelineBranch(); + const { avatar_url: imgSrc, name, path } = pipelineBranch.pipeline.commit.author; + createComponent(pipelineBranch); + + const component = wrapper.findComponent(UserAvatarLink); + expect(component.exists()).toBe(true); + expect(component.props()).toMatchObject({ + imgSize: 16, + imgSrc, + imgAlt: name, + linkHref: path, + tooltipText: name, + }); + }); + + it('does not render when commit author does not exist', () => { + createComponent(); + + expect(wrapper.findComponent(UserAvatarLink).exists()).toBe(false); + }); + }); + + it('should render commit icon tooltip', () => { + createComponent(); + + expect(findCommitIcon().attributes('title')).toBe('Commit'); + }); + + it.each` + pipeline | expectedTitle + ${mockPipelineTag()} | ${'Tag'} + ${mockPipelineBranch()} | ${'Branch'} + ${mockPipeline()} | ${'Merge Request'} + `('should render tooltip $expectedTitle for commit icon type', ({ pipeline, expectedTitle }) => { + createComponent(pipeline); + + expect(findCommitIconType().attributes('title')).toBe(expectedTitle); + }); + + describe('tracking', () => { + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks pipeline id click', () => { + createComponent(); + + findPipelineUrlLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_pipeline_id', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks merge request ref click', () => { + createComponent(); + + findRefName().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_mr_ref', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit ref name click', () => { + createComponent(mockPipelineBranch()); + + findCommitRefName().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_name', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit title click', () => { + createComponent(merge(mockPipelineBranch(), { pipeline: { name: null } })); + + findCommitTitle(findCommitTitleContainer()).vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_title', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit short sha click', () => { + createComponent(mockPipelineBranch()); + + findCommitShortSha().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_sha', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_artifacts_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_artifacts_spec.js new file mode 100644 index 00000000000..7ef3513cbce --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_artifacts_spec.js @@ -0,0 +1,64 @@ +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlSprintf, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineArtifacts from '~/ci/pipeline_details/pipelines_list/components/pipelines_artifacts.vue'; + +describe('Pipelines Artifacts dropdown', () => { + let wrapper; + + const artifacts = [ + { + name: 'job my-artifact', + path: '/download/path', + }, + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]; + const pipelineId = 108; + + const createComponent = ({ mockArtifacts = artifacts } = {}) => { + wrapper = shallowMount(PipelineArtifacts, { + propsData: { + pipelineId, + artifacts: mockArtifacts, + }, + stubs: { + GlSprintf, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + }, + }); + }; + + const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findFirstGlDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + + it('should render a dropdown with all the provided artifacts', () => { + createComponent(); + + const [{ items }] = findGlDropdown().props('items'); + expect(items).toHaveLength(artifacts.length); + }); + + it('should render a link with the provided path', () => { + createComponent(); + + expect(findFirstGlDropdownItem().props('item').href).toBe(artifacts[0].path); + expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name); + }); + + describe('with no artifacts', () => { + it('should not render the dropdown', () => { + createComponent({ mockArtifacts: [] }); + + expect(findGlDropdown().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search_spec.js new file mode 100644 index 00000000000..0b62920e01b --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search_spec.js @@ -0,0 +1,199 @@ +import { GlFilteredSearch } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import PipelinesFilteredSearch from '~/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search.vue'; +import { + FILTERED_SEARCH_TERM, + OPERATORS_IS, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants'; +import { users, mockSearch, branches, tags } from '../../mock_data'; + +describe('Pipelines filtered search', () => { + let wrapper; + let mock; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const getSearchToken = (type) => + findFilteredSearch() + .props('availableTokens') + .find((token) => token.type === type); + const findBranchToken = () => getSearchToken('ref'); + const findTagToken = () => getSearchToken('tag'); + const findUserToken = () => getSearchToken('username'); + const findStatusToken = () => getSearchToken('status'); + const findSourceToken = () => getSearchToken('source'); + + const createComponent = (params = {}) => { + wrapper = mount(PipelinesFilteredSearch, { + propsData: { + projectId: '21', + defaultBranchName: 'main', + params, + }, + attachTo: document.body, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); + + createComponent(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('displays UI elements', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('displays search tokens', () => { + expect(findUserToken()).toMatchObject({ + type: 'username', + icon: 'user', + title: 'Trigger author', + unique: true, + projectId: '21', + operators: OPERATORS_IS, + }); + + expect(findBranchToken()).toMatchObject({ + type: 'ref', + icon: 'branch', + title: 'Branch name', + unique: true, + projectId: '21', + defaultBranchName: 'main', + operators: OPERATORS_IS, + }); + + expect(findSourceToken()).toMatchObject({ + type: 'source', + icon: 'trigger-source', + title: 'Source', + unique: true, + operators: OPERATORS_IS, + }); + + expect(findStatusToken()).toMatchObject({ + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + operators: OPERATORS_IS, + }); + + expect(findTagToken()).toMatchObject({ + type: 'tag', + icon: 'tag', + title: 'Tag name', + unique: true, + operators: OPERATORS_IS, + }); + }); + + it('emits filterPipelines on submit with correct filter', () => { + findFilteredSearch().vm.$emit('submit', mockSearch); + + expect(wrapper.emitted('filterPipelines')).toHaveLength(1); + expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]); + }); + + it('disables tag name token when branch name token is active', async () => { + findFilteredSearch().vm.$emit('input', [ + { type: 'ref', value: { data: 'branch-1', operator: '=' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(true); + }); + + it('disables branch name token when tag name token is active', async () => { + findFilteredSearch().vm.$emit('input', [ + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(true); + expect(findTagToken().disabled).toBe(false); + }); + + it('resets tokens disabled state on clear', async () => { + findFilteredSearch().vm.$emit('clearInput'); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(false); + }); + + it('resets tokens disabled state when clearing tokens by backspace', async () => { + findFilteredSearch().vm.$emit('input', [{ type: FILTERED_SEARCH_TERM, value: { data: '' } }]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(false); + }); + + describe('Url query params', () => { + const params = { + username: 'deja.green', + ref: 'main', + }; + + beforeEach(() => { + createComponent(params); + }); + + it('sets default value if url query params', () => { + const expectedValueProp = [ + { + type: 'username', + value: { + data: params.username, + operator: '=', + }, + }, + { + type: 'ref', + value: { + data: params.ref, + operator: '=', + }, + }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]; + + expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp); + expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks filtered search click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findFilteredSearch().vm.$emit('submit', mockSearch); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filtered_search', { + label: TRACKING_CATEGORIES.search, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions_spec.js new file mode 100644 index 00000000000..c0ea0fda4df --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions_spec.js @@ -0,0 +1,216 @@ +import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineActionsQueryResponse from 'test_fixtures/graphql/pipelines/get_pipeline_actions.query.graphql.json'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import PipelinesManualActions from '~/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions.vue'; +import getPipelineActionsQuery from '~/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_actions.query.graphql'; +import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +describe('Pipeline manual actions', () => { + let wrapper; + let mock; + + const queryHandler = jest.fn().mockResolvedValue(mockPipelineActionsQueryResponse); + const { + data: { + project: { + pipeline: { + jobs: { nodes }, + }, + }, + }, + } = mockPipelineActionsQueryResponse; + + const mockPath = nodes[2].playPath; + + const createComponent = (limit = 50) => { + wrapper = shallowMountExtended(PipelinesManualActions, { + provide: { + fullPath: 'root/ci-project', + manualActionsLimit: limit, + }, + propsData: { + iid: 100, + }, + stubs: { + GlDropdown, + }, + apolloProvider: createMockApollo([[getPipelineActionsQuery, queryHandler]]), + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLimitMessage = () => wrapper.findByTestId('limit-reached-msg'); + + it('skips calling query on mount', () => { + createComponent(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + + describe('loading', () => { + beforeEach(() => { + createComponent(); + + findDropdown().vm.$emit('shown'); + }); + + it('display loading state while actions are being fetched', () => { + expect(findAllDropdownItems().at(0).text()).toBe('Loading...'); + expect(findLoadingIcon().exists()).toBe(true); + expect(findAllDropdownItems()).toHaveLength(1); + }); + }); + + describe('loaded', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + }); + + afterEach(() => { + mock.restore(); + confirmAction.mockReset(); + }); + + it('displays dropdown with the provided actions', () => { + expect(findAllDropdownItems()).toHaveLength(3); + }); + + it("displays a disabled action when it's not playable", () => { + expect(findAllDropdownItems().at(0).attributes('disabled')).toBeDefined(); + }); + + describe('on action click', () => { + it('makes a request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + }); + + it('makes a failed request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks manual actions click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('shown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); + + describe('scheduled jobs', () => { + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + }); + + it('makes post request after confirming', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(true); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(1); + }); + + it('does not make post request if confirmation is cancelled', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(false); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(0); + }); + + it('displays the remaining time in the dropdown', () => { + expect(findAllCountdowns().at(0).props('endDateString')).toBe(nodes[2].scheduledAt); + }); + }); + }); + + describe('limit message', () => { + it('limit message does not show', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(false); + }); + + it('limit message does show', async () => { + createComponent(3); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_table_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_table_spec.js new file mode 100644 index 00000000000..2ae8475f38d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_table_spec.js @@ -0,0 +1,280 @@ +import '~/commons'; +import { GlTableLite } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineFailedJobsWidget from '~/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; +import PipelineOperations from '~/ci/pipeline_details/pipelines_list/components/pipeline_operations.vue'; +import PipelineTriggerer from '~/ci/pipeline_details/pipelines_list/components/pipeline_triggerer.vue'; +import PipelineUrl from '~/ci/pipeline_details/pipelines_list/components/pipeline_url.vue'; +import PipelinesTable from '~/ci/pipeline_details/pipelines_list/components/pipelines_table.vue'; +import PipelinesTimeago from '~/ci/pipeline_details/pipelines_list/components/time_ago.vue'; +import { + PipelineKeyOptions, + BUTTON_TOOLTIP_RETRY, + BUTTON_TOOLTIP_CANCEL, + TRACKING_CATEGORIES, +} from '~/ci/pipeline_details/constants'; + +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; + +jest.mock('~/ci/pipeline_details/event_hub'); + +describe('Pipelines Table', () => { + let pipeline; + let wrapper; + let trackingSpy; + + const defaultProvide = { + glFeatures: {}, + withFailedJobsDetails: false, + }; + + const provideWithDetails = { + glFeatures: { + ciJobFailuresInMr: true, + }, + withFailedJobsDetails: true, + }; + + const defaultProps = { + pipelines: [], + viewType: 'root', + pipelineKeyOption: PipelineKeyOptions[0], + }; + + const createMockPipeline = () => { + // Clone fixture as it could be modified by tests + const { pipelines } = JSON.parse(JSON.stringify(fixture)); + return pipelines.find((p) => p.user !== null && p.commit !== null); + }; + + const createComponent = (props = {}, provide = {}) => { + wrapper = extendedWrapper( + mount(PipelinesTable, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: ['PipelineFailedJobsWidget'], + }), + ); + }; + + const findGlTableLite = () => wrapper.findComponent(GlTableLite); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); + const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); + const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); + const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); + const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago); + const findActions = () => wrapper.findComponent(PipelineOperations); + + const findPipelineFailureWidget = () => wrapper.findComponent(PipelineFailedJobsWidget); + const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); + const findStatusTh = () => wrapper.findByTestId('status-th'); + const findPipelineTh = () => wrapper.findByTestId('pipeline-th'); + const findStagesTh = () => wrapper.findByTestId('stages-th'); + const findActionsTh = () => wrapper.findByTestId('actions-th'); + const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); + const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + + beforeEach(() => { + pipeline = createMockPipeline(); + }); + + describe('Pipelines Table', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline], viewType: 'root' }); + }); + + it('displays table', () => { + expect(findGlTableLite().exists()).toBe(true); + }); + + it('should render table head with correct columns', () => { + expect(findStatusTh().text()).toBe('Status'); + expect(findPipelineTh().text()).toBe('Pipeline'); + expect(findStagesTh().text()).toBe('Stages'); + expect(findActionsTh().text()).toBe('Actions'); + }); + + it('should display a table row', () => { + expect(findTableRows()).toHaveLength(1); + }); + + describe('status cell', () => { + it('should render a status badge', () => { + expect(findCiBadgeLink().exists()).toBe(true); + }); + }); + + describe('pipeline cell', () => { + it('should render pipeline information', () => { + expect(findPipelineInfo().exists()).toBe(true); + }); + + it('should display the pipeline id', () => { + expect(findPipelineInfo().text()).toContain(`#${pipeline.id}`); + }); + }); + + describe('stages cell', () => { + it('should render pipeline mini graph', () => { + expect(findLegacyPipelineMiniGraph().exists()).toBe(true); + }); + + it('should render the right number of stages', () => { + const stagesLength = pipeline.details.stages.length; + expect(findLegacyPipelineMiniGraph().props('stages').length).toBe(stagesLength); + }); + + it('should render the latest downstream pipelines only', () => { + // component receives two downstream pipelines. one of them is already outdated + // because we retried the trigger job, so the mini pipeline graph will only + // render the newly created downstream pipeline instead + expect(pipeline.triggered).toHaveLength(2); + expect(findLegacyPipelineMiniGraph().props('downstreamPipelines')).toHaveLength(1); + }); + + describe('when pipeline does not have stages', () => { + beforeEach(() => { + pipeline = createMockPipeline(); + pipeline.details.stages = []; + + createComponent({ pipelines: [pipeline] }); + }); + + it('stages are not rendered', () => { + expect(findLegacyPipelineMiniGraph().props('stages')).toHaveLength(0); + }); + }); + }); + + describe('duration cell', () => { + it('should render duration information', () => { + expect(findTimeAgo().exists()).toBe(true); + }); + }); + + describe('operations cell', () => { + it('should render pipeline operations', () => { + expect(findActions().exists()).toBe(true); + }); + + it('should render retry action tooltip', () => { + expect(findRetryBtn().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); + }); + + it('should render cancel action tooltip', () => { + expect(findCancelBtn().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); + }); + }); + + describe('triggerer cell', () => { + it('should render the pipeline triggerer', () => { + expect(findTriggerer().exists()).toBe(true); + }); + }); + + describe('failed jobs details', () => { + describe('row', () => { + describe('when the FF is disabled', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline] }); + }); + + it('does not render', () => { + expect(findTableRows()).toHaveLength(1); + expect(findPipelineFailureWidget().exists()).toBe(false); + }); + }); + + describe('when the FF is enabled', () => { + describe('and `withFailedJobsDetails` value is provided', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline] }, provideWithDetails); + }); + + it('renders', () => { + expect(findTableRows()).toHaveLength(2); + expect(findPipelineFailureWidget().exists()).toBe(true); + }); + + it('passes the expected props', () => { + expect(findPipelineFailureWidget().props()).toStrictEqual({ + failedJobsCount: pipeline.failed_builds.length, + isPipelineActive: pipeline.active, + pipelineIid: pipeline.iid, + pipelinePath: pipeline.path, + // Make sure the forward slash was removed + projectPath: 'frontend-fixtures/pipelines-project', + }); + }); + }); + + describe('and `withFailedJobsDetails` value is not provided', () => { + beforeEach(() => { + createComponent( + { pipelines: [pipeline] }, + { glFeatures: { ciJobFailuresInMr: true } }, + ); + }); + + it('does not render', () => { + expect(findTableRows()).toHaveLength(1); + expect(findPipelineFailureWidget().exists()).toBe(false); + }); + }); + }); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks status badge click', () => { + findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks retry pipeline button click', () => { + findRetryBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry_button', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks cancel pipeline button click', () => { + findCancelBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_cancel_button', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks pipeline mini graph stage click', () => { + findLegacyPipelineMiniGraph().vm.$emit('miniGraphStageClick'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_minigraph', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/time_ago_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/components/time_ago_spec.js new file mode 100644 index 00000000000..e651427fb78 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/components/time_ago_spec.js @@ -0,0 +1,85 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TimeAgo from '~/ci/pipeline_details/pipelines_list/components/time_ago.vue'; + +describe('Timeago component', () => { + let wrapper; + + const defaultProps = { duration: 0, finished_at: '' }; + + const createComponent = (props = defaultProps, extraProps) => { + wrapper = extendedWrapper( + shallowMount(TimeAgo, { + propsData: { + pipeline: { + details: { + ...props, + }, + }, + ...extraProps, + }, + data() { + return { + iconTimerSvg: `<svg></svg>`, + }; + }, + }), + ); + }; + + const duration = () => wrapper.find('.duration'); + const finishedAt = () => wrapper.find('.finished-at'); + const findCalendarIcon = () => wrapper.findByTestId('calendar-icon'); + + describe('with duration', () => { + beforeEach(() => { + createComponent({ duration: 10, finished_at: '' }); + }); + + it('should render duration and timer svg', () => { + const icon = duration().findComponent(GlIcon); + + expect(duration().exists()).toBe(true); + expect(icon.props('name')).toBe('timer'); + }); + }); + + describe('without duration', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not render duration and timer svg', () => { + expect(duration().exists()).toBe(false); + }); + }); + + describe('with finishedTime', () => { + it('should render time', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); + + const time = finishedAt().find('time'); + + expect(finishedAt().exists()).toBe(true); + expect(time.exists()).toBe(true); + }); + + it('should display calendar icon', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); + + expect(findCalendarIcon().exists()).toBe(true); + }); + }); + + describe('without finishedTime', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not render time and calendar icon', () => { + expect(finishedAt().exists()).toBe(false); + expect(findCalendarIcon().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ci_templates_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ci_templates_spec.js new file mode 100644 index 00000000000..558063ecba5 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ci_templates_spec.js @@ -0,0 +1,107 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import CiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const suggestedCiTemplates = [ + { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, + { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, + { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, +]; + +describe('CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(CiTemplates, { + provide: { + pipelineEditorPath, + suggestedCiTemplates, + }, + propsData, + }); + }; + + const findTemplateDescription = () => wrapper.findByTestId('template-description'); + const findTemplateLink = () => wrapper.findByTestId('template-link'); + const findTemplateNames = () => wrapper.findAllByTestId('template-name'); + const findTemplateName = () => wrapper.findByTestId('template-name'); + const findTemplateLogo = () => wrapper.findByTestId('template-logo'); + + describe('renders template list', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders all suggested templates', () => { + expect(findTemplateNames().length).toBe(3); + expect(wrapper.text()).toContain('Android', 'Bash', 'C++'); + }); + + it('has the correct template name', () => { + expect(findTemplateName().text()).toBe('Android'); + }); + + it('links to the correct template', () => { + expect(findTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Android'), + ); + }); + + it('has the link button enabled', () => { + expect(findTemplateLink().props('disabled')).toBe(false); + }); + + it('has the description of the template', () => { + expect(findTemplateDescription().text()).toBe( + 'Continuous integration and deployment template to test and deploy your Android project.', + ); + }); + + it('has the right logo of the template', () => { + expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg'); + }); + }); + + describe('filtering the templates', () => { + beforeEach(() => { + createWrapper({ filterTemplates: ['Bash'] }); + }); + + it('renders only the filtered templates', () => { + expect(findTemplateNames()).toHaveLength(1); + expect(findTemplateName().text()).toBe('Bash'); + }); + }); + + describe('disabling the templates', () => { + beforeEach(() => { + createWrapper({ disabled: true }); + }); + + it('has the link button disabled', () => { + expect(findTemplateLink().props('disabled')).toBe(true); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when template is clicked', () => { + findTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Android', + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ios_templates_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ios_templates_spec.js new file mode 100644 index 00000000000..cdd3053d66a --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ios_templates_spec.js @@ -0,0 +1,133 @@ +import '~/commons'; +import { nextTick } from 'vue'; +import { GlPopover, GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import IosTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ios_templates.vue'; +import CiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const registrationToken = 'SECRET_TOKEN'; +const iOSTemplateName = 'iOS-Fastlane'; + +describe('iOS Templates', () => { + let wrapper; + + const createWrapper = (providedPropsData = {}) => { + return shallowMountExtended(IosTemplates, { + provide: { + pipelineEditorPath, + iosRunnersAvailable: true, + ...providedPropsData, + }, + propsData: { + registrationToken, + }, + stubs: { + GlButton, + }, + }); + }; + + const findIosTemplate = () => wrapper.findComponent(CiTemplates); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + const findRunnerInstructionsPopover = () => wrapper.findComponent(GlPopover); + const findRunnerSetupTodoEmoji = () => wrapper.findByTestId('runner-setup-marked-todo'); + const findRunnerSetupCompletedEmoji = () => wrapper.findByTestId('runner-setup-marked-completed'); + const findSetupRunnerLink = () => wrapper.findByText('Set up a runner'); + const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link'); + + describe('when ios runners are not available', () => { + beforeEach(() => { + wrapper = createWrapper({ iosRunnersAvailable: false }); + }); + + describe('the runner setup section', () => { + it('marks the section as todo', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(true); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(false); + }); + + it('renders the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(true); + }); + + it('renders the runner instructions modal with a popover once clicked', async () => { + findSetupRunnerLink().element.parentElement.click(); + + await nextTick(); + + expect(findRunnerInstructionsModal().exists()).toBe(true); + expect(findRunnerInstructionsModal().props('registrationToken')).toBe(registrationToken); + expect(findRunnerInstructionsModal().props('defaultPlatformName')).toBe('osx'); + + findRunnerInstructionsModal().vm.$emit('shown'); + + await nextTick(); + + expect(findRunnerInstructionsPopover().exists()).toBe(true); + }); + }); + + describe('the configure pipeline section', () => { + it('has a disabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(true); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has a disabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(true); + }); + }); + }); + + describe('when ios runners are available', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + describe('the runner setup section', () => { + it('marks the section as completed', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(false); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(true); + }); + + it('does not render the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(false); + }); + }); + + describe('the configure pipeline section', () => { + it('has an enabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has an enabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state_spec.js new file mode 100644 index 00000000000..6ef37f59f66 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state_spec.js @@ -0,0 +1,87 @@ +import '~/commons'; +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import { stubExperiments } from 'helpers/experimentation_helper'; +import EmptyState from '~/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state.vue'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import PipelinesCiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue'; +import IosTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ios_templates.vue'; + +describe('Pipelines Empty State', () => { + let wrapper; + + const findIllustration = () => wrapper.find('img'); + const findButton = () => wrapper.find('a'); + const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates); + const iosTemplates = () => wrapper.findComponent(IosTemplates); + + const createWrapper = (props = {}) => { + wrapper = shallowMount(EmptyState, { + provide: { + pipelineEditorPath: '', + suggestedCiTemplates: [], + anyRunnersAvailable: true, + ciRunnerSettingsPath: '', + }, + propsData: { + emptyStateSvgPath: 'foo.svg', + canSetCi: true, + ...props, + }, + stubs: { + GlEmptyState, + GitlabExperiment, + }, + }); + }; + + describe('when user can configure CI', () => { + describe('when the ios_specific_templates experiment is active', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'candidate' }); + createWrapper(); + }); + + it('should render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(true); + }); + + it('should not render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(false); + }); + }); + + describe('when the ios_specific_templates experiment is inactive', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'control' }); + createWrapper(); + }); + + it('should render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(true); + }); + + it('should not render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(false); + }); + }); + }); + + describe('when user cannot configure CI', () => { + beforeEach(() => { + createWrapper({ canSetCi: false }); + }); + + it('should render empty state SVG', () => { + expect(findIllustration().attributes('src')).toBe('foo.svg'); + }); + + it('should render empty state header', () => { + expect(wrapper.text()).toBe('This project is not currently set up to run pipelines.'); + }); + + it('should not render a link', () => { + expect(findButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates_spec.js new file mode 100644 index 00000000000..76b4cc163b2 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates_spec.js @@ -0,0 +1,58 @@ +import '~/commons'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import PipelinesCiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue'; +import CiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; + +describe('Pipelines CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = (propsData = {}, stubs = {}) => { + return shallowMountExtended(PipelinesCiTemplates, { + provide: { + pipelineEditorPath, + ...propsData, + }, + stubs, + }); + }; + + const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); + const findCiTemplates = () => wrapper.findComponent(CiTemplates); + + describe('templates', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders test template and Ci templates', () => { + expect(findTestTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Getting-Started'), + ); + expect(findCiTemplates().exists()).toBe(true); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + wrapper = createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when Getting-Started template is clicked', () => { + findTestTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Getting-Started', + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details_spec.js new file mode 100644 index 00000000000..cc68af4f7f3 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details_spec.js @@ -0,0 +1,254 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlIcon, GlLink } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import FailedJobDetails from '~/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details.vue'; +import RetryMrFailedJobMutation from '~/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql'; +import { BRIDGE_KIND } from '~/ci/pipeline_details/graph/constants'; +import { job } from './mock'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +const createFakeEvent = () => ({ stopPropagation: jest.fn() }); + +describe('FailedJobDetails component', () => { + let wrapper; + let mockRetryResponse; + + const retrySuccessResponse = { + data: { + jobRetry: { + errors: [], + }, + }, + }; + + const defaultProps = { + job, + }; + + const createComponent = ({ props = {} } = {}) => { + const handlers = [[RetryMrFailedJobMutation, mockRetryResponse]]; + + wrapper = shallowMountExtended(FailedJobDetails, { + propsData: { + ...defaultProps, + ...props, + }, + apolloProvider: createMockApollo(handlers), + }); + }; + + const findArrowIcon = () => wrapper.findComponent(GlIcon); + const findJobId = () => wrapper.findComponent(GlLink); + const findJobLog = () => wrapper.findByTestId('job-log'); + const findJobName = () => wrapper.findByText(defaultProps.job.name); + const findRetryButton = () => wrapper.findByLabelText('Retry'); + const findRow = () => wrapper.findByTestId('widget-row'); + const findStageName = () => wrapper.findByText(defaultProps.job.stage.name); + + beforeEach(() => { + mockRetryResponse = jest.fn(); + mockRetryResponse.mockResolvedValue(retrySuccessResponse); + }); + + describe('ui', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the job name', () => { + expect(findJobName().exists()).toBe(true); + }); + + it('renders the stage name', () => { + expect(findStageName().exists()).toBe(true); + }); + + it('renders the job id as a link', () => { + const jobId = getIdFromGraphQLId(defaultProps.job.id); + + expect(findJobId().exists()).toBe(true); + expect(findJobId().text()).toContain(String(jobId)); + }); + + it('does not renders the job lob', () => { + expect(findJobLog().exists()).toBe(false); + }); + }); + + describe('Retry action', () => { + describe('when the job is not retryable', () => { + beforeEach(() => { + createComponent({ props: { job: { ...job, retryable: false } } }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + + describe('when the job is a bridge', () => { + beforeEach(() => { + createComponent({ props: { job: { ...job, kind: BRIDGE_KIND } } }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + + describe('when the job is retryable', () => { + describe('and user has permission to update the build', () => { + beforeEach(() => { + createComponent(); + }); + + it('enables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(false); + }); + + describe('when clicking on the retry button', () => { + it('passes the loading state to the button', async () => { + await findRetryButton().vm.$emit('click', createFakeEvent()); + + expect(findRetryButton().props().loading).toBe(true); + }); + + describe('and it succeeds', () => { + beforeEach(async () => { + findRetryButton().vm.$emit('click', createFakeEvent()); + await waitForPromises(); + }); + + it('is no longer loading', () => { + expect(findRetryButton().props().loading).toBe(false); + }); + + it('calls the retry mutation', () => { + expect(mockRetryResponse).toHaveBeenCalled(); + expect(mockRetryResponse).toHaveBeenCalledWith({ + id: job.id, + }); + }); + + it('emits the `retried-job` event', () => { + expect(wrapper.emitted('job-retried')).toStrictEqual([[job.name]]); + }); + }); + + describe('and it fails', () => { + const customErrorMsg = 'Custom error message from API'; + + beforeEach(async () => { + mockRetryResponse.mockResolvedValue({ + data: { jobRetry: { errors: [customErrorMsg] } }, + }); + findRetryButton().vm.$emit('click', createFakeEvent()); + + await waitForPromises(); + }); + + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: customErrorMsg }); + }); + + it('does not emits the `refetch-jobs` event', () => { + expect(wrapper.emitted('refetch-jobs')).toBeUndefined(); + }); + }); + }); + }); + + describe('and user does not have permission to update the build', () => { + beforeEach(() => { + createComponent({ + props: { job: { ...job, retryable: true, userPermissions: { updateBuild: false } } }, + }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + }); + }); + + describe('Job log', () => { + describe('without permissions', () => { + beforeEach(async () => { + createComponent({ props: { job: { ...job, userPermissions: { readBuild: false } } } }); + await findRow().trigger('click'); + }); + + it('does not renders the received html of the job log', () => { + expect(findJobLog().html()).not.toContain(defaultProps.job.trace.htmlSummary); + }); + + it('shows a permission error message', () => { + expect(findJobLog().text()).toBe("You do not have permission to read this job's log."); + }); + }); + + describe('with permissions', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when clicking on the row', () => { + beforeEach(async () => { + await findRow().trigger('click'); + }); + + describe('while collapsed', () => { + it('expands the job log', () => { + expect(findJobLog().exists()).toBe(true); + }); + + it('renders the down arrow', () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + }); + + it('renders the received html of the job log', () => { + expect(findJobLog().html()).toContain(defaultProps.job.trace.htmlSummary); + }); + }); + + describe('while expanded', () => { + it('collapes the job log', async () => { + expect(findJobLog().exists()).toBe(true); + + await findRow().trigger('click'); + + expect(findJobLog().exists()).toBe(false); + }); + + it('renders the right arrow', async () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + + await findRow().trigger('click'); + + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + + describe('when clicking on a link element within the row', () => { + it('does not expands/collapse the job log', async () => { + expect(findJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + + await findJobId().vm.$emit('click'); + + expect(findJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list_spec.js new file mode 100644 index 00000000000..6c1c5f9c223 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list_spec.js @@ -0,0 +1,279 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { GlLoadingIcon, GlToast } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import FailedJobsList from '~/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list.vue'; +import FailedJobDetails from '~/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details.vue'; +import * as utils from '~/ci/pipeline_details/pipelines_list/failure_widget/utils'; +import getPipelineFailedJobs from '~/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { failedJobsMock, failedJobsMock2, failedJobsMockEmpty, activeFailedJobsMock } from './mock'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +jest.mock('~/alert'); + +describe('FailedJobsList component', () => { + let wrapper; + let mockFailedJobsResponse; + const showToast = jest.fn(); + + const defaultProps = { + failedJobsCount: 0, + graphqlResourceEtag: 'api/graphql', + isPipelineActive: false, + pipelineIid: 1, + projectPath: 'namespace/project/', + }; + + const defaultProvide = { + graphqlPath: 'api/graphql', + }; + + const createComponent = ({ props = {}, provide } = {}) => { + const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(FailedJobsList, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + apolloProvider: mockApollo, + mocks: { + $toast: { + show: showToast, + }, + }, + }); + }; + + const findAllHeaders = () => wrapper.findAllByTestId('header'); + const findFailedJobRows = () => wrapper.findAllComponents(FailedJobDetails); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findNoFailedJobsText = () => wrapper.findByText('No failed jobs in this pipeline 🎉'); + + beforeEach(() => { + mockFailedJobsResponse = jest.fn(); + }); + + describe('on mount', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + createComponent(); + }); + + it('fires the graphql query', () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(mockFailedJobsResponse).toHaveBeenCalledWith({ + fullPath: defaultProps.projectPath, + pipelineIid: defaultProps.pipelineIid, + }); + }); + }); + + describe('when loading failed jobs', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + createComponent(); + }); + + it('shows a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('when failed jobs have loaded', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await waitForPromises(); + }); + + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders table column', () => { + expect(findAllHeaders()).toHaveLength(3); + }); + + it('shows the list of failed jobs', () => { + expect(findFailedJobRows()).toHaveLength( + failedJobsMock.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('does not renders the empty state', () => { + expect(findNoFailedJobsText().exists()).toBe(false); + }); + + it('calls sortJobsByStatus', () => { + expect(utils.sortJobsByStatus).toHaveBeenCalledWith( + failedJobsMock.data.project.pipeline.jobs.nodes, + ); + }); + }); + + describe('when there are no failed jobs', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await waitForPromises(); + }); + + it('renders the empty state', () => { + expect(findNoFailedJobsText().exists()).toBe(true); + }); + }); + + describe('polling', () => { + it.each` + isGraphqlActive | text + ${true} | ${'polls'} + ${false} | ${'does not poll'} + `(`$text when isGraphqlActive: $isGraphqlActive`, async ({ isGraphqlActive }) => { + const defaultCount = 2; + const newCount = 1; + + const expectedCount = isGraphqlActive ? newCount : defaultCount; + const expectedCallCount = isGraphqlActive ? 2 : 1; + const mockResponse = isGraphqlActive ? activeFailedJobsMock : failedJobsMock; + + // Second result is to simulate polling with a different response + mockFailedJobsResponse.mockResolvedValueOnce(mockResponse); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + + createComponent(); + await waitForPromises(); + + // Initially, we get the first response which is always the default + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(findFailedJobRows()).toHaveLength(defaultCount); + + jest.advanceTimersByTime(10000); + await waitForPromises(); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(expectedCallCount); + expect(findFailedJobRows()).toHaveLength(expectedCount); + }); + }); + + describe('when a REST action occurs', () => { + beforeEach(() => { + // Second result is to simulate polling with a different response + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + }); + + it.each([true, false])('triggers a refetch of the jobs count', async (isPipelineActive) => { + const defaultCount = 2; + const newCount = 1; + + createComponent({ props: { isPipelineActive } }); + await waitForPromises(); + + // Initially, we get the first response which is always the default + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(findFailedJobRows()).toHaveLength(defaultCount); + + wrapper.setProps({ isPipelineActive: !isPipelineActive }); + await waitForPromises(); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); + expect(findFailedJobRows()).toHaveLength(newCount); + }); + }); + + describe('When the job count changes from REST', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); + + createComponent(); + }); + + describe('and the count is the same', () => { + it('does not re-fetch the query', async () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ failedJobsCount: 0 }); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + }); + }); + + describe('and the count is different', () => { + it('re-fetches the query', async () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ failedJobsCount: 10 }); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('when an error occurs loading jobs', () => { + const errorMessage = "We couldn't fetch jobs for you because you are not qualified"; + + beforeEach(async () => { + mockFailedJobsResponse.mockRejectedValue({ message: errorMessage }); + + createComponent(); + + await waitForPromises(); + }); + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('calls create Alert with the error message and danger variant', () => { + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' }); + }); + }); + + describe('when `refetch-jobs` job is fired from the widget', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + + createComponent(); + + await waitForPromises(); + }); + + it('refetches all failed jobs', async () => { + expect(findFailedJobRows()).not.toHaveLength( + failedJobsMock2.data.project.pipeline.jobs.nodes.length, + ); + + await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); + await waitForPromises(); + + expect(findFailedJobRows()).toHaveLength( + failedJobsMock2.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('shows a toast message', async () => { + await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); + await waitForPromises(); + + expect(showToast).toHaveBeenCalledWith('job-name job is being retried'); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/mock.js b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/mock.js new file mode 100644 index 00000000000..318d787a984 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/mock.js @@ -0,0 +1,78 @@ +export const job = { + id: 'gid://gitlab/Ci::Build/5241', + allowFailure: false, + detailedStatus: { + id: 'status', + detailsPath: '/jobs/5241', + action: { + id: 'action', + path: '/retry', + icon: 'retry', + }, + group: 'running', + icon: 'status_running_icon', + }, + name: 'job-name', + retried: false, + retryable: true, + kind: 'BUILD', + stage: { + id: '1', + name: 'build', + }, + trace: { + htmlSummary: '<h1>Hello</h1>', + }, + userPermissions: { + readBuild: true, + updateBuild: true, + }, +}; + +export const allowedToFailJob = { + ...job, + id: 'gid://gitlab/Ci::Build/5242', + allowFailure: true, +}; + +export const createFailedJobsMockCount = ({ count = 4, active = false } = {}) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Pipeline/20', + active, + jobs: { + count, + }, + }, + }, + }, + }; +}; + +const createFailedJobsMock = (nodes, active = false) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + active, + id: 'gid://gitlab/Pipeline/20', + jobs: { + count: nodes.length, + nodes, + }, + }, + }, + }, + }; +}; + +export const failedJobsMock = createFailedJobsMock([allowedToFailJob, job]); +export const failedJobsMockEmpty = createFailedJobsMock([]); + +export const activeFailedJobsMock = createFailedJobsMock([allowedToFailJob, job], true); + +export const failedJobsMock2 = createFailedJobsMock([job]); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js new file mode 100644 index 00000000000..5135bf57b22 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js @@ -0,0 +1,139 @@ +import { GlButton, GlCard, GlIcon, GlPopover } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineFailedJobsWidget from '~/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; +import FailedJobsList from '~/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list.vue'; + +jest.mock('~/alert'); + +describe('PipelineFailedJobsWidget component', () => { + let wrapper; + + const defaultProps = { + failedJobsCount: 4, + isPipelineActive: false, + pipelineIid: 1, + pipelinePath: '/pipelines/1', + projectPath: 'namespace/project/', + }; + + const defaultProvide = { + fullPath: 'namespace/project/', + }; + + const createComponent = ({ props = {}, provide = {} } = {}) => { + wrapper = shallowMountExtended(PipelineFailedJobsWidget, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { GlCard }, + }); + }; + + const findFailedJobsCard = () => wrapper.findByTestId('failed-jobs-card'); + const findFailedJobsButton = () => wrapper.findComponent(GlButton); + const findFailedJobsList = () => wrapper.findAllComponents(FailedJobsList); + const findInfoIcon = () => wrapper.findComponent(GlIcon); + const findInfoPopover = () => wrapper.findComponent(GlPopover); + + describe('when there are no failed jobs', () => { + beforeEach(() => { + createComponent({ props: { failedJobsCount: 0 } }); + }); + + it('renders the show failed jobs button with a count of 0', () => { + expect(findFailedJobsButton().exists()).toBe(true); + expect(findFailedJobsButton().text()).toBe('Failed jobs (0)'); + }); + }); + + describe('when there are failed jobs', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the show failed jobs button with correct count', () => { + expect(findFailedJobsButton().exists()).toBe(true); + expect(findFailedJobsButton().text()).toBe(`Failed jobs (${defaultProps.failedJobsCount})`); + }); + + it('renders the info icon', () => { + expect(findInfoIcon().exists()).toBe(true); + }); + + it('renders the info popover', () => { + expect(findInfoPopover().exists()).toBe(true); + }); + + it('does not render the failed jobs widget', () => { + expect(findFailedJobsList().exists()).toBe(false); + }); + }); + + describe('when the job button is clicked', () => { + beforeEach(async () => { + createComponent(); + await findFailedJobsButton().vm.$emit('click'); + }); + + it('renders the failed jobs widget', () => { + expect(findFailedJobsList().exists()).toBe(true); + }); + + it('removes the CSS border classes', () => { + expect(findFailedJobsCard().attributes('class')).not.toContain( + 'gl-border-white gl-hover-border-gray-100', + ); + }); + }); + + describe('when the job details are not expanded', () => { + beforeEach(() => { + createComponent(); + }); + + it('has the CSS border classes', () => { + expect(findFailedJobsCard().attributes('class')).toContain( + 'gl-border-white gl-hover-border-gray-100', + ); + }); + }); + + describe('when the job count changes', () => { + beforeEach(() => { + createComponent(); + }); + + describe('from the prop', () => { + it('updates the job count', async () => { + const newJobCount = 12; + + expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); + + await wrapper.setProps({ failedJobsCount: newJobCount }); + + expect(findFailedJobsButton().text()).toContain(String(newJobCount)); + }); + }); + + describe('from the event', () => { + beforeEach(async () => { + await findFailedJobsButton().vm.$emit('click'); + }); + + it('updates the job count', async () => { + const newJobCount = 12; + + expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); + + await findFailedJobsList().at(0).vm.$emit('failed-jobs-count', newJobCount); + + expect(findFailedJobsButton().text()).toContain(String(newJobCount)); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/utils_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/utils_spec.js new file mode 100644 index 00000000000..16a0da4e054 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/utils_spec.js @@ -0,0 +1,58 @@ +import { + isFailedJob, + sortJobsByStatus, +} from '~/ci/pipeline_details/pipelines_list/failure_widget/utils'; + +describe('isFailedJob', () => { + describe('when the job argument is undefined', () => { + it('returns false', () => { + expect(isFailedJob()).toBe(false); + }); + }); + + describe('when the job is of status `failed`', () => { + it('returns false', () => { + expect(isFailedJob({ detailedStatus: { group: 'success' } })).toBe(false); + }); + }); + + describe('when the job status is `failed`', () => { + it('returns true', () => { + expect(isFailedJob({ detailedStatus: { group: 'failed' } })).toBe(true); + }); + }); +}); + +describe('sortJobsByStatus', () => { + describe('when the arg is undefined', () => { + it('returns an empty array', () => { + expect(sortJobsByStatus()).toEqual([]); + }); + }); + + describe('when receiving an empty array', () => { + it('returns an empty array', () => { + expect(sortJobsByStatus([])).toEqual([]); + }); + }); + + describe('when reciving a list of jobs', () => { + const jobArr = [ + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'allowed_to_fail' } }, + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'success' } }, + ]; + + const expectedResult = [ + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'allowed_to_fail' } }, + { detailedStatus: { group: 'success' } }, + ]; + + it('sorts failed jobs first', () => { + expect(sortJobsByStatus(jobArr)).toEqual(expectedResult); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/pipelines_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/pipelines_spec.js new file mode 100644 index 00000000000..5790b753706 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/pipelines_spec.js @@ -0,0 +1,851 @@ +import '~/commons'; +import { + GlButton, + GlEmptyState, + GlFilteredSearch, + GlLoadingIcon, + GlPagination, + GlCollapsibleListbox, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { chunk } from 'lodash'; +import { nextTick } from 'vue'; +import mockPipelinesResponse from 'test_fixtures/pipelines/pipelines.json'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import { mockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import Api from '~/api'; +import { createAlert, VARIANT_WARNING } from '~/alert'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import NavigationControls from '~/ci/pipeline_details/pipelines_list/components/nav_controls.vue'; +import PipelinesComponent from '~/ci/pipeline_details/pipelines_list/pipelines.vue'; +import PipelinesCiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue'; +import PipelinesTableComponent from '~/ci/pipeline_details/pipelines_list/components/pipelines_table.vue'; +import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants'; +import Store from '~/ci/pipeline_details/stores/pipelines_store'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { + setIdTypePreferenceMutationResponse, + setIdTypePreferenceMutationResponseWithErrors, +} from 'jest/issues/list/mock_data'; + +import { stageReply } from 'jest/ci/pipeline_mini_graph/mock_data'; +import { users, mockSearch, branches } from '../mock_data'; + +jest.mock('@sentry/browser'); +jest.mock('~/alert'); + +const mockProjectPath = 'twitter/flight'; +const mockProjectId = '21'; +const mockDefaultBranchName = 'main'; +const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`; +const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id); +const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( + (p) => p.details.stages && p.details.stages.length, +); + +describe('Pipelines', () => { + let wrapper; + let mockApollo; + let mock; + let trackingSpy; + + const paths = { + emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', + noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + ciLintPath: '/ci/lint', + resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, + newPipelinePath: `${mockProjectPath}/pipelines/new`, + + ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`, + }; + + const noPermissions = { + emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', + noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + }; + + const defaultProps = { + hasGitlabCi: true, + canCreatePipeline: true, + ...paths, + }; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findNavigationTabs = () => wrapper.findComponent(NavigationTabs); + const findNavigationControls = () => wrapper.findComponent(NavigationControls); + const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); + const findTablePagination = () => wrapper.findComponent(TablePagination); + const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox); + + const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); + const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box'); + const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); + const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); + const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); + const findStagesDropdownToggle = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'); + const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); + + const createComponent = (props = defaultProps) => { + const { mutationMock, ...restProps } = props; + mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]); + + wrapper = extendedWrapper( + mount(PipelinesComponent, { + provide: { + pipelineEditorPath: '', + suggestedCiTemplates: [], + ciRunnerSettingsPath: paths.ciRunnerSettingsPath, + anyRunnersAvailable: true, + }, + propsData: { + store: new Store(), + projectId: mockProjectId, + defaultBranchName: mockDefaultBranchName, + endpoint: mockPipelinesEndpoint, + params: {}, + ...restProps, + }, + apolloProvider: mockApollo, + }), + ); + }; + + beforeEach(() => { + setWindowLocation(TEST_HOST); + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(window.history, 'pushState'); + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + }); + + afterEach(() => { + mock.reset(); + mockApollo = null; + window.history.pushState.mockReset(); + }); + + describe('when pipelines are not yet loaded', () => { + beforeEach(async () => { + createComponent(); + await nextTick(); + }); + + it('shows loading state when the app is loading', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('does not display tabs when the first request has not yet been made', () => { + expect(findNavigationTabs().exists()).toBe(false); + }); + + it('does not display buttons', () => { + expect(findNavigationControls().exists()).toBe(false); + }); + }); + + describe('when there are pipelines in the project', () => { + beforeEach(() => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, mockPipelinesResponse); + }); + + describe('when user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + await waitForPromises(); + }); + + it('renders "All" tab with count different from "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 3'); + }); + + it('does not render buttons', () => { + expect(findNavigationControls().exists()).toBe(false); + + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('renders pipelines in a table', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + }); + + describe('when user has permissions', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should set up navigation tabs', () => { + expect(findNavigationTabs().props('tabs')).toEqual([ + { name: 'All', scope: 'all', count: '3', isActive: true }, + { name: 'Finished', scope: 'finished', count: undefined, isActive: false }, + { name: 'Branches', scope: 'branches', isActive: false }, + { name: 'Tags', scope: 'tags', isActive: false }, + ]); + }); + + it('renders "All" tab with count different from "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 3'); + }); + + it('should render other navigation tabs', () => { + expect(findTab('finished').text()).toBe('Finished'); + expect(findTab('branches').text()).toBe('Branches'); + expect(findTab('tags').text()).toBe('Tags'); + }); + + it('shows navigation controls', () => { + expect(findNavigationControls().exists()).toBe(true); + }); + + it('renders Run pipeline link', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + }); + + it('renders CI lint link', () => { + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + }); + + it('renders Clear runner cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('renders pipelines in a table', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + + describe('when user goes to a tab', () => { + const goToTab = (tab) => { + findNavigationTabs().vm.$emit('onChangeTab', tab); + }; + + describe('when the scope in the tab has pipelines', () => { + const mockFinishedPipeline = mockPipelinesResponse.pipelines[0]; + + beforeEach(async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [mockFinishedPipeline], + count: mockPipelinesResponse.count, + }); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + goToTab('finished'); + + await waitForPromises(); + }); + + it('should filter pipelines', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(1); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFinishedPipeline.id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?scope=finished&page=1`, + ); + }); + + it.each(['all', 'finished', 'branches', 'tags'])('tracks %p tab click', async (scope) => { + goToTab(scope); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filter_tabs', { + label: TRACKING_CATEGORIES.tabs, + property: scope, + }); + }); + }); + + describe('when the scope in the tab is empty', () => { + beforeEach(async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'branches', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: mockPipelinesResponse.count, + }); + + goToTab('branches'); + + await waitForPromises(); + }); + + it('should filter pipelines', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?scope=branches&page=1`, + ); + }); + }); + }); + + describe('when user triggers a filtered search', () => { + const mockFilteredPipeline = mockPipelinesResponse.pipelines[1]; + + let expectedParams; + + beforeEach(async () => { + expectedParams = { + page: '1', + scope: 'all', + username: 'root', + ref: 'main', + status: 'pending', + }; + + mock + .onGet(mockPipelinesEndpoint, { + params: expectedParams, + }) + .replyOnce(HTTP_STATUS_OK, { + pipelines: [mockFilteredPipeline], + count: mockPipelinesResponse.count, + }); + + findFilteredSearch().vm.$emit('submit', mockSearch); + + await waitForPromises(); + }); + + it('requests data with query params on filter submit', () => { + expect(mock.history.get[1].params).toEqual(expectedParams); + }); + + it('renders filtered pipelines', () => { + expect(findPipelineUrlLinks()).toHaveLength(1); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all&username=root&ref=main&status=pending`, + ); + }); + }); + + describe('when user changes Show Pipeline ID to Show Pipeline IID', () => { + const mockFilteredPipeline = mockPipelinesResponse.pipelines[0]; + + beforeEach(() => { + gon.current_user_id = 1; + }); + + it('should change the text to Show Pipeline IID', async () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + + await waitForPromises(); + + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.iid}`); + }); + + it('calls mutation to save idType preference', () => { + const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); + createComponent({ ...defaultProps, mutationMock }); + + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + + expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } }); + }); + + it('captures error when mutation response has errors', async () => { + const mutationMock = jest + .fn() + .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); + createComponent({ ...defaultProps, mutationMock }); + + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); + }); + }); + + describe('when user triggers a filtered search with raw text', () => { + beforeEach(async () => { + findFilteredSearch().vm.$emit('submit', ['rawText']); + + await waitForPromises(); + }); + + it('requests data with query params on filter submit', () => { + expect(mock.history.get[1].params).toEqual({ page: '1', scope: 'all' }); + }); + + it('displays a warning message if raw text search is used', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: RAW_TEXT_WARNING, + variant: VARIANT_WARNING, + }); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all`, + ); + }); + }); + }); + }); + + describe('when there are multiple pages of pipelines', () => { + const mockPageSize = 2; + const mockPageHeaders = ({ page = 1 } = {}) => { + return { + 'X-PER-PAGE': `${mockPageSize}`, + 'X-PREV-PAGE': `${page - 1}`, + 'X-PAGE': `${page}`, + 'X-NEXT-PAGE': `${page + 1}`, + }; + }; + const [firstPage, secondPage] = chunk(mockPipelinesResponse.pipelines, mockPageSize); + + const goToPage = (page) => { + findTablePagination().findComponent(GlPagination).vm.$emit('input', page); + }; + + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }).reply( + HTTP_STATUS_OK, + { + pipelines: firstPage, + count: mockPipelinesResponse.count, + }, + mockPageHeaders({ page: 1 }), + ); + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '2' } }).reply( + HTTP_STATUS_OK, + { + pipelines: secondPage, + count: mockPipelinesResponse.count, + }, + mockPageHeaders({ page: 2 }), + ); + + createComponent(); + + await waitForPromises(); + }); + + it('shows the first page of pipelines', () => { + expect(findPipelineUrlLinks()).toHaveLength(firstPage.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${firstPage[0].id}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${firstPage[1].id}`); + }); + + it('should not update browser bar', () => { + expect(window.history.pushState).not.toHaveBeenCalled(); + }); + + describe('when user goes to next page', () => { + beforeEach(async () => { + goToPage(2); + await waitForPromises(); + }); + + it('should update page and keep scope the same scope', () => { + expect(findPipelineUrlLinks()).toHaveLength(secondPage.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${secondPage[0].id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=2&scope=all`, + ); + }); + + it('should reset page to 1 when filtering pipelines', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=2&scope=all`, + ); + + findFilteredSearch().vm.$emit('submit', [ + { type: 'status', value: { data: 'success', operator: '=' } }, + ]); + + expect(window.history.pushState).toHaveBeenCalledTimes(2); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all&status=success`, + ); + }); + }); + }); + + describe('when pipelines can be polled', () => { + beforeEach(() => { + const emptyResponse = { + pipelines: [], + count: { all: '0' }, + }; + + // Mock no pipelines in the first attempt + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .replyOnce(HTTP_STATUS_OK, emptyResponse, { + 'POLL-INTERVAL': 100, + }); + // Mock pipelines in the next attempt + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, mockPipelinesResponse, { + 'POLL-INTERVAL': 100, + }); + }); + + describe('data is loaded for the first time', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('shows tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + }); + + it('should update page and keep scope the same scope', () => { + expect(findPipelineUrlLinks()).toHaveLength(0); + }); + + describe('data is loaded for a second time', () => { + beforeEach(async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('shows tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + }); + + it('is loading after a time', () => { + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + }); + }); + }); + + describe('when no pipelines exist', () => { + beforeEach(() => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: { all: '0' }, + }); + }); + + describe('when CI is enabled and user has permissions', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('renders tab with count of "0"', () => { + expect(findNavigationTabs().exists()).toBe(true); + expect(findTab('all').text()).toMatchInterpolatedText('All 0'); + }); + + it('renders Run pipeline link', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + }); + + it('renders CI lint link', () => { + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + }); + + it('renders Clear runner cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('renders empty state', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + + it('renders filtered search', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('renders the pipeline key collapsible box', () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); + }); + + it('renders tab empty state finished scope', async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: { all: '0' }, + }); + + findNavigationTabs().vm.$emit('onChangeTab', 'finished'); + + await waitForPromises(); + + expect(findEmptyState().text()).toBe('There are currently no finished pipelines.'); + }); + }); + + describe('when CI is not enabled and user has permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + await waitForPromises(); + }); + + it('renders the CI/CD templates', () => { + expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true); + }); + + it('does not render filtered search', () => { + expect(findFilteredSearch().exists()).toBe(false); + }); + + it('does not render the pipeline key dropdown', () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(false); + }); + + it('does not render tabs nor buttons', () => { + expect(findNavigationTabs().exists()).toBe(false); + expect(findTab('all').exists()).toBe(false); + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + }); + + describe('when CI is not enabled and user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + await waitForPromises(); + }); + + it('renders empty state without button to set CI', () => { + expect(findEmptyState().text()).toBe( + 'This project is not currently set up to run pipelines.', + ); + + expect(findEmptyState().findComponent(GlButton).exists()).toBe(false); + }); + + it('does not render tabs or buttons', () => { + expect(findTab('all').exists()).toBe(false); + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + }); + + describe('when CI is enabled and user has no permissions', () => { + beforeEach(() => { + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + + return waitForPromises(); + }); + + it('renders tab with count of "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 0'); + }); + + it('does not render buttons', () => { + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('renders empty state', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + }); + }); + + describe('when a pipeline with stages exists', () => { + describe('updates results when a staged is clicked', () => { + let stopMock; + let restartMock; + let cancelMock; + + beforeEach(() => { + mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply( + HTTP_STATUS_OK, + { + pipelines: [mockPipelineWithStages], + count: { all: '1' }, + }, + { + 'POLL-INTERVAL': 100, + }, + ); + + mock + .onGet(mockPipelineWithStages.details.stages[0].dropdown_path) + .reply(HTTP_STATUS_OK, stageReply); + + createComponent(); + + stopMock = jest.spyOn(window, 'clearTimeout'); + restartMock = jest.spyOn(axios, 'get'); + }); + + describe('when a request is being made', () => { + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_OK, mockPipelinesResponse); + + await waitForPromises(); + }); + + it('stops polling, cancels the request, & restarts polling', async () => { + // Mock init a polling cycle + wrapper.vm.poll.options.notificationCallback(true); + + await findStagesDropdownToggle().trigger('click'); + jest.runOnlyPendingTimers(); + + // cancelMock is getting overwritten in pipelines_service.js#L29 + // so we have to spy on it again here + cancelMock = jest.spyOn(axios.CancelToken, 'source'); + + await waitForPromises(); + + expect(cancelMock).toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalledWith( + `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`, + ); + }); + + it('stops polling & restarts polling', async () => { + await findStagesDropdownToggle().trigger('click'); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(cancelMock).not.toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalledWith( + `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`, + ); + }); + }); + }); + }); + + describe('when pipelines cannot be loaded', () => { + beforeEach(() => { + mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, {}); + }); + + describe('when user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); + + await waitForPromises(); + }); + + it('renders tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + expect(findTab('all').text()).toBe('All'); + }); + + it('does not render buttons', () => { + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('shows error state', () => { + expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.'); + expect(findEmptyState().props('description')).toBe( + 'Try again in a few moments or contact your support team.', + ); + }); + }); + + describe('when user has permissions', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('renders tabs', () => { + expect(findTab('all').text()).toBe('All'); + }); + + it('renders buttons', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('shows error state', () => { + expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.'); + expect(findEmptyState().props('description')).toBe( + 'Try again in a few moments or contact your support team.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token_spec.js new file mode 100644 index 00000000000..effcb533ffa --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token_spec.js @@ -0,0 +1,142 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import PipelineBranchNameToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token.vue'; +import { branches, mockBranchesAfterMap } from '../../mock_data'; + +describe('Pipeline Branch Name Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const getBranchSuggestions = () => + findAllFilteredSearchSuggestions().wrappers.map((w) => w.text()); + + const stubs = { + GlFilteredSearchToken: { + template: `<div><slot name="suggestions"></slot></div>`, + }, + }; + + const defaultProps = { + config: { + type: 'ref', + icon: 'branch', + title: 'Branch name', + unique: true, + projectId: '21', + defaultBranchName: null, + disabled: false, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const optionsWithDefaultBranchName = (options) => { + return { + propsData: { + ...defaultProps, + config: { + ...defaultProps.config, + defaultBranchName: 'main', + }, + }, + ...options, + }; + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineBranchNameToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project branches', () => { + expect(Api.branches).toHaveBeenCalled(); + + expect(wrapper.vm.branches).toEqual(mockBranchesAfterMap); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ stubs }, { loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ stubs }, { loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows branches correctly', () => { + it('renders all branches', () => { + createComponent({ stubs }, { branches, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length); + }); + + it('renders only the branch searched for', () => { + const mockBranches = ['main']; + createComponent({ stubs }, { branches: mockBranches, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length); + }); + + it('shows the default branch first if no branch was searched for', async () => { + const mockBranches = [{ name: 'branch-1' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false }); + await nextTick(); + expect(getBranchSuggestions()).toEqual(['main', 'branch-1']); + }); + + it('does not show the default branch if a search term was provided', async () => { + const mockBranches = [{ name: 'branch-1' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName(), { loading: false }); + + findFilteredSearchToken().vm.$emit('input', { data: 'branch-1' }); + await waitForPromises(); + expect(getBranchSuggestions()).toEqual(['branch-1']); + }); + + it('shows the default branch only once if it appears in the results', async () => { + const mockBranches = [{ name: 'main' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false }); + await nextTick(); + expect(getBranchSuggestions()).toEqual(['main']); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token_spec.js new file mode 100644 index 00000000000..180fdee8353 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token_spec.js @@ -0,0 +1,53 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipeline_details/pipelines_list/tokens/constants'; +import { stubComponent } from 'helpers/stub_component'; +import PipelineSourceToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token.vue'; + +describe('Pipeline Source Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + + const defaultProps = { + config: { + type: 'source', + icon: 'trigger-source', + title: 'Source', + unique: true, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = () => { + wrapper = shallowMount(PipelineSourceToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('shows sources correctly', () => { + it('renders all pipeline sources available', () => { + expect(findAllFilteredSearchSuggestions()).toHaveLength(PIPELINE_SOURCES.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token_spec.js new file mode 100644 index 00000000000..4b9d4fb7a94 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token_spec.js @@ -0,0 +1,58 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import PipelineStatusToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token.vue'; +import { + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +describe('Pipeline Status Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findAllGlIcons = () => wrapper.findAllComponents(GlIcon); + + const defaultProps = { + config: { + type: TOKEN_TYPE_STATUS, + icon: 'status', + title: TOKEN_TITLE_STATUS, + unique: true, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = () => { + wrapper = shallowMount(PipelineStatusToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('shows statuses correctly', () => { + it('renders all pipeline statuses available', () => { + expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.statuses.length); + expect(findAllGlIcons()).toHaveLength(wrapper.vm.statuses.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token_spec.js new file mode 100644 index 00000000000..d3eae14608d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token_spec.js @@ -0,0 +1,95 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Api from '~/api'; +import PipelineTagNameToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token.vue'; +import { tags, mockTagsAfterMap } from '../../mock_data'; + +describe('Pipeline Branch Name Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const stubs = { + GlFilteredSearchToken: { + template: `<div><slot name="suggestions"></slot></div>`, + }, + }; + + const defaultProps = { + config: { + type: 'tag', + icon: 'tag', + title: 'Tag name', + unique: true, + projectId: '21', + disabled: false, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineTagNameToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project tags', () => { + expect(Api.tags).toHaveBeenCalled(); + + expect(wrapper.vm.tags).toEqual(mockTagsAfterMap); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ stubs }, { loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ stubs }, { loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows tags correctly', () => { + it('renders all tags', () => { + createComponent({ stubs }, { tags, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(tags.length); + }); + + it('renders only the tag searched for', () => { + const mockTags = ['main-tag']; + createComponent({ stubs }, { tags: mockTags, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token_spec.js new file mode 100644 index 00000000000..2eab2cd2ef2 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token_spec.js @@ -0,0 +1,99 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import Api from '~/api'; +import PipelineTriggerAuthorToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token.vue'; +import { users } from '../../mock_data'; + +describe('Pipeline Trigger Author Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const defaultProps = { + config: { + type: 'username', + icon: 'user', + title: 'Trigger author', + dataType: 'username', + unique: true, + triggerAuthors: users, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = (data) => { + wrapper = shallowMount(PipelineTriggerAuthorToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project users', () => { + expect(Api.projectUsers).toHaveBeenCalled(); + + expect(wrapper.vm.users).toEqual(users); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows trigger authors correctly', () => { + beforeEach(() => {}); + + it('renders all trigger authors', () => { + createComponent({ users, loading: false }); + + // should have length of all users plus the static 'Any' option + expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1); + }); + + it('renders only the trigger author searched for', () => { + createComponent({ + users: [{ name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }], + loading: false, + }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(2); + }); + }); +}); 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..700d51930dd --- /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/pipeline_details/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); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/graph/mock_data.js b/spec/frontend/ci/pipeline_editor/components/graph/mock_data.js new file mode 100644 index 00000000000..db77e0a0573 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/graph/mock_data.js @@ -0,0 +1,283 @@ +export const yamlString = `stages: +- empty +- build +- test +- deploy +- final + +include: +- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml' + +build_a: + stage: build + script: echo hello +build_b: + stage: build + script: echo hello +build_c: + stage: build + script: echo hello +build_d: + stage: Queen + script: echo hello + +test_a: + stage: test + script: ls + needs: [build_a, build_b, build_c] +test_b: + stage: test + script: ls + needs: [build_a, build_b, build_d] +test_c: + stage: test + script: ls + needs: [build_a, build_b, build_c] + +deploy_a: + stage: deploy + script: echo hello +`; + +export const pipelineDataWithNoNeeds = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + ], + }, + ], +}; + +export const pipelineData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test' }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_1'] }], + }, + ], + }, + ], +}; + +export const invalidNeedsData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test' }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['invalid_job'] }], + }, + ], + }, + ], +}; + +export const parallelNeedData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + parallel: 3, + jobs: [ + { script: 'echo hello', stage: 'build', name: 'build_1 1/3' }, + { script: 'echo hello', stage: 'build', name: 'build_1 2/3' }, + { script: 'echo hello', stage: 'build', name: 'build_1 3/3' }, + ], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test', needs: ['build_1'] }], + }, + ], + }, + ], +}; + +export const sameStageNeeds = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build', name: 'build_1' }], + }, + ], + }, + { + name: 'build', + groups: [ + { + name: 'build_2', + jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_1'] }], + }, + ], + }, + { + name: 'build', + groups: [ + { + name: 'build_3', + jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_2'] }], + }, + ], + }, + ], +}; + +export const largePipelineData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + { + name: 'build_2', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + { + name: 'build_3', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test', needs: ['build_2'] }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test', needs: ['build_2'] }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_1'] }], + }, + { + name: 'deploy_2', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['build_3'] }], + }, + { + name: 'deploy_3', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_2'] }], + }, + ], + }, + ], +}; + +export const singleStageData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + ], +}; + +export const rootRect = { + bottom: 463, + height: 271, + left: 236, + right: 1252, + top: 192, + width: 1016, + x: 236, + y: 192, +}; + +export const jobRect = { + bottom: 312, + height: 24, + left: 308, + right: 428, + top: 288, + width: 120, + x: 308, + y: 288, +}; diff --git a/spec/frontend/ci/pipeline_editor/components/graph/pipeline_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/graph/pipeline_graph_spec.js new file mode 100644 index 00000000000..95edfb01cf0 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/graph/pipeline_graph_spec.js @@ -0,0 +1,100 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; +import { CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants'; +import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; +import JobPill from '~/ci/pipeline_editor/components/graph/job_pill.vue'; +import PipelineGraph from '~/ci/pipeline_editor/components/graph/pipeline_graph.vue'; +import StageName from '~/ci/pipeline_editor/components/graph/stage_name.vue'; +import { pipelineData, singleStageData } from './mock_data'; + +describe('pipeline graph component', () => { + const defaultProps = { pipelineData }; + let wrapper; + + const containerId = 'pipeline-graph-container-0'; + setHTMLFixture(`<div id="${containerId}"></div>`); + + const createComponent = (props = defaultProps) => { + return shallowMount(PipelineGraph, { + propsData: { + ...props, + }, + stubs: { LinksLayer, LinksInner }, + data() { + return { + measurements: { + width: 1000, + height: 1000, + }, + }; + }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findAllJobPills = () => wrapper.findAllComponents(JobPill); + const findAllStageNames = () => wrapper.findAllComponents(StageName); + const findLinksLayer = () => wrapper.findComponent(LinksLayer); + const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); + + describe('with `VALID` status', () => { + beforeEach(() => { + wrapper = createComponent({ + pipelineData: { + status: CI_CONFIG_STATUS_VALID, + stages: [{ name: 'hello', groups: [] }], + }, + }); + }); + + it('renders the graph with no status error', () => { + expect(findAlert().exists()).toBe(false); + expect(findPipelineGraph().exists()).toBe(true); + expect(findLinksLayer().exists()).toBe(true); + }); + }); + + describe('with only one stage', () => { + beforeEach(() => { + wrapper = createComponent({ pipelineData: singleStageData }); + }); + + it('renders the right number of stage titles', () => { + const expectedStagesLength = singleStageData.stages.length; + + expect(findAllStageNames()).toHaveLength(expectedStagesLength); + }); + + it('renders the right number of job pills', () => { + // We count the number of jobs in the mock data + const expectedJobsLength = singleStageData.stages.reduce((acc, val) => { + return acc + val.groups.length; + }, 0); + + expect(findAllJobPills()).toHaveLength(expectedJobsLength); + }); + }); + + describe('with multiple stages and jobs', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the right number of stage titles', () => { + const expectedStagesLength = pipelineData.stages.length; + + expect(findAllStageNames()).toHaveLength(expectedStagesLength); + }); + + it('renders the right number of job pills', () => { + // We count the number of jobs in the mock data + const expectedJobsLength = pipelineData.stages.reduce((acc, val) => { + return acc + val.groups.length; + }, 0); + + expect(findAllJobPills()).toHaveLength(expectedJobsLength); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/job_item_spec.js b/spec/frontend/ci/pipeline_mini_graph/job_item_spec.js new file mode 100644 index 00000000000..9c14e75caa4 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/job_item_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import JobItem from '~/ci/pipeline_mini_graph/job_item.vue'; + +describe('JobItem', () => { + let wrapper; + + const defaultProps = { + job: { id: '3' }, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(JobItem, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the received HTML', () => { + expect(wrapper.html()).toContain(defaultProps.job.id); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js new file mode 100644 index 00000000000..916f3053153 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js @@ -0,0 +1,122 @@ +import { mount } from '@vue/test-utils'; +import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineStages from '~/ci/pipeline_mini_graph/pipeline_stages.vue'; +import mockLinkedPipelines from './linked_pipelines_mock_data'; + +const mockStages = pipelines[0].details.stages; + +describe('Legacy Pipeline Mini Graph', () => { + let wrapper; + + const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); + const findPipelineStages = () => wrapper.findComponent(PipelineStages); + + const findLinkedPipelineUpstream = () => + wrapper.findComponent('[data-testid="pipeline-mini-graph-upstream"]'); + const findLinkedPipelineDownstream = () => + wrapper.findComponent('[data-testid="pipeline-mini-graph-downstream"]'); + const findDownstreamArrowIcon = () => wrapper.find('[data-testid="downstream-arrow-icon"]'); + const findUpstreamArrowIcon = () => wrapper.find('[data-testid="upstream-arrow-icon"]'); + + const createComponent = (props = {}) => { + wrapper = mount(LegacyPipelineMiniGraph, { + propsData: { + stages: mockStages, + ...props, + }, + }); + }; + + describe('rendered state without upstream or downstream pipelines', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render the pipeline stages', () => { + expect(findPipelineStages().exists()).toBe(true); + }); + + it('should have the correct props', () => { + expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ + downstreamPipelines: [], + isMergeTrain: false, + pipelinePath: '', + stages: expect.any(Array), + updateDropdown: false, + upstreamPipeline: undefined, + }); + }); + + it('should have no linked pipelines', () => { + expect(findLinkedPipelineDownstream().exists()).toBe(false); + expect(findLinkedPipelineUpstream().exists()).toBe(false); + }); + + it('should not render arrow icons', () => { + expect(findUpstreamArrowIcon().exists()).toBe(false); + expect(findDownstreamArrowIcon().exists()).toBe(false); + }); + }); + + describe('rendered state with upstream pipeline', () => { + beforeEach(() => { + createComponent({ + upstreamPipeline: mockLinkedPipelines.triggered_by, + }); + }); + + it('should have the correct props', () => { + expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ + downstreamPipelines: [], + isMergeTrain: false, + pipelinePath: '', + stages: expect.any(Array), + updateDropdown: false, + upstreamPipeline: expect.any(Object), + }); + }); + + it('should render the upstream linked pipelines mini list only', () => { + expect(findLinkedPipelineUpstream().exists()).toBe(true); + expect(findLinkedPipelineDownstream().exists()).toBe(false); + }); + + it('should render an upstream arrow icon only', () => { + expect(findDownstreamArrowIcon().exists()).toBe(false); + expect(findUpstreamArrowIcon().exists()).toBe(true); + expect(findUpstreamArrowIcon().props('name')).toBe('long-arrow'); + }); + }); + + describe('rendered state with downstream pipelines', () => { + beforeEach(() => { + createComponent({ + downstreamPipelines: mockLinkedPipelines.triggered, + pipelinePath: 'my/pipeline/path', + }); + }); + + it('should have the correct props', () => { + expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ + downstreamPipelines: expect.any(Array), + isMergeTrain: false, + pipelinePath: 'my/pipeline/path', + stages: expect.any(Array), + updateDropdown: false, + upstreamPipeline: undefined, + }); + }); + + it('should render the downstream linked pipelines mini list only', () => { + expect(findLinkedPipelineDownstream().exists()).toBe(true); + expect(findLinkedPipelineUpstream().exists()).toBe(false); + }); + + it('should render a downstream arrow icon only', () => { + expect(findUpstreamArrowIcon().exists()).toBe(false); + expect(findDownstreamArrowIcon().exists()).toBe(true); + expect(findDownstreamArrowIcon().props('name')).toBe('long-arrow'); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js new file mode 100644 index 00000000000..1d44134bef8 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js @@ -0,0 +1,247 @@ +import { GlDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue'; +import eventHub from '~/ci/pipeline_details/event_hub'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stageReply } from './mock_data'; + +const dropdownPath = 'path.json'; + +describe('Pipelines stage component', () => { + let wrapper; + let mock; + let glTooltipDirectiveMock; + + const createComponent = (props = {}) => { + glTooltipDirectiveMock = jest.fn(); + wrapper = mount(LegacyPipelineStage, { + attachTo: document.body, + directives: { + GlTooltip: glTooltipDirectiveMock, + }, + propsData: { + stage: { + status: { + group: 'success', + icon: 'status_success', + title: 'success', + }, + dropdown_path: dropdownPath, + }, + updateDropdown: false, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + eventHub.$emit.mockRestore(); + mock.restore(); + // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy + wrapper.destroy(); + }); + + const findCiActionBtn = () => wrapper.find('.js-ci-action'); + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); + const findDropdownMenu = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); + const findDropdownMenuTitle = () => + wrapper.find('[data-testid="pipeline-stage-dropdown-menu-title"]'); + const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); + const findLoadingState = () => wrapper.find('[data-testid="pipeline-stage-loading-state"]'); + + const openStageDropdown = async () => { + await findDropdownToggle().trigger('click'); + await waitForPromises(); + await nextTick(); + }; + + describe('loading state', () => { + beforeEach(async () => { + createComponent({ updateDropdown: true }); + + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + + await openStageDropdown(); + }); + + it('displays loading state while jobs are being fetched', async () => { + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findLoadingState().exists()).toBe(true); + expect(findLoadingState().text()).toBe(LegacyPipelineStage.i18n.loadingText); + }); + + it('does not display loading state after jobs have been fetched', async () => { + await waitForPromises(); + + expect(findLoadingState().exists()).toBe(false); + }); + }); + + describe('default appearance', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets up the tooltip to not have a show delay animation', () => { + expect(glTooltipDirectiveMock.mock.calls[0][1].modifiers.ds0).toBe(true); + }); + + it('renders a dropdown with the status icon', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdownToggle().exists()).toBe(true); + expect(findCiIcon().exists()).toBe(true); + }); + + it('renders a borderless ci-icon', () => { + expect(findCiIcon().exists()).toBe(true); + expect(findCiIcon().props('isBorderless')).toBe(true); + expect(findCiIcon().classes('borderless')).toBe(true); + }); + + it('renders a ci-icon with a custom border class', () => { + expect(findCiIcon().exists()).toBe(true); + expect(findCiIcon().classes('gl-border')).toBe(true); + }); + }); + + describe('when user opens dropdown and stage request is successful', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent(); + + await openStageDropdown(); + await jest.runAllTimers(); + await axios.waitForAll(); + }); + + it('renders the received data and emits the correct events', () => { + expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); + expect(findDropdownMenuTitle().text()).toContain(stageReply.name); + expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + expect(wrapper.emitted('miniGraphStageClick')).toEqual([[]]); + }); + + it('refreshes when updateDropdown is set to true', async () => { + expect(mock.history.get).toHaveLength(1); + + wrapper.setProps({ updateDropdown: true }); + await axios.waitForAll(); + + expect(mock.history.get).toHaveLength(2); + }); + }); + + describe('when user opens dropdown and stage request fails', () => { + it('should close the dropdown', async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + await waitForPromises(); + + expect(findDropdown().classes('show')).toBe(false); + }); + }); + + describe('update endpoint correctly', () => { + beforeEach(async () => { + const copyStage = { ...stageReply }; + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(HTTP_STATUS_OK, copyStage); + createComponent({ + stage: { + status: { + group: 'running', + icon: 'status_running', + title: 'running', + }, + dropdown_path: 'bar.json', + }, + }); + await axios.waitForAll(); + }); + + it('should update the stage to request the new endpoint provided', async () => { + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(findDropdownMenu().text()).toContain('this is the updated content'); + }); + }); + + describe('job update in dropdown', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(HTTP_STATUS_OK); + + createComponent(); + await waitForPromises(); + await nextTick(); + }); + + const clickCiAction = async () => { + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + await findCiActionBtn().trigger('click'); + }; + + it('keeps dropdown open when job item action is clicked', async () => { + await clickCiAction(); + await waitForPromises(); + + expect(findDropdown().classes('show')).toBe(true); + }); + }); + + describe('With merge trains enabled', () => { + it('shows a warning on the dropdown', async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent({ + isMergeTrain: true, + }); + + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + const warning = findMergeTrainWarning(); + + expect(warning.text()).toBe('Merge train pipeline jobs can not be retried'); + }); + }); + + describe('With merge trains disabled', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('does not show a warning on the dropdown', () => { + const warning = findMergeTrainWarning(); + + expect(warning.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js new file mode 100644 index 00000000000..0396029cdaf --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js @@ -0,0 +1,166 @@ +import { mount } from '@vue/test-utils'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LinkedPipelinesMiniList from '~/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue'; +import mockData from './linked_pipelines_mock_data'; + +describe('Linked pipeline mini list', () => { + let wrapper; + + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findCiIcons = () => wrapper.findAllComponents(CiIcon); + const findLinkedPipelineCounter = () => wrapper.find('[data-testid="linked-pipeline-counter"]'); + const findLinkedPipelineMiniItem = () => + wrapper.find('[data-testid="linked-pipeline-mini-item"]'); + const findLinkedPipelineMiniItems = () => + wrapper.findAll('[data-testid="linked-pipeline-mini-item"]'); + const findLinkedPipelineMiniList = () => wrapper.findComponent(LinkedPipelinesMiniList); + + const createComponent = (props = {}) => { + wrapper = mount(LinkedPipelinesMiniList, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + ...props, + }, + }); + }; + + describe('when passed an upstream pipeline as prop', () => { + beforeEach(() => { + createComponent({ + triggeredBy: [mockData.triggered_by], + }); + }); + + it('should render one linked pipeline item', () => { + expect(findLinkedPipelineMiniItem().exists()).toBe(true); + }); + + it('should render a linked pipeline with the correct href', () => { + expect(findLinkedPipelineMiniItem().exists()).toBe(true); + + expect(findLinkedPipelineMiniItem().attributes('href')).toBe( + '/gitlab-org/gitlab-foss/-/pipelines/129', + ); + }); + + it('should render one ci status icon', () => { + expect(findCiIcon().exists()).toBe(true); + }); + + it('should render a borderless ci-icon', () => { + expect(findCiIcon().exists()).toBe(true); + + expect(findCiIcon().props('isBorderless')).toBe(true); + expect(findCiIcon().classes('borderless')).toBe(true); + }); + + it('should render a ci-icon with a custom border class', () => { + expect(findCiIcon().exists()).toBe(true); + + expect(findCiIcon().classes('gl-border')).toBe(true); + }); + + it('should render the correct ci status icon', () => { + expect(findCiIcon().classes('ci-status-icon-running')).toBe(true); + }); + + it('should have an activated tooltip', () => { + expect(findLinkedPipelineMiniItem().exists()).toBe(true); + const tooltip = getBinding(findLinkedPipelineMiniItem().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe('GitLabCE - running'); + }); + + it('should correctly set is-upstream', () => { + expect(findLinkedPipelineMiniList().exists()).toBe(true); + + expect(findLinkedPipelineMiniList().classes('is-upstream')).toBe(true); + }); + + it('should correctly compute shouldRenderCounter', () => { + expect(findLinkedPipelineMiniList().vm.shouldRenderCounter).toBe(false); + }); + + it('should not render the pipeline counter', () => { + expect(findLinkedPipelineCounter().exists()).toBe(false); + }); + }); + + describe('when passed downstream pipelines as props', () => { + beforeEach(() => { + createComponent({ + triggered: mockData.triggered, + pipelinePath: 'my/pipeline/path', + }); + }); + + it('should render three linked pipeline items', () => { + expect(findLinkedPipelineMiniItems().exists()).toBe(true); + expect(findLinkedPipelineMiniItems().length).toBe(3); + }); + + it('should render three ci status icons', () => { + expect(findCiIcons().exists()).toBe(true); + expect(findCiIcons().length).toBe(3); + }); + + it('should render the correct ci status icon', () => { + expect(findCiIcon().classes('ci-status-icon-running')).toBe(true); + }); + + it('should have an activated tooltip', () => { + expect(findLinkedPipelineMiniItem().exists()).toBe(true); + const tooltip = getBinding(findLinkedPipelineMiniItem().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe('GitLabCE - running'); + }); + + it('should correctly set is-downstream', () => { + expect(findLinkedPipelineMiniList().exists()).toBe(true); + + expect(findLinkedPipelineMiniList().classes('is-downstream')).toBe(true); + }); + + it('should render a borderless ci-icon', () => { + expect(findCiIcon().exists()).toBe(true); + + expect(findCiIcon().props('isBorderless')).toBe(true); + expect(findCiIcon().classes('borderless')).toBe(true); + }); + + it('should render a ci-icon with a custom border class', () => { + expect(findCiIcon().exists()).toBe(true); + + expect(findCiIcon().classes('gl-border')).toBe(true); + }); + + it('should render the pipeline counter', () => { + expect(findLinkedPipelineCounter().exists()).toBe(true); + }); + + it('should correctly compute shouldRenderCounter', () => { + expect(findLinkedPipelineMiniList().vm.shouldRenderCounter).toBe(true); + }); + + it('should correctly trim linkedPipelines', () => { + expect(findLinkedPipelineMiniList().props('triggered').length).toBe(6); + expect(findLinkedPipelineMiniList().vm.linkedPipelinesTrimmed.length).toBe(3); + }); + + it('should set the correct pipeline path', () => { + expect(findLinkedPipelineCounter().exists()).toBe(true); + + expect(findLinkedPipelineCounter().attributes('href')).toBe('my/pipeline/path'); + }); + + it('should render the correct counterTooltipText', () => { + expect(findLinkedPipelineCounter().exists()).toBe(true); + const tooltip = getBinding(findLinkedPipelineCounter().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(findLinkedPipelineMiniList().vm.counterTooltipText); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mock_data.js b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mock_data.js new file mode 100644 index 00000000000..117c7f2ae52 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mock_data.js @@ -0,0 +1,407 @@ +export default { + triggered_by: { + id: 129, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/129', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/129', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: '7-5-stable', + path: '/gitlab-org/gitlab-foss/commits/7-5-stable', + tag: false, + branch: true, + }, + commit: { + id: '23433d4d8b20d7e45c103d0b6048faad38a130ab', + short_id: '23433d4d', + title: 'Version 7.5.0.rc1', + created_at: '2014-11-17T15:44:14.000+01:00', + parent_ids: ['30ac909f30f58d319b42ed1537664483894b18cd'], + message: 'Version 7.5.0.rc1\n', + author_name: 'Jacob Vosmaer', + author_email: 'contact@jacobvosmaer.nl', + authored_date: '2014-11-17T15:44:14.000+01:00', + committer_name: 'Jacob Vosmaer', + committer_email: 'contact@jacobvosmaer.nl', + committed_date: '2014-11-17T15:44:14.000+01:00', + author_gravatar_url: + 'http://www.gravatar.com/avatar/e66d11c0eedf8c07b3b18fca46599807?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/23433d4d8b20d7e45c103d0b6048faad38a130ab', + commit_path: '/gitlab-org/gitlab-foss/commit/23433d4d8b20d7e45c103d0b6048faad38a130ab', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/129/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/129/cancel', + created_at: '2017-05-24T14:46:20.090Z', + updated_at: '2017-05-24T14:46:29.906Z', + }, + triggered: [ + { + id: 132, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/132', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/132', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: 'b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + short_id: 'b9d58c4c', + title: 'getting user keys publically through http without any authentication, the github…', + created_at: '2013-10-03T12:50:33.000+05:30', + parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], + message: + 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n\nchangelog updated to include ssh key retrieval feature update\n', + author_name: 'devaroop', + author_email: 'devaroop123@yahoo.co.in', + authored_date: '2013-10-02T20:39:29.000+05:30', + committer_name: 'devaroop', + committer_email: 'devaroop123@yahoo.co.in', + committed_date: '2013-10-03T12:50:33.000+05:30', + author_gravatar_url: + 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + commit_path: '/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/132/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/132/cancel', + created_at: '2017-05-24T14:46:24.644Z', + updated_at: '2017-05-24T14:48:55.226Z', + }, + { + id: 133, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/133', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/133', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: 'b6bd4856a33df3d144be66c4ed1f1396009bb08b', + short_id: 'b6bd4856', + title: 'getting user keys publically through http without any authentication, the github…', + created_at: '2013-10-02T20:39:29.000+05:30', + parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], + message: + 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n', + author_name: 'devaroop', + author_email: 'devaroop123@yahoo.co.in', + authored_date: '2013-10-02T20:39:29.000+05:30', + committer_name: 'devaroop', + committer_email: 'devaroop123@yahoo.co.in', + committed_date: '2013-10-02T20:39:29.000+05:30', + author_gravatar_url: + 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', + commit_path: '/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/133/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/133/cancel', + created_at: '2017-05-24T14:46:24.648Z', + updated_at: '2017-05-24T14:48:59.673Z', + }, + { + id: 130, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/130', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/130', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: '6d7ced4a2311eeff037c5575cca1868a6d3f586f', + short_id: '6d7ced4a', + title: 'Whitespace fixes to patch', + created_at: '2013-10-08T13:53:22.000-05:00', + parent_ids: ['1875141a963a4238bda29011d8f7105839485253'], + message: 'Whitespace fixes to patch\n', + author_name: 'Dale Hamel', + author_email: 'dale.hamel@srvthe.net', + authored_date: '2013-10-08T13:53:22.000-05:00', + committer_name: 'Dale Hamel', + committer_email: 'dale.hamel@invenia.ca', + committed_date: '2013-10-08T13:53:22.000-05:00', + author_gravatar_url: + 'http://www.gravatar.com/avatar/cd08930e69fa5ad1a669206e7bafe476?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', + commit_path: '/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/130/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/130/cancel', + created_at: '2017-05-24T14:46:24.630Z', + updated_at: '2017-05-24T14:49:45.091Z', + }, + { + id: 131, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/132', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/132', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: 'b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + short_id: 'b9d58c4c', + title: 'getting user keys publically through http without any authentication, the github…', + created_at: '2013-10-03T12:50:33.000+05:30', + parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], + message: + 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n\nchangelog updated to include ssh key retrieval feature update\n', + author_name: 'devaroop', + author_email: 'devaroop123@yahoo.co.in', + authored_date: '2013-10-02T20:39:29.000+05:30', + committer_name: 'devaroop', + committer_email: 'devaroop123@yahoo.co.in', + committed_date: '2013-10-03T12:50:33.000+05:30', + author_gravatar_url: + 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + commit_path: '/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/132/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/132/cancel', + created_at: '2017-05-24T14:46:24.644Z', + updated_at: '2017-05-24T14:48:55.226Z', + }, + { + id: 134, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/133', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/133', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: 'b6bd4856a33df3d144be66c4ed1f1396009bb08b', + short_id: 'b6bd4856', + title: 'getting user keys publically through http without any authentication, the github…', + created_at: '2013-10-02T20:39:29.000+05:30', + parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], + message: + 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n', + author_name: 'devaroop', + author_email: 'devaroop123@yahoo.co.in', + authored_date: '2013-10-02T20:39:29.000+05:30', + committer_name: 'devaroop', + committer_email: 'devaroop123@yahoo.co.in', + committed_date: '2013-10-02T20:39:29.000+05:30', + author_gravatar_url: + 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', + commit_path: '/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/133/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/133/cancel', + created_at: '2017-05-24T14:46:24.648Z', + updated_at: '2017-05-24T14:48:59.673Z', + }, + { + id: 135, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/130', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/130', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: '6d7ced4a2311eeff037c5575cca1868a6d3f586f', + short_id: '6d7ced4a', + title: 'Whitespace fixes to patch', + created_at: '2013-10-08T13:53:22.000-05:00', + parent_ids: ['1875141a963a4238bda29011d8f7105839485253'], + message: 'Whitespace fixes to patch\n', + author_name: 'Dale Hamel', + author_email: 'dale.hamel@srvthe.net', + authored_date: '2013-10-08T13:53:22.000-05:00', + committer_name: 'Dale Hamel', + committer_email: 'dale.hamel@invenia.ca', + committed_date: '2013-10-08T13:53:22.000-05:00', + author_gravatar_url: + 'http://www.gravatar.com/avatar/cd08930e69fa5ad1a669206e7bafe476?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', + commit_path: '/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/130/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/130/cancel', + created_at: '2017-05-24T14:46:24.630Z', + updated_at: '2017-05-24T14:49:45.091Z', + }, + ], +}; diff --git a/spec/frontend/ci/pipeline_mini_graph/mock_data.js b/spec/frontend/ci/pipeline_mini_graph/mock_data.js new file mode 100644 index 00000000000..231375b40dd --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/mock_data.js @@ -0,0 +1,252 @@ +export const mockDownstreamPipelinesGraphql = ({ includeSourceJobRetried = true } = {}) => ({ + nodes: [ + { + id: 'gid://gitlab/Ci::Pipeline/612', + path: '/root/job-log-sections/-/pipelines/612', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-612-612', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/532', + retried: includeSourceJobRetried ? false : null, + }, + __typename: 'Pipeline', + }, + { + id: 'gid://gitlab/Ci::Pipeline/611', + path: '/root/job-log-sections/-/pipelines/611', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-611-611', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/531', + retried: includeSourceJobRetried ? true : null, + }, + __typename: 'Pipeline', + }, + { + id: 'gid://gitlab/Ci::Pipeline/609', + path: '/root/job-log-sections/-/pipelines/609', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-609-609', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/530', + retried: includeSourceJobRetried ? true : null, + }, + __typename: 'Pipeline', + }, + ], + __typename: 'PipelineConnection', +}); + +const upstream = { + id: 'gid://gitlab/Ci::Pipeline/610', + path: '/root/trigger-downstream/-/pipelines/610', + project: { + id: 'gid://gitlab/Project/21', + name: 'trigger-downstream', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-610-610', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', +}; + +export const mockPipelineStagesQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + stages: { + nodes: [ + { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/409', + name: 'build', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-409-409', + icon: 'status_success', + group: 'success', + }, + }, + ], + }, + }, + }, + }, +}; + +export const mockPipelineStatusResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + detailedStatus: { + id: 'pending-320-320', + detailsPath: '/root/ci-project/-/pipelines/320', + icon: 'status_pending', + group: 'pending', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; + +export const mockUpstreamDownstreamQueryResponse = { + data: { + project: { + id: '1', + pipeline: { + id: 'pipeline-1', + path: '/root/ci-project/-/pipelines/790', + downstream: mockDownstreamPipelinesGraphql(), + upstream, + }, + __typename: 'Project', + }, + }, +}; + +export const linkedPipelinesFetchError = 'There was a problem fetching linked pipelines.'; +export const stagesFetchError = 'There was a problem fetching the pipeline stages.'; + +export const stageReply = { + name: 'deploy', + title: 'deploy: running', + latest_statuses: [ + { + id: 928, + name: 'stop staging', + started: false, + build_path: '/twitter/flight/-/jobs/928', + cancel_path: '/twitter/flight/-/jobs/928/cancel', + playable: false, + created_at: '2018-04-04T20:02:02.728Z', + updated_at: '2018-04-04T20:02:02.766Z', + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/twitter/flight/-/jobs/928', + favicon: + '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/twitter/flight/-/jobs/928/cancel', + method: 'post', + }, + }, + }, + { + id: 926, + name: 'production', + started: false, + build_path: '/twitter/flight/-/jobs/926', + retry_path: '/twitter/flight/-/jobs/926/retry', + play_path: '/twitter/flight/-/jobs/926/play', + playable: true, + created_at: '2018-04-04T20:00:57.202Z', + updated_at: '2018-04-04T20:11:13.110Z', + status: { + icon: 'status_canceled', + text: 'canceled', + label: 'manual play action', + group: 'canceled', + tooltip: 'canceled', + has_details: true, + details_path: '/twitter/flight/-/jobs/926', + favicon: + '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico', + action: { + icon: 'play', + title: 'Play', + path: '/twitter/flight/-/jobs/926/play', + method: 'post', + }, + }, + }, + { + id: 217, + name: 'staging', + started: '2018-03-07T08:41:46.234Z', + build_path: '/twitter/flight/-/jobs/217', + retry_path: '/twitter/flight/-/jobs/217/retry', + playable: false, + created_at: '2018-03-07T14:41:58.093Z', + updated_at: '2018-03-07T14:41:58.093Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/twitter/flight/-/jobs/217', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/twitter/flight/-/jobs/217/retry', + method: 'post', + }, + }, + }, + ], + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + tooltip: 'running', + has_details: true, + details_path: '/twitter/flight/pipelines/13#deploy', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + path: '/twitter/flight/pipelines/13#deploy', + dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy', +}; diff --git a/spec/frontend/ci/pipeline_mini_graph/pipeline_mini_graph_spec.js b/spec/frontend/ci/pipeline_mini_graph/pipeline_mini_graph_spec.js new file mode 100644 index 00000000000..6833726a297 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/pipeline_mini_graph_spec.js @@ -0,0 +1,123 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; + +import { createAlert } from '~/alert'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; +import * as sharedGraphQlUtils from '~/graphql_shared/utils'; + +import { + linkedPipelinesFetchError, + stagesFetchError, + mockPipelineStagesQueryResponse, + mockUpstreamDownstreamQueryResponse, +} from './mock_data'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('PipelineMiniGraph', () => { + let wrapper; + let linkedPipelinesResponse; + let pipelineStagesResponse; + + const fullPath = 'gitlab-org/gitlab'; + const iid = '315'; + const pipelineEtag = '/api/graphql:pipelines/id/315'; + + const createComponent = ({ + pipelineStagesHandler = pipelineStagesResponse, + linkedPipelinesHandler = linkedPipelinesResponse, + } = {}) => { + const handlers = [ + [getLinkedPipelinesQuery, linkedPipelinesHandler], + [getPipelineStagesQuery, pipelineStagesHandler], + ]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(PipelineMiniGraph, { + propsData: { + fullPath, + iid, + pipelineEtag, + }, + apolloProvider: mockApollo, + }); + + return waitForPromises(); + }; + + const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(() => { + linkedPipelinesResponse = jest.fn().mockResolvedValue(mockUpstreamDownstreamQueryResponse); + pipelineStagesResponse = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse); + }); + + describe('when initial queries are loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows a loading icon and no mini graph', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findLegacyPipelineMiniGraph().exists()).toBe(false); + }); + }); + + describe('when queries have loaded', () => { + it('does not show a loading icon', async () => { + await createComponent(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders the Pipeline Mini Graph', async () => { + await createComponent(); + + expect(findLegacyPipelineMiniGraph().exists()).toBe(true); + }); + + it('fires the queries', async () => { + await createComponent(); + + expect(linkedPipelinesResponse).toHaveBeenCalledWith({ iid, fullPath }); + expect(pipelineStagesResponse).toHaveBeenCalledWith({ iid, fullPath }); + }); + }); + + describe('polling', () => { + it('toggles query polling with visibility check', async () => { + jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility'); + + createComponent(); + + await waitForPromises(); + + expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledTimes(2); + }); + }); + + describe('when pipeline queries are unsuccessful', () => { + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + it.each` + query | handlerName | errorMessage + ${'pipeline stages'} | ${'pipelineStagesHandler'} | ${stagesFetchError} + ${'linked pipelines'} | ${'linkedPipelinesHandler'} | ${linkedPipelinesFetchError} + `('throws an error for the $query query', async ({ errorMessage, handlerName }) => { + await createComponent({ [handlerName]: failedHandler }); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/pipeline_stage_spec.js new file mode 100644 index 00000000000..96966bcbb84 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/pipeline_stage_spec.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +import getPipelineStageQuery from '~/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stage.query.graphql'; +import PipelineStage from '~/ci/pipeline_mini_graph/pipeline_stage.vue'; + +Vue.use(VueApollo); + +describe('PipelineStage', () => { + let wrapper; + let pipelineStageResponse; + + const defaultProps = { + pipelineEtag: '/etag', + stageId: '1', + }; + + const createComponent = ({ pipelineStageHandler = pipelineStageResponse } = {}) => { + const handlers = [[getPipelineStageQuery, pipelineStageHandler]]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(PipelineStage, { + propsData: { + ...defaultProps, + }, + apolloProvider: mockApollo, + }); + + return waitForPromises(); + }; + + const findPipelineStage = () => wrapper.findComponent(PipelineStage); + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders job item', () => { + expect(findPipelineStage().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/pipeline_stages_spec.js b/spec/frontend/ci/pipeline_mini_graph/pipeline_stages_spec.js new file mode 100644 index 00000000000..bbd39c6fcd9 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/pipeline_stages_spec.js @@ -0,0 +1,63 @@ +import { shallowMount } from '@vue/test-utils'; +import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; +import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue'; +import PipelineStages from '~/ci/pipeline_mini_graph/pipeline_stages.vue'; + +const mockStages = pipelines[0].details.stages; + +describe('Pipeline Stages', () => { + let wrapper; + + const findLegacyPipelineStages = () => wrapper.findAllComponents(LegacyPipelineStage); + const findPipelineStagesAt = (i) => findLegacyPipelineStages().at(i); + + const createComponent = (props = {}) => { + wrapper = shallowMount(PipelineStages, { + propsData: { + stages: mockStages, + ...props, + }, + }); + }; + + it('renders stages', () => { + createComponent(); + + expect(findLegacyPipelineStages()).toHaveLength(mockStages.length); + }); + + it('does not fail when stages are empty', () => { + createComponent({ stages: [] }); + + expect(wrapper.exists()).toBe(true); + expect(findLegacyPipelineStages()).toHaveLength(0); + }); + + it('update dropdown is false by default', () => { + createComponent(); + + expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(false); + expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(false); + }); + + it('update dropdown is set to true', () => { + createComponent({ updateDropdown: true }); + + expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(true); + expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(true); + }); + + it('is merge train is false by default', () => { + createComponent(); + + expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(false); + expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(false); + }); + + it('is merge train is set to true', () => { + createComponent({ isMergeTrain: true }); + + expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(true); + expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(true); + }); +}); |