diff options
Diffstat (limited to 'spec/frontend/pipelines/components/dag')
6 files changed, 1165 insertions, 0 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); + }); + }); +}); |