diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 14:18:50 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 14:18:50 +0300 |
commit | 8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch) | |
tree | a77e7fe7a93de11213032ed4ab1f33a3db51b738 /spec/frontend/pipelines | |
parent | 00b35af3db1abfe813a778f643dad221aad51fca (diff) |
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'spec/frontend/pipelines')
14 files changed, 1559 insertions, 28 deletions
diff --git a/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap b/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap new file mode 100644 index 00000000000..629efc6d3fa --- /dev/null +++ b/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap @@ -0,0 +1,230 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The DAG graph in the basic case renders the graph svg 1`] = ` +"<svg viewBox=\\"0,0,1000,540\\" width=\\"1000\\" height=\\"540\\"> + <g fill=\\"none\\" stroke-opacity=\\"0.8\\"> + <g id=\\"dag-link43\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad53\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\"> + <stop offset=\\"0%\\" stop-color=\\"#e17223\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#83ab4a\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip63\\"> + <path d=\\" + M100, 129 + V158 + H377.3333333333333 + V100 + H100 + Z + \\"></path> + </clipPath> + <path d=\\"M108,129L190,129L190,129L369.3333333333333,129\\" stroke=\\"url(#dag-grad53)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip63)\\"></path> + </g> + <g id=\\"dag-link44\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad54\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\"> + <stop offset=\\"0%\\" stop-color=\\"#83ab4a\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#6f3500\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip64\\"> + <path d=\\" + M361.3333333333333, 129.0000000000002 + V158.0000000000002 + H638.6666666666666 + V100 + H361.3333333333333 + Z + \\"></path> + </clipPath> + <path d=\\"M369.3333333333333,129L509.3333333333333,129L509.3333333333333,129.0000000000002L630.6666666666666,129.0000000000002\\" stroke=\\"url(#dag-grad54)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip64)\\"></path> + </g> + <g id=\\"dag-link45\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad55\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"622.6666666666666\\"> + <stop offset=\\"0%\\" stop-color=\\"#5772ff\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#6f3500\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip65\\"> + <path d=\\" + M100, 187.0000000000002 + V241.00000000000003 + H638.6666666666666 + V158.0000000000002 + H100 + Z + \\"></path> + </clipPath> + <path d=\\"M108,212.00000000000003L306,212.00000000000003L306,187.0000000000002L630.6666666666666,187.0000000000002\\" stroke=\\"url(#dag-grad55)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip65)\\"></path> + </g> + <g id=\\"dag-link46\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad56\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\"> + <stop offset=\\"0%\\" stop-color=\\"#b24800\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#006887\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip66\\"> + <path d=\\" + M100, 269.9999999999998 + V324 + H377.3333333333333 + V240.99999999999977 + H100 + Z + \\"></path> + </clipPath> + <path d=\\"M108,295L338.93333333333334,295L338.93333333333334,269.9999999999998L369.3333333333333,269.9999999999998\\" stroke=\\"url(#dag-grad56)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip66)\\"></path> + </g> + <g id=\\"dag-link47\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad57\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\"> + <stop offset=\\"0%\\" stop-color=\\"#25d2d2\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#487900\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip67\\"> + <path d=\\" + M100, 352.99999999999994 + V407.00000000000006 + H377.3333333333333 + V323.99999999999994 + H100 + Z + \\"></path> + </clipPath> + <path d=\\"M108,378.00000000000006L144.66666666666669,378.00000000000006L144.66666666666669,352.99999999999994L369.3333333333333,352.99999999999994\\" stroke=\\"url(#dag-grad57)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip67)\\"></path> + </g> + <g id=\\"dag-link48\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad58\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\"> + <stop offset=\\"0%\\" stop-color=\\"#006887\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#d84280\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip68\\"> + <path d=\\" + M361.3333333333333, 270.0000000000001 + V299.0000000000001 + H638.6666666666666 + V240.99999999999977 + H361.3333333333333 + Z + \\"></path> + </clipPath> + <path d=\\"M369.3333333333333,269.9999999999998L464,269.9999999999998L464,270.0000000000001L630.6666666666666,270.0000000000001\\" stroke=\\"url(#dag-grad58)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip68)\\"></path> + </g> + <g id=\\"dag-link49\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad59\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\"> + <stop offset=\\"0%\\" stop-color=\\"#487900\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#d84280\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip69\\"> + <path d=\\" + M361.3333333333333, 328.0000000000001 + V381.99999999999994 + H638.6666666666666 + V299.0000000000001 + H361.3333333333333 + Z + \\"></path> + </clipPath> + <path d=\\"M369.3333333333333,352.99999999999994L522,352.99999999999994L522,328.0000000000001L630.6666666666666,328.0000000000001\\" stroke=\\"url(#dag-grad59)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip69)\\"></path> + </g> + <g id=\\"dag-link50\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad60\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\"> + <stop offset=\\"0%\\" stop-color=\\"#487900\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#3547de\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip70\\"> + <path d=\\" + M361.3333333333333, 411 + V440 + H638.6666666666666 + V381.99999999999994 + H361.3333333333333 + Z + \\"></path> + </clipPath> + <path d=\\"M369.3333333333333,410.99999999999994L580,410.99999999999994L580,411L630.6666666666666,411\\" stroke=\\"url(#dag-grad60)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip70)\\"></path> + </g> + <g id=\\"dag-link51\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad61\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"638.6666666666666\\" x2=\\"884\\"> + <stop offset=\\"0%\\" stop-color=\\"#d84280\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#006887\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip71\\"> + <path d=\\" + M622.6666666666666, 270.1890725105691 + V299.1890725105691 + H900 + V241.0000000000001 + H622.6666666666666 + Z + \\"></path> + </clipPath> + <path d=\\"M630.6666666666666,270.0000000000001L861.6,270.0000000000001L861.6,270.1890725105691L892,270.1890725105691\\" stroke=\\"url(#dag-grad61)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip71)\\"></path> + </g> + <g id=\\"dag-link52\\" class=\\"dag-link gl-cursor-pointer\\"> + <linearGradient id=\\"dag-grad62\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"638.6666666666666\\" x2=\\"884\\"> + <stop offset=\\"0%\\" stop-color=\\"#3547de\\"></stop> + <stop offset=\\"100%\\" stop-color=\\"#275600\\"></stop> + </linearGradient> + <clipPath id=\\"dag-clip72\\"> + <path d=\\" + M622.6666666666666, 411 + V440 + H900 + V382 + H622.6666666666666 + Z + \\"></path> + </clipPath> + <path d=\\"M630.6666666666666,411L679.9999999999999,411L679.9999999999999,411L892,411\\" stroke=\\"url(#dag-grad62)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip72)\\"></path> + </g> + </g> + <g> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node73\\" stroke=\\"#e17223\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"104\\" y2=\\"154.00000000000003\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node74\\" stroke=\\"#83ab4a\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"104\\" y2=\\"154\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node75\\" stroke=\\"#5772ff\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"187.00000000000003\\" y2=\\"237.00000000000003\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node76\\" stroke=\\"#b24800\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"270\\" y2=\\"320.00000000000006\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node77\\" stroke=\\"#25d2d2\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"353.00000000000006\\" y2=\\"403.0000000000001\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node78\\" stroke=\\"#6f3500\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"104.0000000000002\\" y2=\\"212.00000000000009\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node79\\" stroke=\\"#006887\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"244.99999999999977\\" y2=\\"294.99999999999994\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node80\\" stroke=\\"#487900\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"327.99999999999994\\" y2=\\"436\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node81\\" stroke=\\"#d84280\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"245.00000000000009\\" y2=\\"353\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node82\\" stroke=\\"#3547de\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"386\\" y2=\\"436\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node83\\" stroke=\\"#006887\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"892\\" x2=\\"892\\" y1=\\"245.18907251056908\\" y2=\\"295.1890725105691\\"></line> + <line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node84\\" stroke=\\"#275600\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"892\\" x2=\\"892\\" y1=\\"386\\" y2=\\"436\\"></line> + </g> + <g class=\\"gl-font-sm\\"> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58.00000000000003px\\" width=\\"84\\" x=\\"8\\" y=\\"100\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58.00000000000003px; text-align: right;\\">build_a</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"369.3333333333333\\" y=\\"75\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: left;\\">test_a</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58px\\" width=\\"84\\" x=\\"8\\" y=\\"183.00000000000003\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58px; text-align: right;\\">test_b</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58.00000000000006px\\" width=\\"84\\" x=\\"8\\" y=\\"266\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58.00000000000006px; text-align: right;\\">post_test_a</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58.00000000000006px\\" width=\\"84\\" x=\\"8\\" y=\\"349.00000000000006\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58.00000000000006px; text-align: right;\\">post_test_b</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"630.6666666666666\\" y=\\"75.0000000000002\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: right;\\">post_test_c</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"369.3333333333333\\" y=\\"215.99999999999977\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: left;\\">staging_a</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"369.3333333333333\\" y=\\"298.99999999999994\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: left;\\">staging_b</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"630.6666666666666\\" y=\\"216.00000000000009\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: right;\\">canary_a</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"630.6666666666666\\" y=\\"357\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: right;\\">canary_c</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58px\\" width=\\"84\\" x=\\"908\\" y=\\"241.18907251056908\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58px; text-align: left;\\">production_a</div> + </foreignObject> + <foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58px\\" width=\\"84\\" x=\\"908\\" y=\\"382\\" class=\\"gl-overflow-visible\\"> + <div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58px; text-align: left;\\">production_d</div> + </foreignObject> + </g> +</svg>" +`; diff --git a/spec/frontend/pipelines/components/dag/dag_graph_spec.js b/spec/frontend/pipelines/components/dag/dag_graph_spec.js new file mode 100644 index 00000000000..017461dfb84 --- /dev/null +++ b/spec/frontend/pipelines/components/dag/dag_graph_spec.js @@ -0,0 +1,218 @@ +import { mount } from '@vue/test-utils'; +import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; +import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants'; +import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions'; +import { createSankey } from '~/pipelines/components/dag/drawing_utils'; +import { removeOrphanNodes } from '~/pipelines/components/dag/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 = mount(DagGraph, { + attachToDocument: true, + propsData, + data() { + return { + color: () => {}, + width: 0, + height: 0, + }; + }, + }); + }; + + beforeEach(() => { + createComponent({ graphData: parsedData }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + 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/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js new file mode 100644 index 00000000000..666b4cfaa2f --- /dev/null +++ b/spec/frontend/pipelines/components/dag/dag_spec.js @@ -0,0 +1,137 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { GlAlert } from '@gitlab/ui'; +import Dag from '~/pipelines/components/dag/dag.vue'; +import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; + +import { + DEFAULT, + PARSE_FAILURE, + LOAD_FAILURE, + UNSUPPORTED_DATA, +} from '~/pipelines/components/dag//constants'; +import { mockBaseData, tooSmallGraph, unparseableGraph } from './mock_data'; + +describe('Pipeline DAG graph wrapper', () => { + let wrapper; + let mock; + const getAlert = () => wrapper.find(GlAlert); + const getAllAlerts = () => wrapper.findAll(GlAlert); + const getGraph = () => wrapper.find(DagGraph); + const getErrorText = type => wrapper.vm.$options.errorTexts[type]; + + const dataPath = '/root/test/pipelines/90/dag.json'; + + const createComponent = (propsData = {}, method = shallowMount) => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = method(Dag, { + propsData, + data() { + return { + showFailureAlert: false, + }; + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + wrapper = null; + }); + + describe('when there is no dataUrl', () => { + beforeEach(() => { + createComponent({ graphUrl: undefined }); + }); + + it('shows the DEFAULT alert and not the graph', () => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(DEFAULT)); + expect(getGraph().exists()).toBe(false); + }); + }); + + describe('when there is a dataUrl', () => { + describe('but the data fetch fails', () => { + beforeEach(() => { + mock.onGet(dataPath).replyOnce(500); + createComponent({ graphUrl: dataPath }); + }); + + it('shows the LOAD_FAILURE alert and not the graph', () => { + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(LOAD_FAILURE)); + expect(getGraph().exists()).toBe(false); + }); + }); + }); + + describe('the data fetch succeeds but the parse fails', () => { + beforeEach(() => { + mock.onGet(dataPath).replyOnce(200, unparseableGraph); + createComponent({ graphUrl: dataPath }); + }); + + it('shows the PARSE_FAILURE alert and not the graph', () => { + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(PARSE_FAILURE)); + expect(getGraph().exists()).toBe(false); + }); + }); + }); + + describe('and the data fetch and parse succeeds', () => { + beforeEach(() => { + mock.onGet(dataPath).replyOnce(200, mockBaseData); + createComponent({ graphUrl: dataPath }, mount); + }); + + it('shows the graph and not the beta alert', () => { + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(getAllAlerts().length).toBe(1); + expect(getAlert().text()).toContain('This feature is currently in beta.'); + expect(getGraph().exists()).toBe(true); + }); + }); + }); + + describe('the data fetch and parse succeeds, but the resulting graph is too small', () => { + beforeEach(() => { + mock.onGet(dataPath).replyOnce(200, tooSmallGraph); + createComponent({ graphUrl: dataPath }); + }); + + it('shows the UNSUPPORTED_DATA alert and not the graph', () => { + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(UNSUPPORTED_DATA)); + expect(getGraph().exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/dag/drawing_utils_spec.js b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js new file mode 100644 index 00000000000..a50163411ed --- /dev/null +++ b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js @@ -0,0 +1,57 @@ +import { createSankey } from '~/pipelines/components/dag/drawing_utils'; +import { parseData } from '~/pipelines/components/dag/parsing_utils'; +import { mockBaseData } from './mock_data'; + +describe('DAG visualization drawing utilities', () => { + const parsed = parseData(mockBaseData.stages); + + 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/pipelines/components/dag/mock_data.js b/spec/frontend/pipelines/components/dag/mock_data.js new file mode 100644 index 00000000000..5de8697170a --- /dev/null +++ b/spec/frontend/pipelines/components/dag/mock_data.js @@ -0,0 +1,390 @@ +/* + It is important that the simple 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 mockBaseData = { + stages: [ + { + 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: 'fixtures', + groups: [ + { + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + ], + }, + { + name: 'un-needed', + groups: [ + { + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, + ], + }, + ], +}; + +export const tooSmallGraph = { + stages: [ + { + name: 'test', + groups: [ + { + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], + }, + ], + }, + { + name: 'fixtures', + groups: [ + { + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + ], + }, + { + name: 'un-needed', + groups: [ + { + 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, + }, + ], +}; diff --git a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js new file mode 100644 index 00000000000..d9a1296e572 --- /dev/null +++ b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js @@ -0,0 +1,133 @@ +import { + createNodesStructure, + makeLinksFromNodes, + filterByAncestors, + parseData, + removeOrphanNodes, + getMaxNodes, +} from '~/pipelines/components/dag/parsing_utils'; + +import { createSankey } from '~/pipelines/components/dag/drawing_utils'; +import { mockBaseData } from './mock_data'; + +describe('DAG visualization parsing utilities', () => { + const { nodes, nodeDict } = createNodesStructure(mockBaseData.stages); + const unfilteredLinks = makeLinksFromNodes(nodes, nodeDict); + const parsed = parseData(mockBaseData.stages); + + const layoutSettings = { + width: 200, + height: 200, + nodeWidth: 10, + nodePadding: 20, + paddingForLabels: 100, + }; + + const sankeyLayout = createSankey(layoutSettings)(parsed); + + describe('createNodesStructure', () => { + const parallelGroupName = 'jest'; + const parallelJobName = 'jest 1/2'; + const singleJobName = 'frontend fixtures'; + + const { name, jobs, size } = mockBaseData.stages[0].groups[0]; + + it('returns the expected node structure', () => { + expect(nodes[0]).toHaveProperty('category', mockBaseData.stages[0].name); + expect(nodes[0]).toHaveProperty('name', name); + expect(nodes[0]).toHaveProperty('jobs', jobs); + expect(nodes[0]).toHaveProperty('size', size); + }); + + it('adds needs to top level of nodeDict entries', () => { + expect(nodeDict[parallelGroupName]).toHaveProperty('needs'); + expect(nodeDict[parallelJobName]).toHaveProperty('needs'); + expect(nodeDict[singleJobName]).toHaveProperty('needs'); + }); + + it('makes entries in nodeDict for jobs and parallel jobs', () => { + const nodeNames = Object.keys(nodeDict); + + expect(nodeNames.includes(parallelGroupName)).toBe(true); + expect(nodeNames.includes(parallelJobName)).toBe(true); + expect(nodeNames.includes(singleJobName)).toBe(true); + }); + }); + + describe('makeLinksFromNodes', () => { + it('returns the expected link structure', () => { + expect(unfilteredLinks[0]).toHaveProperty('source', 'frontend fixtures'); + expect(unfilteredLinks[0]).toHaveProperty('target', 'jest'); + expect(unfilteredLinks[0]).toHaveProperty('value', 10); + }); + }); + + 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 cleanedNodes = removeOrphanNodes(sankeyLayout.nodes); + expect(cleanedNodes).toHaveLength(sankeyLayout.nodes.length - 1); + }); + }); + + 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); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index 12c6fab9c41..bdc807fcbfe 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -3,13 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import PipelinesFilteredSearch from '~/pipelines/components/pipelines_filtered_search.vue'; -import { - users, - mockSearch, - pipelineWithStages, - branches, - mockBranchesAfterMap, -} from '../mock_data'; +import { users, mockSearch, branches, tags } from '../mock_data'; import { GlFilteredSearch } from '@gitlab/ui'; describe('Pipelines filtered search', () => { @@ -21,12 +15,16 @@ describe('Pipelines filtered search', () => { 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 createComponent = () => { + const createComponent = (params = {}) => { wrapper = mount(PipelinesFilteredSearch, { propsData: { - pipelines: [pipelineWithStages], projectId: '21', + params, }, attachToDocument: true, }); @@ -37,6 +35,7 @@ describe('Pipelines filtered search', () => { jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); createComponent(); }); @@ -55,37 +54,39 @@ describe('Pipelines filtered search', () => { }); it('displays search tokens', () => { - expect(getSearchToken('username')).toMatchObject({ + expect(findUserToken()).toMatchObject({ type: 'username', icon: 'user', title: 'Trigger author', unique: true, - triggerAuthors: users, projectId: '21', operators: [expect.objectContaining({ value: '=' })], }); - expect(getSearchToken('ref')).toMatchObject({ + expect(findBranchToken()).toMatchObject({ type: 'ref', icon: 'branch', title: 'Branch name', unique: true, - branches: mockBranchesAfterMap, projectId: '21', operators: [expect.objectContaining({ value: '=' })], }); - }); - - it('fetches and sets project users', () => { - expect(Api.projectUsers).toHaveBeenCalled(); - - expect(wrapper.vm.projectUsers).toEqual(users); - }); - it('fetches and sets branches', () => { - expect(Api.branches).toHaveBeenCalled(); + expect(findStatusToken()).toMatchObject({ + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + operators: [expect.objectContaining({ value: '=' })], + }); - expect(wrapper.vm.projectBranches).toEqual(mockBranchesAfterMap); + expect(findTagToken()).toMatchObject({ + type: 'tag', + icon: 'tag', + title: 'Tag name', + unique: true, + operators: [expect.objectContaining({ value: '=' })], + }); }); it('emits filterPipelines on submit with correct filter', () => { @@ -94,4 +95,80 @@ describe('Pipelines filtered search', () => { expect(wrapper.emitted('filterPipelines')).toBeTruthy(); expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]); }); + + it('disables tag name token when branch name token is active', () => { + findFilteredSearch().vm.$emit('input', [ + { type: 'ref', value: { data: 'branch-1', operator: '=' } }, + { type: 'filtered-search-term', value: { data: '' } }, + ]); + + return wrapper.vm.$nextTick().then(() => { + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(true); + }); + }); + + it('disables branch name token when tag name token is active', () => { + findFilteredSearch().vm.$emit('input', [ + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: 'filtered-search-term', value: { data: '' } }, + ]); + + return wrapper.vm.$nextTick().then(() => { + expect(findBranchToken().disabled).toBe(true); + expect(findTagToken().disabled).toBe(false); + }); + }); + + it('resets tokens disabled state on clear', () => { + findFilteredSearch().vm.$emit('clearInput'); + + return wrapper.vm.$nextTick().then(() => { + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(false); + }); + }); + + it('resets tokens disabled state when clearing tokens by backspace', () => { + findFilteredSearch().vm.$emit('input', [{ type: 'filtered-search-term', value: { data: '' } }]); + + return wrapper.vm.$nextTick().then(() => { + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(false); + }); + }); + + describe('Url query params', () => { + const params = { + username: 'deja.green', + ref: 'master', + }; + + 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')).toEqual(expectedValueProp); + expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length); + }); + }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index a9b06eab3fa..9731ce3f8a6 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -7,6 +7,7 @@ import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines import graphJSON from './mock_data'; import linkedPipelineJSON from './linked_pipelines_mock_data'; import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; +import { setHTMLFixture } from 'helpers/fixtures'; describe('graph component', () => { const store = new PipelineStore(); @@ -15,6 +16,10 @@ describe('graph component', () => { let wrapper; + beforeEach(() => { + setHTMLFixture('<div class="layout-page"></div>'); + }); + afterEach(() => { wrapper.destroy(); wrapper = null; diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 37c1e471415..e63efc543f1 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -560,9 +560,107 @@ export const branches = [ }, ]; +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: 'master-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: 'master', 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', 'master-tag']; diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 2ddd2116e2c..0eeaef01a2d 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -56,6 +56,7 @@ describe('Pipelines', () => { propsData: { store: new Store(), projectId: '21', + params: {}, ...props, }, methods: { @@ -683,7 +684,13 @@ describe('Pipelines', () => { }); it('updates request data and query params on filter submit', () => { - const expectedQueryParams = { page: '1', scope: 'all', username: 'root', ref: 'master' }; + const expectedQueryParams = { + page: '1', + scope: 'all', + username: 'root', + ref: 'master', + status: 'pending', + }; findFilteredSearch().vm.$emit('submit', mockSearch); diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js index a6753600792..1a85221581e 100644 --- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js @@ -1,7 +1,8 @@ +import Api from '~/api'; import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import PipelineBranchNameToken from '~/pipelines/components/tokens/pipeline_branch_name_token.vue'; -import { branches } from '../mock_data'; +import { branches, mockBranchesAfterMap } from '../mock_data'; describe('Pipeline Branch Name Token', () => { let wrapper; @@ -21,10 +22,9 @@ describe('Pipeline Branch Name Token', () => { type: 'ref', icon: 'branch', title: 'Branch name', - dataType: 'ref', unique: true, - branches, projectId: '21', + disabled: false, }, value: { data: '', @@ -46,6 +46,8 @@ describe('Pipeline Branch Name Token', () => { }; beforeEach(() => { + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + createComponent(); }); @@ -58,6 +60,13 @@ describe('Pipeline Branch Name Token', () => { 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 }); @@ -73,7 +82,7 @@ describe('Pipeline Branch Name Token', () => { }); describe('shows branches correctly', () => { - it('renders all trigger authors', () => { + it('renders all branches', () => { createComponent({ stubs }, { branches, loading: false }); expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length); diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js new file mode 100644 index 00000000000..ee3694868a5 --- /dev/null +++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js @@ -0,0 +1,62 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineStatusToken from '~/pipelines/components/tokens/pipeline_status_token.vue'; + +describe('Pipeline Status Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + const findAllGlIcons = () => wrapper.findAll(GlIcon); + + const stubs = { + GlFilteredSearchToken: { + template: `<div><slot name="suggestions"></slot></div>`, + }, + }; + + const defaultProps = { + config: { + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + }, + value: { + data: '', + }, + }; + + const createComponent = options => { + wrapper = shallowMount(PipelineStatusToken, { + propsData: { + ...defaultProps, + }, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('shows statuses correctly', () => { + beforeEach(() => { + createComponent({ stubs }); + }); + + 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/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js new file mode 100644 index 00000000000..9fecc9412b7 --- /dev/null +++ b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js @@ -0,0 +1,98 @@ +import Api from '~/api'; +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineTagNameToken from '~/pipelines/components/tokens/pipeline_tag_name_token.vue'; +import { tags, mockTagsAfterMap } from '../mock_data'; + +describe('Pipeline Branch Name Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.find(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: '', + }, + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineTagNameToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + 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 = ['master-tag']; + createComponent({ stubs }, { tags: mockTags, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length); + }); + }); +}); diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js index 00a9ff04e75..98de4f40c51 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -1,3 +1,4 @@ +import Api from '~/api'; import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import PipelineTriggerAuthorToken from '~/pipelines/components/tokens/pipeline_trigger_author_token.vue'; @@ -45,6 +46,8 @@ describe('Pipeline Trigger Author Token', () => { }; beforeEach(() => { + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + createComponent(); }); @@ -57,6 +60,13 @@ describe('Pipeline Trigger Author Token', () => { 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({ stubs }, { loading: true }); |